sagedesk 2.0.0 → 2.1.1
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/README.md +7 -3
- package/dist/next/{SageDeskWidget-R3XJ5OUY.js → SageDeskWidget-SJVE6QK3.js} +35 -14
- package/dist/next/SageDeskWidget-SJVE6QK3.js.map +1 -0
- package/dist/next/index.cjs +35 -15
- package/dist/next/index.cjs.map +1 -1
- package/dist/next/index.js +1 -1
- package/dist/react/index.cjs +34 -13
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.js +34 -13
- package/dist/react/index.js.map +1 -1
- package/dist/server/index.cjs +62 -34
- package/dist/server/index.cjs.map +1 -1
- package/dist/server/index.js +62 -34
- package/dist/server/index.js.map +1 -1
- package/dist/vanilla/index.cjs +31 -7
- package/dist/vanilla/index.cjs.map +1 -1
- package/dist/vanilla/index.js +31 -7
- package/dist/vanilla/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/next/SageDeskWidget-R3XJ5OUY.js.map +0 -1
package/dist/server/index.cjs
CHANGED
|
@@ -36,63 +36,75 @@ __export(server_exports, {
|
|
|
36
36
|
module.exports = __toCommonJS(server_exports);
|
|
37
37
|
var import_fs = require("fs");
|
|
38
38
|
|
|
39
|
-
// src/core/embedder.ts
|
|
39
|
+
// src/core/server-embedder.ts
|
|
40
40
|
var XENOVA_IDS = {
|
|
41
41
|
"all-MiniLM-L6-v2": "Xenova/all-MiniLM-L6-v2",
|
|
42
42
|
"bge-small-en-v1-5": "Xenova/bge-small-en-v1.5",
|
|
43
43
|
"paraphrase-multilingual-MiniLM-L12-v2": "Xenova/paraphrase-multilingual-MiniLM-L12-v2",
|
|
44
44
|
"all-mpnet-base-v2": "Xenova/all-mpnet-base-v2"
|
|
45
45
|
};
|
|
46
|
-
var
|
|
46
|
+
var ServerEmbedder = class _ServerEmbedder {
|
|
47
47
|
constructor() {
|
|
48
48
|
this._ready = false;
|
|
49
49
|
this._failed = false;
|
|
50
|
+
this._model = "all-MiniLM-L6-v2";
|
|
50
51
|
}
|
|
51
52
|
static {
|
|
52
|
-
// Module-level singleton
|
|
53
|
-
|
|
54
|
-
this._pipelineInstance = null;
|
|
53
|
+
// Module-level singleton cache — survives across Lambda/Vercel warm invocations
|
|
54
|
+
this._pipelineCache = /* @__PURE__ */ new Map();
|
|
55
55
|
}
|
|
56
56
|
static {
|
|
57
|
-
this.
|
|
57
|
+
this._loadingPromises = /* @__PURE__ */ new Map();
|
|
58
58
|
}
|
|
59
59
|
async load(model = "all-MiniLM-L6-v2") {
|
|
60
60
|
if (this._ready) return;
|
|
61
|
-
if (this._failed) throw new Error("
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
if (this._failed) throw new Error("ServerEmbedder previously failed to load");
|
|
62
|
+
this._model = model;
|
|
63
|
+
if (_ServerEmbedder._pipelineCache.has(model)) {
|
|
64
|
+
this._ready = true;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (_ServerEmbedder._loadingPromises.has(model)) {
|
|
68
|
+
await _ServerEmbedder._loadingPromises.get(model);
|
|
64
69
|
this._ready = true;
|
|
65
70
|
return;
|
|
66
71
|
}
|
|
67
72
|
const modelId = XENOVA_IDS[model];
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const { pipeline } = await import("@huggingface/transformers");
|
|
71
|
-
_EmbedderRuntime._pipelineInstance = await pipeline(
|
|
72
|
-
"feature-extraction",
|
|
73
|
-
modelId,
|
|
74
|
-
{ dtype: "q8" }
|
|
75
|
-
);
|
|
76
|
-
} catch (err) {
|
|
77
|
-
_EmbedderRuntime._loadingPromise = null;
|
|
78
|
-
_EmbedderRuntime._pipelineInstance = null;
|
|
79
|
-
throw err;
|
|
80
|
-
}
|
|
81
|
-
})();
|
|
73
|
+
const loadPromise = this._loadModel(model, modelId);
|
|
74
|
+
_ServerEmbedder._loadingPromises.set(model, loadPromise);
|
|
82
75
|
try {
|
|
83
|
-
await
|
|
76
|
+
await loadPromise;
|
|
84
77
|
this._ready = true;
|
|
85
78
|
} catch (err) {
|
|
86
79
|
this._failed = true;
|
|
80
|
+
_ServerEmbedder._loadingPromises.delete(model);
|
|
87
81
|
throw err;
|
|
82
|
+
} finally {
|
|
83
|
+
_ServerEmbedder._loadingPromises.delete(model);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async _loadModel(model, modelId) {
|
|
87
|
+
try {
|
|
88
|
+
const { pipeline } = await import("@huggingface/transformers");
|
|
89
|
+
const pipelineInstance = await pipeline("feature-extraction", modelId, {
|
|
90
|
+
dtype: "q8",
|
|
91
|
+
device: "cpu"
|
|
92
|
+
});
|
|
93
|
+
_ServerEmbedder._pipelineCache.set(model, pipelineInstance);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
throw new Error(`Failed to load embedding model ${modelId}: ${String(err)}`);
|
|
88
96
|
}
|
|
89
97
|
}
|
|
90
98
|
async embed(text) {
|
|
91
|
-
if (!
|
|
92
|
-
await this.load();
|
|
99
|
+
if (!this._ready) {
|
|
100
|
+
await this.load(this._model);
|
|
101
|
+
}
|
|
102
|
+
const pipelineInstance = _ServerEmbedder._pipelineCache.get(this._model);
|
|
103
|
+
if (!pipelineInstance) {
|
|
104
|
+
throw new Error(`Embedding model ${this._model} not loaded`);
|
|
93
105
|
}
|
|
94
106
|
try {
|
|
95
|
-
const output = await
|
|
107
|
+
const output = await pipelineInstance(text, {
|
|
96
108
|
pooling: "mean",
|
|
97
109
|
normalize: true
|
|
98
110
|
});
|
|
@@ -107,10 +119,10 @@ var EmbedderRuntime = class _EmbedderRuntime {
|
|
|
107
119
|
get hasFailed() {
|
|
108
120
|
return this._failed;
|
|
109
121
|
}
|
|
110
|
-
/** @internal */
|
|
122
|
+
/** @internal - Reset for testing */
|
|
111
123
|
static _reset() {
|
|
112
|
-
|
|
113
|
-
|
|
124
|
+
_ServerEmbedder._pipelineCache.clear();
|
|
125
|
+
_ServerEmbedder._loadingPromises.clear();
|
|
114
126
|
}
|
|
115
127
|
};
|
|
116
128
|
|
|
@@ -123,17 +135,33 @@ function dotProduct(a, b) {
|
|
|
123
135
|
for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
|
|
124
136
|
return dot;
|
|
125
137
|
}
|
|
138
|
+
function insertSorted(arr, item, maxLen) {
|
|
139
|
+
arr.push(item);
|
|
140
|
+
let i = arr.length - 1;
|
|
141
|
+
while (i > 0 && arr[i - 1].score < arr[i].score) {
|
|
142
|
+
const tmp = arr[i - 1];
|
|
143
|
+
arr[i - 1] = arr[i];
|
|
144
|
+
arr[i] = tmp;
|
|
145
|
+
i--;
|
|
146
|
+
}
|
|
147
|
+
if (arr.length > maxLen) arr.pop();
|
|
148
|
+
}
|
|
126
149
|
function search(queryVector, index, topK = 3, minScore = 0.42) {
|
|
127
150
|
const results = [];
|
|
128
151
|
for (const chunk of index) {
|
|
129
152
|
const score = dotProduct(queryVector, chunk.vector384);
|
|
130
153
|
if (score < minScore) continue;
|
|
131
154
|
if (results.length < topK) {
|
|
132
|
-
results
|
|
133
|
-
results.sort((a, b) => b.score - a.score);
|
|
155
|
+
insertSorted(results, { chunk, score }, topK);
|
|
134
156
|
} else if (score > results[topK - 1].score) {
|
|
135
157
|
results[topK - 1] = { chunk, score };
|
|
136
|
-
|
|
158
|
+
let i = topK - 1;
|
|
159
|
+
while (i > 0 && results[i - 1].score < results[i].score) {
|
|
160
|
+
const tmp = results[i - 1];
|
|
161
|
+
results[i - 1] = results[i];
|
|
162
|
+
results[i] = tmp;
|
|
163
|
+
i--;
|
|
164
|
+
}
|
|
137
165
|
}
|
|
138
166
|
}
|
|
139
167
|
return results;
|
|
@@ -180,7 +208,7 @@ function loadIndexFile(indexPath) {
|
|
|
180
208
|
}
|
|
181
209
|
async function getEmbedder(model = "all-MiniLM-L6-v2") {
|
|
182
210
|
if (embedderCache.has(model)) return embedderCache.get(model);
|
|
183
|
-
const embedder = new
|
|
211
|
+
const embedder = new ServerEmbedder();
|
|
184
212
|
await embedder.load(model);
|
|
185
213
|
embedderCache.set(model, embedder);
|
|
186
214
|
return embedder;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/server/index.ts","../../src/core/embedder.ts","../../src/core/search.ts","../../src/core/renderer.ts"],"sourcesContent":["import { readFileSync } from 'fs';\nimport { EmbedderRuntime } from '../core/embedder.js';\nimport { search } from '../core/search.js';\nimport { buildAnswer } from '../core/renderer.js';\nimport type { IndexChunk, IndexFile, SageDeskModel, FallbackReason } from '../core/types.js';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\nexport interface SageDeskHandlerConfig {\n /** Filesystem path to the pre-built vector index (e.g. \"./public/support-index.json\"). */\n indexPath: string;\n /** LLM provider: 'openai', 'deepseek', 'groq', 'gemini', 'anthropic', or any OpenAI-compatible base URL. */\n provider: string;\n /** API key for the LLM provider. Never sent to the browser. */\n apiKey: string;\n /** LLM model name (e.g. 'deepseek-chat', 'gpt-4o-mini', 'llama3-8b-8192'). */\n model: string;\n /** Embedding model - must match the model used at build time. Defaults to all-MiniLM-L6-v2. */\n embeddingModel?: SageDeskModel;\n /** Number of chunks to retrieve for context. Defaults to 5. */\n topK?: number;\n /** Minimum similarity score for a chunk to be included. Defaults to 0.42. */\n minScore?: number;\n /** Override the system prompt sent to the LLM. */\n systemPrompt?: string;\n /** Timeout for LLM API calls in milliseconds. Defaults to 5000 (5 seconds). */\n llmTimeoutMs?: number;\n}\n\n// ─── Provider URL map ─────────────────────────────────────────────────────────\n\nconst PROVIDER_URLS: Record<string, string> = {\n openai: 'https://api.openai.com/v1/chat/completions',\n deepseek: 'https://api.deepseek.com/chat/completions',\n groq: 'https://api.groq.com/openai/v1/chat/completions',\n gemini: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',\n anthropic: 'https://api.anthropic.com/v1/messages',\n};\n\n// ─── Default system prompt ────────────────────────────────────────────────────\n\nconst DEFAULT_SYSTEM_PROMPT =\n 'You are a helpful support assistant. Answer the user\\'s question based ONLY on the ' +\n 'provided context. If the context does not contain a confident answer, respond with a ' +\n 'friendly message saying you don\\'t have that information right now. Do not make up ' +\n 'information or draw from outside knowledge. Be concise, warm, and helpful.';\n\n// ─── Server-side caches (module-level singletons) ─────────────────────────────\n\nconst indexCache = new Map<string, IndexChunk[]>();\nconst embedderCache = new Map<string, EmbedderRuntime>();\n\nfunction loadIndexFile(indexPath: string): IndexChunk[] {\n if (indexCache.has(indexPath)) return indexCache.get(indexPath)!;\n\n const raw = readFileSync(indexPath, 'utf-8');\n const data = JSON.parse(raw) as IndexFile | IndexChunk[];\n const chunks: IndexChunk[] = Array.isArray(data) ? data : data.chunks;\n\n for (const chunk of chunks) {\n chunk.textLower = chunk.text.toLowerCase();\n if (Array.isArray(chunk.vector384)) {\n chunk.vector384 = new Float32Array(chunk.vector384);\n }\n }\n\n indexCache.set(indexPath, chunks);\n return chunks;\n}\n\nasync function getEmbedder(model: SageDeskModel = 'all-MiniLM-L6-v2'): Promise<EmbedderRuntime> {\n if (embedderCache.has(model)) return embedderCache.get(model)!;\n\n const embedder = new EmbedderRuntime();\n await embedder.load(model);\n embedderCache.set(model, embedder);\n return embedder;\n}\n\n// ─── Helper: Classify error for client-side logging ───────────────────────────\n\nfunction classifyError(error: unknown): FallbackReason {\n const msg = String(error).toLowerCase();\n\n if (msg.includes('401') || msg.includes('403') || msg.includes('unauthorized') || msg.includes('invalid api key')) {\n return 'auth-error';\n }\n if (msg.includes('429') || msg.includes('quota') || msg.includes('rate limit')) {\n return 'quota-exceeded';\n }\n if (msg.includes('timeout') || msg.includes('aborted')) {\n return 'timeout';\n }\n if (msg.includes('malformed') || msg.includes('json')) {\n return 'malformed-response';\n }\n\n return 'api-error';\n}\n\n// ─── LLM call ─────────────────────────────────────────────────────────────────\n\nasync function callLLM(\n provider: string,\n apiKey: string,\n model: string,\n systemPrompt: string,\n query: string,\n context: string,\n timeoutMs: number = 5000\n): Promise<{ answer: string; error?: FallbackReason }> {\n const url = PROVIDER_URLS[provider] ?? provider;\n const controller = new AbortController();\n const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);\n\n try {\n if (provider === 'anthropic') {\n const res = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 512,\n system: systemPrompt,\n messages: [{ role: 'user', content: `Context:\\n${context}\\n\\nQuestion: ${query}` }],\n }),\n signal: controller.signal,\n });\n\n if (!res.ok) {\n const error = classifyError(`${res.status}`);\n return { answer: '', error };\n }\n\n const data = (await res.json()) as { content: Array<{ type: string; text: string }> };\n const answer = data.content?.[0]?.text?.trim() ?? '';\n return { answer };\n }\n\n const res = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model,\n messages: [\n { role: 'system', content: systemPrompt },\n {\n role: 'user',\n content: `Context:\\n${context}\\n\\nQuestion: ${query}`,\n },\n ],\n temperature: 0.3,\n max_tokens: 512,\n }),\n signal: controller.signal,\n });\n\n if (!res.ok) {\n const error = classifyError(`${res.status}`);\n return { answer: '', error };\n }\n\n const data = (await res.json()) as {\n choices: Array<{ message: { content: string } }>;\n };\n const answer = data.choices?.[0]?.message?.content?.trim() ?? '';\n return { answer };\n } catch (err) {\n const error = classifyError(err);\n return { answer: '', error };\n } finally {\n clearTimeout(timeoutHandle);\n }\n}\n\n// ─── Core handler logic ───────────────────────────────────────────────────────\n\nasync function handleQuery(\n query: string,\n config: SageDeskHandlerConfig\n): Promise<{ answer: string; isFallback: boolean; fallbackReason?: FallbackReason }> {\n const {\n indexPath,\n provider,\n apiKey,\n model,\n embeddingModel,\n topK = 5,\n minScore = 0.42,\n systemPrompt = DEFAULT_SYSTEM_PROMPT,\n llmTimeoutMs = 5000,\n } = config;\n\n const index = loadIndexFile(indexPath);\n const embedder = await getEmbedder(embeddingModel);\n\n const queryVector = await embedder.embed(query);\n const results = search(queryVector, index, topK, minScore);\n\n if (results.length === 0) {\n return { answer: '', isFallback: true };\n }\n\n const context = buildAnswer(results);\n const llmResult = await callLLM(\n provider,\n apiKey,\n model,\n systemPrompt,\n query,\n context,\n llmTimeoutMs\n );\n\n if (!llmResult.answer) {\n return {\n answer: '',\n isFallback: true,\n fallbackReason: llmResult.error,\n };\n }\n\n return { answer: llmResult.answer, isFallback: false };\n}\n\n// ─── Next.js App Router handler ───────────────────────────────────────────────\n\n/**\n * Returns a Next.js App Router POST handler.\n *\n * @example\n * // app/api/sagedesk/route.ts\n * import { createSageDeskHandler } from 'sagedesk/server'\n * export const POST = createSageDeskHandler({\n * indexPath: './public/support-index.json',\n * provider: 'deepseek',\n * apiKey: process.env.SAGEDESK_LLM_API_KEY!,\n * model: 'deepseek-chat',\n * })\n */\nexport function createSageDeskHandler(config: SageDeskHandlerConfig) {\n return async function POST(request: Request): Promise<Response> {\n try {\n const body = (await request.json()) as { query?: string };\n const query = body.query?.trim();\n\n if (!query) {\n return Response.json({ error: 'Missing query' }, { status: 400 });\n }\n\n const result = await handleQuery(query, config);\n return Response.json(result);\n } catch (err) {\n console.error('[sagedesk/server] Handler error:', err);\n return Response.json({ answer: '', isFallback: true }, { status: 500 });\n }\n };\n}\n\n// ─── Express / Connect middleware ─────────────────────────────────────────────\n\ntype ExpressRequest = {\n body: { query?: string };\n};\ntype ExpressResponse = {\n status: (code: number) => ExpressResponse;\n json: (data: unknown) => void;\n};\ntype NextFunction = (err?: unknown) => void;\n\n/**\n * Returns an Express (or any Connect-compatible) middleware.\n *\n * @example\n * // server.ts / index.ts\n * import { createSageDeskMiddleware } from 'sagedesk/server'\n * app.use('/api/sagedesk', express.json(), createSageDeskMiddleware({\n * indexPath: './public/support-index.json',\n * provider: 'openai',\n * apiKey: process.env.SAGEDESK_LLM_API_KEY!,\n * model: 'gpt-4o-mini',\n * }))\n */\nexport function createSageDeskMiddleware(config: SageDeskHandlerConfig) {\n return async function sageDeskMiddleware(\n req: ExpressRequest,\n res: ExpressResponse,\n next: NextFunction\n ): Promise<void> {\n try {\n const query = req.body?.query?.trim();\n\n if (!query) {\n res.status(400).json({ error: 'Missing query' });\n return;\n }\n\n const result = await handleQuery(query, config);\n res.json(result);\n } catch (err) {\n console.error('[sagedesk/server] Middleware error:', err);\n next(err);\n }\n };\n}\n","import type { SageDeskModel } from './types';\n\ntype PipelineFn = (\n text: string,\n options: { pooling: string; normalize: boolean }\n) => Promise<{ data: Float32Array }>;\n\n// Maps each supported model alias to its full Xenova/HuggingFace model ID.\nconst XENOVA_IDS: Record<SageDeskModel, string> = {\n 'all-MiniLM-L6-v2': 'Xenova/all-MiniLM-L6-v2',\n 'bge-small-en-v1-5': 'Xenova/bge-small-en-v1.5',\n 'paraphrase-multilingual-MiniLM-L12-v2': 'Xenova/paraphrase-multilingual-MiniLM-L12-v2',\n 'all-mpnet-base-v2': 'Xenova/all-mpnet-base-v2',\n};\n\nexport class EmbedderRuntime {\n private _ready = false;\n private _failed = false;\n\n // Module-level singleton so the WASM model is loaded at most once per page,\n // regardless of how many widget instances exist.\n private static _pipelineInstance: PipelineFn | null = null;\n private static _loadingPromise: Promise<void> | null = null;\n\n async load(model: SageDeskModel = 'all-MiniLM-L6-v2'): Promise<void> {\n if (this._ready) return;\n if (this._failed) throw new Error('EmbedderRuntime previously failed to load');\n\n if (EmbedderRuntime._loadingPromise) {\n await EmbedderRuntime._loadingPromise;\n this._ready = true;\n return;\n }\n\n const modelId = XENOVA_IDS[model];\n\n EmbedderRuntime._loadingPromise = (async () => {\n try {\n const { pipeline } = await import('@huggingface/transformers');\n EmbedderRuntime._pipelineInstance = (await pipeline(\n 'feature-extraction',\n modelId,\n { dtype: 'q8' }\n )) as unknown as PipelineFn;\n } catch (err) {\n EmbedderRuntime._loadingPromise = null;\n EmbedderRuntime._pipelineInstance = null;\n throw err;\n }\n })();\n\n try {\n await EmbedderRuntime._loadingPromise;\n this._ready = true;\n } catch (err) {\n this._failed = true;\n throw err;\n }\n }\n\n async embed(text: string): Promise<Float32Array> {\n if (!EmbedderRuntime._pipelineInstance) {\n await this.load();\n }\n\n try {\n const output = await EmbedderRuntime._pipelineInstance!(text, {\n pooling: 'mean',\n normalize: true,\n });\n return output.data;\n } catch (err) {\n throw new Error(`Embedding failed: ${String(err)}`);\n }\n }\n\n get isReady(): boolean {\n return this._ready;\n }\n\n get hasFailed(): boolean {\n return this._failed;\n }\n\n /** @internal */\n static _reset(): void {\n EmbedderRuntime._pipelineInstance = null;\n EmbedderRuntime._loadingPromise = null;\n }\n}\n","import type { IndexChunk, SearchResult } from './types';\n\n// Both the query vector (embedder.ts, normalize:true) and stored vectors\n// (builder-embedder.ts, normalize:true) are guaranteed unit-length, so\n// cosine similarity reduces to a plain dot product - no norms needed.\nfunction dotProduct(a: Float32Array, b: Float32Array): number {\n if (a.length !== b.length) {\n throw new Error(`Vector dimension mismatch: query(${a.length}) vs index(${b.length})`);\n }\n let dot = 0;\n for (let i = 0; i < a.length; i++) dot += a[i] * b[i];\n return dot;\n}\n\nexport function search(\n queryVector: Float32Array,\n index: IndexChunk[],\n topK = 3,\n minScore = 0.42\n): SearchResult[] {\n // Use a simple selection sort approach for small topK, \n // or a full sort if index is small. For very large indexes, \n // a proper Heap would be better, but O(N * topK) is already better than O(N log N).\n const results: SearchResult[] = [];\n\n for (const chunk of index) {\n const score = dotProduct(queryVector, chunk.vector384 as Float32Array);\n if (score < minScore) continue;\n\n if (results.length < topK) {\n results.push({ chunk, score });\n results.sort((a, b) => b.score - a.score);\n } else if (score > results[topK - 1].score) {\n results[topK - 1] = { chunk, score };\n results.sort((a, b) => b.score - a.score);\n }\n }\n\n return results;\n}\n\nexport function keywordSearch(\n query: string,\n index: IndexChunk[],\n topK = 3\n): SearchResult[] {\n const terms = query\n .toLowerCase()\n .split(/\\s+/)\n .filter((w) => w.length > 2)\n .map((w) => w.replace(/[^a-z0-9]/g, ''));\n\n if (terms.length === 0) return [];\n\n const results: SearchResult[] = [];\n\n for (const chunk of index) {\n const chunkLower = chunk.textLower || chunk.text.toLowerCase();\n const matchCount = terms.filter((t) => chunkLower.includes(t)).length;\n const score = matchCount / terms.length;\n\n if (score <= 0) continue;\n\n if (results.length < topK) {\n results.push({ chunk, score });\n results.sort((a, b) => b.score - a.score);\n } else if (score > results[topK - 1].score) {\n results[topK - 1] = { chunk, score };\n results.sort((a, b) => b.score - a.score);\n }\n }\n\n return results;\n}\n\nexport async function loadIndex(url: string): Promise<IndexChunk[]> {\n const res = await fetch(url);\n if (!res.ok) {\n throw new Error(`Failed to fetch index (HTTP ${res.status}): ${url}`);\n }\n const data = await res.json();\n // Support both the new { meta, chunks } format and the legacy bare-array format.\n const chunks: IndexChunk[] = Array.isArray(data)\n ? data\n : (data as { chunks: IndexChunk[] }).chunks;\n\n // Materialize lowercase versions and convert vectors to Float32Array once at load time.\n for (const chunk of chunks) {\n chunk.textLower = chunk.text.toLowerCase();\n if (Array.isArray(chunk.vector384)) {\n chunk.vector384 = new Float32Array(chunk.vector384);\n }\n if (Array.isArray(chunk.vector768)) {\n chunk.vector768 = new Float32Array(chunk.vector768);\n }\n }\n\n return chunks;\n}\n","import type { SearchResult } from './types';\n\nexport function buildAnswer(results: SearchResult[]): string {\n if (results.length === 0) return '';\n // Deduplicate by sourceId: query expansion produces multiple chunks per\n // source entry (same answer, different query phrasings) - show each source once.\n const seen = new Set<string>();\n const parts: string[] = [];\n for (const r of results) {\n if (!seen.has(r.chunk.sourceId)) {\n seen.add(r.chunk.sourceId);\n parts.push(r.chunk.text);\n }\n }\n return parts.join('\\n\\n');\n}\n\nexport function extractChips(\n index: { text: string; question?: string; sourceId?: string }[],\n override?: string[]\n): string[] {\n if (override && override.length > 0) return override.slice(0, 5);\n\n const chips: string[] = [];\n const seenText = new Set<string>();\n const seenSource = new Set<string>();\n\n for (const chunk of index) {\n if (chips.length >= 5) break;\n\n // Deduplicate by sourceId if available to ensure variety of answers.\n if (chunk.sourceId) {\n if (seenSource.has(chunk.sourceId)) continue;\n seenSource.add(chunk.sourceId);\n }\n\n const candidate = chunk.question ?? extractFirstSentence(chunk.text);\n if (candidate && !seenText.has(candidate)) {\n seenText.add(candidate);\n chips.push(candidate);\n }\n }\n\n return chips;\n}\n\nfunction extractFirstSentence(text: string): string {\n const match = text.match(/^[^\\n.!?]{10,80}[.!?\\n]?/);\n if (!match) return text.slice(0, 60);\n return match[0].trim();\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,gBAA6B;;;ACQ7B,IAAM,aAA4C;AAAA,EAChD,oBAAoB;AAAA,EACpB,qBAAqB;AAAA,EACrB,yCAAyC;AAAA,EACzC,qBAAqB;AACvB;AAEO,IAAM,kBAAN,MAAM,iBAAgB;AAAA,EAAtB;AACL,SAAQ,SAAS;AACjB,SAAQ,UAAU;AAAA;AAAA,EAIlB;AAAA;AAAA;AAAA,SAAe,oBAAuC;AAAA;AAAA,EACtD;AAAA,SAAe,kBAAwC;AAAA;AAAA,EAEvD,MAAM,KAAK,QAAuB,oBAAmC;AACnE,QAAI,KAAK,OAAQ;AACjB,QAAI,KAAK,QAAS,OAAM,IAAI,MAAM,2CAA2C;AAE7E,QAAI,iBAAgB,iBAAiB;AACnC,YAAM,iBAAgB;AACtB,WAAK,SAAS;AACd;AAAA,IACF;AAEA,UAAM,UAAU,WAAW,KAAK;AAEhC,qBAAgB,mBAAmB,YAAY;AAC7C,UAAI;AACF,cAAM,EAAE,SAAS,IAAI,MAAM,OAAO,2BAA2B;AAC7D,yBAAgB,oBAAqB,MAAM;AAAA,UACzC;AAAA,UACA;AAAA,UACA,EAAE,OAAO,KAAK;AAAA,QAChB;AAAA,MACF,SAAS,KAAK;AACZ,yBAAgB,kBAAkB;AAClC,yBAAgB,oBAAoB;AACpC,cAAM;AAAA,MACR;AAAA,IACF,GAAG;AAEH,QAAI;AACF,YAAM,iBAAgB;AACtB,WAAK,SAAS;AAAA,IAChB,SAAS,KAAK;AACZ,WAAK,UAAU;AACf,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEA,MAAM,MAAM,MAAqC;AAC/C,QAAI,CAAC,iBAAgB,mBAAmB;AACtC,YAAM,KAAK,KAAK;AAAA,IAClB;AAEA,QAAI;AACF,YAAM,SAAS,MAAM,iBAAgB,kBAAmB,MAAM;AAAA,QAC5D,SAAS;AAAA,QACT,WAAW;AAAA,MACb,CAAC;AACD,aAAO,OAAO;AAAA,IAChB,SAAS,KAAK;AACZ,YAAM,IAAI,MAAM,qBAAqB,OAAO,GAAG,CAAC,EAAE;AAAA,IACpD;AAAA,EACF;AAAA,EAEA,IAAI,UAAmB;AACrB,WAAO,KAAK;AAAA,EACd;AAAA,EAEA,IAAI,YAAqB;AACvB,WAAO,KAAK;AAAA,EACd;AAAA;AAAA,EAGA,OAAO,SAAe;AACpB,qBAAgB,oBAAoB;AACpC,qBAAgB,kBAAkB;AAAA,EACpC;AACF;;;ACpFA,SAAS,WAAW,GAAiB,GAAyB;AAC5D,MAAI,EAAE,WAAW,EAAE,QAAQ;AACzB,UAAM,IAAI,MAAM,oCAAoC,EAAE,MAAM,cAAc,EAAE,MAAM,GAAG;AAAA,EACvF;AACA,MAAI,MAAM;AACV,WAAS,IAAI,GAAG,IAAI,EAAE,QAAQ,IAAK,QAAO,EAAE,CAAC,IAAI,EAAE,CAAC;AACpD,SAAO;AACT;AAEO,SAAS,OACd,aACA,OACA,OAAO,GACP,WAAW,MACK;AAIhB,QAAM,UAA0B,CAAC;AAEjC,aAAW,SAAS,OAAO;AACzB,UAAM,QAAQ,WAAW,aAAa,MAAM,SAAyB;AACrE,QAAI,QAAQ,SAAU;AAEtB,QAAI,QAAQ,SAAS,MAAM;AACzB,cAAQ,KAAK,EAAE,OAAO,MAAM,CAAC;AAC7B,cAAQ,KAAK,CAAC,GAAG,MAAM,EAAE,QAAQ,EAAE,KAAK;AAAA,IAC1C,WAAW,QAAQ,QAAQ,OAAO,CAAC,EAAE,OAAO;AAC1C,cAAQ,OAAO,CAAC,IAAI,EAAE,OAAO,MAAM;AACnC,cAAQ,KAAK,CAAC,GAAG,MAAM,EAAE,QAAQ,EAAE,KAAK;AAAA,IAC1C;AAAA,EACF;AAEA,SAAO;AACT;;;ACrCO,SAAS,YAAY,SAAiC;AAC3D,MAAI,QAAQ,WAAW,EAAG,QAAO;AAGjC,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,QAAkB,CAAC;AACzB,aAAW,KAAK,SAAS;AACvB,QAAI,CAAC,KAAK,IAAI,EAAE,MAAM,QAAQ,GAAG;AAC/B,WAAK,IAAI,EAAE,MAAM,QAAQ;AACzB,YAAM,KAAK,EAAE,MAAM,IAAI;AAAA,IACzB;AAAA,EACF;AACA,SAAO,MAAM,KAAK,MAAM;AAC1B;;;AHgBA,IAAM,gBAAwC;AAAA,EAC5C,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,WAAW;AACb;AAIA,IAAM,wBACJ;AAOF,IAAM,aAAa,oBAAI,IAA0B;AACjD,IAAM,gBAAgB,oBAAI,IAA6B;AAEvD,SAAS,cAAc,WAAiC;AACtD,MAAI,WAAW,IAAI,SAAS,EAAG,QAAO,WAAW,IAAI,SAAS;AAE9D,QAAM,UAAM,wBAAa,WAAW,OAAO;AAC3C,QAAM,OAAO,KAAK,MAAM,GAAG;AAC3B,QAAM,SAAuB,MAAM,QAAQ,IAAI,IAAI,OAAO,KAAK;AAE/D,aAAW,SAAS,QAAQ;AAC1B,UAAM,YAAY,MAAM,KAAK,YAAY;AACzC,QAAI,MAAM,QAAQ,MAAM,SAAS,GAAG;AAClC,YAAM,YAAY,IAAI,aAAa,MAAM,SAAS;AAAA,IACpD;AAAA,EACF;AAEA,aAAW,IAAI,WAAW,MAAM;AAChC,SAAO;AACT;AAEA,eAAe,YAAY,QAAuB,oBAA8C;AAC9F,MAAI,cAAc,IAAI,KAAK,EAAG,QAAO,cAAc,IAAI,KAAK;AAE5D,QAAM,WAAW,IAAI,gBAAgB;AACrC,QAAM,SAAS,KAAK,KAAK;AACzB,gBAAc,IAAI,OAAO,QAAQ;AACjC,SAAO;AACT;AAIA,SAAS,cAAc,OAAgC;AACrD,QAAM,MAAM,OAAO,KAAK,EAAE,YAAY;AAEtC,MAAI,IAAI,SAAS,KAAK,KAAK,IAAI,SAAS,KAAK,KAAK,IAAI,SAAS,cAAc,KAAK,IAAI,SAAS,iBAAiB,GAAG;AACjH,WAAO;AAAA,EACT;AACA,MAAI,IAAI,SAAS,KAAK,KAAK,IAAI,SAAS,OAAO,KAAK,IAAI,SAAS,YAAY,GAAG;AAC9E,WAAO;AAAA,EACT;AACA,MAAI,IAAI,SAAS,SAAS,KAAK,IAAI,SAAS,SAAS,GAAG;AACtD,WAAO;AAAA,EACT;AACA,MAAI,IAAI,SAAS,WAAW,KAAK,IAAI,SAAS,MAAM,GAAG;AACrD,WAAO;AAAA,EACT;AAEA,SAAO;AACT;AAIA,eAAe,QACb,UACA,QACA,OACA,cACA,OACA,SACA,YAAoB,KACiC;AACrD,QAAM,MAAM,cAAc,QAAQ,KAAK;AACvC,QAAM,aAAa,IAAI,gBAAgB;AACvC,QAAM,gBAAgB,WAAW,MAAM,WAAW,MAAM,GAAG,SAAS;AAEpE,MAAI;AACF,QAAI,aAAa,aAAa;AAC5B,YAAMA,OAAM,MAAM,MAAM,KAAK;AAAA,QAC3B,QAAQ;AAAA,QACR,SAAS;AAAA,UACP,gBAAgB;AAAA,UAChB,aAAa;AAAA,UACb,qBAAqB;AAAA,QACvB;AAAA,QACA,MAAM,KAAK,UAAU;AAAA,UACnB;AAAA,UACA,YAAY;AAAA,UACZ,QAAQ;AAAA,UACR,UAAU,CAAC,EAAE,MAAM,QAAQ,SAAS;AAAA,EAAa,OAAO;AAAA;AAAA,YAAiB,KAAK,GAAG,CAAC;AAAA,QACpF,CAAC;AAAA,QACD,QAAQ,WAAW;AAAA,MACrB,CAAC;AAED,UAAI,CAACA,KAAI,IAAI;AACX,cAAM,QAAQ,cAAc,GAAGA,KAAI,MAAM,EAAE;AAC3C,eAAO,EAAE,QAAQ,IAAI,MAAM;AAAA,MAC7B;AAEA,YAAMC,QAAQ,MAAMD,KAAI,KAAK;AAC7B,YAAME,UAASD,MAAK,UAAU,CAAC,GAAG,MAAM,KAAK,KAAK;AAClD,aAAO,EAAE,QAAAC,QAAO;AAAA,IAClB;AAEA,UAAM,MAAM,MAAM,MAAM,KAAK;AAAA,MAC3B,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,gBAAgB;AAAA,QAChB,eAAe,UAAU,MAAM;AAAA,MACjC;AAAA,MACA,MAAM,KAAK,UAAU;AAAA,QACnB;AAAA,QACA,UAAU;AAAA,UACR,EAAE,MAAM,UAAU,SAAS,aAAa;AAAA,UACxC;AAAA,YACE,MAAM;AAAA,YACN,SAAS;AAAA,EAAa,OAAO;AAAA;AAAA,YAAiB,KAAK;AAAA,UACrD;AAAA,QACF;AAAA,QACA,aAAa;AAAA,QACb,YAAY;AAAA,MACd,CAAC;AAAA,MACD,QAAQ,WAAW;AAAA,IACrB,CAAC;AAED,QAAI,CAAC,IAAI,IAAI;AACX,YAAM,QAAQ,cAAc,GAAG,IAAI,MAAM,EAAE;AAC3C,aAAO,EAAE,QAAQ,IAAI,MAAM;AAAA,IAC7B;AAEA,UAAM,OAAQ,MAAM,IAAI,KAAK;AAG7B,UAAM,SAAS,KAAK,UAAU,CAAC,GAAG,SAAS,SAAS,KAAK,KAAK;AAC9D,WAAO,EAAE,OAAO;AAAA,EAClB,SAAS,KAAK;AACZ,UAAM,QAAQ,cAAc,GAAG;AAC/B,WAAO,EAAE,QAAQ,IAAI,MAAM;AAAA,EAC7B,UAAE;AACA,iBAAa,aAAa;AAAA,EAC5B;AACF;AAIA,eAAe,YACb,OACA,QACmF;AACnF,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,OAAO;AAAA,IACP,WAAW;AAAA,IACX,eAAe;AAAA,IACf,eAAe;AAAA,EACjB,IAAI;AAEJ,QAAM,QAAQ,cAAc,SAAS;AACrC,QAAM,WAAW,MAAM,YAAY,cAAc;AAEjD,QAAM,cAAc,MAAM,SAAS,MAAM,KAAK;AAC9C,QAAM,UAAU,OAAO,aAAa,OAAO,MAAM,QAAQ;AAEzD,MAAI,QAAQ,WAAW,GAAG;AACxB,WAAO,EAAE,QAAQ,IAAI,YAAY,KAAK;AAAA,EACxC;AAEA,QAAM,UAAU,YAAY,OAAO;AACnC,QAAM,YAAY,MAAM;AAAA,IACtB;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAEA,MAAI,CAAC,UAAU,QAAQ;AACrB,WAAO;AAAA,MACL,QAAQ;AAAA,MACR,YAAY;AAAA,MACZ,gBAAgB,UAAU;AAAA,IAC5B;AAAA,EACF;AAEA,SAAO,EAAE,QAAQ,UAAU,QAAQ,YAAY,MAAM;AACvD;AAiBO,SAAS,sBAAsB,QAA+B;AACnE,SAAO,eAAe,KAAK,SAAqC;AAC9D,QAAI;AACF,YAAM,OAAQ,MAAM,QAAQ,KAAK;AACjC,YAAM,QAAQ,KAAK,OAAO,KAAK;AAE/B,UAAI,CAAC,OAAO;AACV,eAAO,SAAS,KAAK,EAAE,OAAO,gBAAgB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,MAClE;AAEA,YAAM,SAAS,MAAM,YAAY,OAAO,MAAM;AAC9C,aAAO,SAAS,KAAK,MAAM;AAAA,IAC7B,SAAS,KAAK;AACZ,cAAQ,MAAM,oCAAoC,GAAG;AACrD,aAAO,SAAS,KAAK,EAAE,QAAQ,IAAI,YAAY,KAAK,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IACxE;AAAA,EACF;AACF;AA0BO,SAAS,yBAAyB,QAA+B;AACtE,SAAO,eAAe,mBACpB,KACA,KACA,MACe;AACf,QAAI;AACF,YAAM,QAAQ,IAAI,MAAM,OAAO,KAAK;AAEpC,UAAI,CAAC,OAAO;AACV,YAAI,OAAO,GAAG,EAAE,KAAK,EAAE,OAAO,gBAAgB,CAAC;AAC/C;AAAA,MACF;AAEA,YAAM,SAAS,MAAM,YAAY,OAAO,MAAM;AAC9C,UAAI,KAAK,MAAM;AAAA,IACjB,SAAS,KAAK;AACZ,cAAQ,MAAM,uCAAuC,GAAG;AACxD,WAAK,GAAG;AAAA,IACV;AAAA,EACF;AACF;","names":["res","data","answer"]}
|
|
1
|
+
{"version":3,"sources":["../../src/server/index.ts","../../src/core/server-embedder.ts","../../src/core/search.ts","../../src/core/renderer.ts"],"sourcesContent":["import { readFileSync } from 'fs';\nimport { ServerEmbedder } from '../core/server-embedder.js';\nimport { search } from '../core/search.js';\nimport { buildAnswer } from '../core/renderer.js';\nimport type { IndexChunk, IndexFile, SageDeskModel, FallbackReason } from '../core/types.js';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\nexport interface SageDeskHandlerConfig {\n /** Filesystem path to the pre-built vector index (e.g. \"./public/support-index.json\"). */\n indexPath: string;\n /** LLM provider: 'openai', 'deepseek', 'groq', 'gemini', 'anthropic', or any OpenAI-compatible base URL. */\n provider: string;\n /** API key for the LLM provider. Never sent to the browser. */\n apiKey: string;\n /** LLM model name (e.g. 'deepseek-chat', 'gpt-4o-mini', 'llama3-8b-8192'). */\n model: string;\n /** Embedding model - must match the model used at build time. Defaults to all-MiniLM-L6-v2. */\n embeddingModel?: SageDeskModel;\n /** Number of chunks to retrieve for context. Defaults to 5. */\n topK?: number;\n /** Minimum similarity score for a chunk to be included. Defaults to 0.42. */\n minScore?: number;\n /** Override the system prompt sent to the LLM. */\n systemPrompt?: string;\n /** Timeout for LLM API calls in milliseconds. Defaults to 5000 (5 seconds). */\n llmTimeoutMs?: number;\n}\n\n// ─── Provider URL map ─────────────────────────────────────────────────────────\n\nconst PROVIDER_URLS: Record<string, string> = {\n openai: 'https://api.openai.com/v1/chat/completions',\n deepseek: 'https://api.deepseek.com/chat/completions',\n groq: 'https://api.groq.com/openai/v1/chat/completions',\n gemini: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',\n anthropic: 'https://api.anthropic.com/v1/messages',\n};\n\n// ─── Default system prompt ────────────────────────────────────────────────────\n\nconst DEFAULT_SYSTEM_PROMPT =\n 'You are a helpful support assistant. Answer the user\\'s question based ONLY on the ' +\n 'provided context. If the context does not contain a confident answer, respond with a ' +\n 'friendly message saying you don\\'t have that information right now. Do not make up ' +\n 'information or draw from outside knowledge. Be concise, warm, and helpful.';\n\n// ─── Server-side caches (module-level singletons) ─────────────────────────────\n\nconst indexCache = new Map<string, IndexChunk[]>();\nconst embedderCache = new Map<string, ServerEmbedder>();\n\nfunction loadIndexFile(indexPath: string): IndexChunk[] {\n if (indexCache.has(indexPath)) return indexCache.get(indexPath)!;\n\n const raw = readFileSync(indexPath, 'utf-8');\n const data = JSON.parse(raw) as IndexFile | IndexChunk[];\n const chunks: IndexChunk[] = Array.isArray(data) ? data : data.chunks;\n\n for (const chunk of chunks) {\n chunk.textLower = chunk.text.toLowerCase();\n if (Array.isArray(chunk.vector384)) {\n chunk.vector384 = new Float32Array(chunk.vector384);\n }\n }\n\n indexCache.set(indexPath, chunks);\n return chunks;\n}\n\nasync function getEmbedder(model: SageDeskModel = 'all-MiniLM-L6-v2'): Promise<ServerEmbedder> {\n if (embedderCache.has(model)) return embedderCache.get(model)!;\n\n const embedder = new ServerEmbedder();\n await embedder.load(model);\n embedderCache.set(model, embedder);\n return embedder;\n}\n\n// ─── Helper: Classify error for client-side logging ───────────────────────────\n\nfunction classifyError(error: unknown): FallbackReason {\n const msg = String(error).toLowerCase();\n\n if (msg.includes('401') || msg.includes('403') || msg.includes('unauthorized') || msg.includes('invalid api key')) {\n return 'auth-error';\n }\n if (msg.includes('429') || msg.includes('quota') || msg.includes('rate limit')) {\n return 'quota-exceeded';\n }\n if (msg.includes('timeout') || msg.includes('aborted')) {\n return 'timeout';\n }\n if (msg.includes('malformed') || msg.includes('json')) {\n return 'malformed-response';\n }\n\n return 'api-error';\n}\n\n// ─── LLM call ─────────────────────────────────────────────────────────────────\n\nasync function callLLM(\n provider: string,\n apiKey: string,\n model: string,\n systemPrompt: string,\n query: string,\n context: string,\n timeoutMs: number = 5000\n): Promise<{ answer: string; error?: FallbackReason }> {\n const url = PROVIDER_URLS[provider] ?? provider;\n const controller = new AbortController();\n const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);\n\n try {\n if (provider === 'anthropic') {\n const res = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 512,\n system: systemPrompt,\n messages: [{ role: 'user', content: `Context:\\n${context}\\n\\nQuestion: ${query}` }],\n }),\n signal: controller.signal,\n });\n\n if (!res.ok) {\n const error = classifyError(`${res.status}`);\n return { answer: '', error };\n }\n\n const data = (await res.json()) as { content: Array<{ type: string; text: string }> };\n const answer = data.content?.[0]?.text?.trim() ?? '';\n return { answer };\n }\n\n const res = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model,\n messages: [\n { role: 'system', content: systemPrompt },\n {\n role: 'user',\n content: `Context:\\n${context}\\n\\nQuestion: ${query}`,\n },\n ],\n temperature: 0.3,\n max_tokens: 512,\n }),\n signal: controller.signal,\n });\n\n if (!res.ok) {\n const error = classifyError(`${res.status}`);\n return { answer: '', error };\n }\n\n const data = (await res.json()) as {\n choices: Array<{ message: { content: string } }>;\n };\n const answer = data.choices?.[0]?.message?.content?.trim() ?? '';\n return { answer };\n } catch (err) {\n const error = classifyError(err);\n return { answer: '', error };\n } finally {\n clearTimeout(timeoutHandle);\n }\n}\n\n// ─── Core handler logic ───────────────────────────────────────────────────────\n\nasync function handleQuery(\n query: string,\n config: SageDeskHandlerConfig\n): Promise<{ answer: string; isFallback: boolean; fallbackReason?: FallbackReason }> {\n const {\n indexPath,\n provider,\n apiKey,\n model,\n embeddingModel,\n topK = 5,\n minScore = 0.42,\n systemPrompt = DEFAULT_SYSTEM_PROMPT,\n llmTimeoutMs = 5000,\n } = config;\n\n const index = loadIndexFile(indexPath);\n const embedder = await getEmbedder(embeddingModel);\n\n const queryVector = await embedder.embed(query);\n const results = search(queryVector, index, topK, minScore);\n\n if (results.length === 0) {\n return { answer: '', isFallback: true };\n }\n\n const context = buildAnswer(results);\n const llmResult = await callLLM(\n provider,\n apiKey,\n model,\n systemPrompt,\n query,\n context,\n llmTimeoutMs\n );\n\n if (!llmResult.answer) {\n return {\n answer: '',\n isFallback: true,\n fallbackReason: llmResult.error,\n };\n }\n\n return { answer: llmResult.answer, isFallback: false };\n}\n\n// ─── Next.js App Router handler ───────────────────────────────────────────────\n\n/**\n * Returns a Next.js App Router POST handler.\n *\n * @example\n * // app/api/sagedesk/route.ts\n * import { createSageDeskHandler } from 'sagedesk/server'\n * export const POST = createSageDeskHandler({\n * indexPath: './public/support-index.json',\n * provider: 'deepseek',\n * apiKey: process.env.SAGEDESK_LLM_API_KEY!,\n * model: 'deepseek-chat',\n * })\n */\nexport function createSageDeskHandler(config: SageDeskHandlerConfig) {\n return async function POST(request: Request): Promise<Response> {\n try {\n const body = (await request.json()) as { query?: string };\n const query = body.query?.trim();\n\n if (!query) {\n return Response.json({ error: 'Missing query' }, { status: 400 });\n }\n\n const result = await handleQuery(query, config);\n return Response.json(result);\n } catch (err) {\n console.error('[sagedesk/server] Handler error:', err);\n return Response.json({ answer: '', isFallback: true }, { status: 500 });\n }\n };\n}\n\n// ─── Express / Connect middleware ─────────────────────────────────────────────\n\ntype ExpressRequest = {\n body: { query?: string };\n};\ntype ExpressResponse = {\n status: (code: number) => ExpressResponse;\n json: (data: unknown) => void;\n};\ntype NextFunction = (err?: unknown) => void;\n\n/**\n * Returns an Express (or any Connect-compatible) middleware.\n *\n * @example\n * // server.ts / index.ts\n * import { createSageDeskMiddleware } from 'sagedesk/server'\n * app.use('/api/sagedesk', express.json(), createSageDeskMiddleware({\n * indexPath: './public/support-index.json',\n * provider: 'openai',\n * apiKey: process.env.SAGEDESK_LLM_API_KEY!,\n * model: 'gpt-4o-mini',\n * }))\n */\nexport function createSageDeskMiddleware(config: SageDeskHandlerConfig) {\n return async function sageDeskMiddleware(\n req: ExpressRequest,\n res: ExpressResponse,\n next: NextFunction\n ): Promise<void> {\n try {\n const query = req.body?.query?.trim();\n\n if (!query) {\n res.status(400).json({ error: 'Missing query' });\n return;\n }\n\n const result = await handleQuery(query, config);\n res.json(result);\n } catch (err) {\n console.error('[sagedesk/server] Middleware error:', err);\n next(err);\n }\n };\n}\n","import type { SageDeskModel } from './types';\n\ntype PipelineFn = (\n text: string,\n options: { pooling: string; normalize: boolean }\n) => Promise<{ data: Float32Array }>;\n\n// Must stay in sync with embedder.ts and cli/builder-embedder.ts — same model IDs\n// ensure build-time and runtime vectors share the same embedding space.\nconst XENOVA_IDS: Record<SageDeskModel, string> = {\n 'all-MiniLM-L6-v2': 'Xenova/all-MiniLM-L6-v2',\n 'bge-small-en-v1-5': 'Xenova/bge-small-en-v1.5',\n 'paraphrase-multilingual-MiniLM-L12-v2': 'Xenova/paraphrase-multilingual-MiniLM-L12-v2',\n 'all-mpnet-base-v2': 'Xenova/all-mpnet-base-v2',\n};\n\n/**\n * Server-side embedder optimized for serverless environments (Vercel, Lambda, etc).\n * Uses pure WASM with no native ONNX Runtime dependency.\n * Models are cached at the module level to survive across serverless invocations.\n */\nexport class ServerEmbedder {\n private _ready = false;\n private _failed = false;\n private _model: SageDeskModel = 'all-MiniLM-L6-v2';\n\n // Module-level singleton cache — survives across Lambda/Vercel warm invocations\n private static _pipelineCache = new Map<SageDeskModel, PipelineFn>();\n private static _loadingPromises = new Map<SageDeskModel, Promise<void>>();\n\n async load(model: SageDeskModel = 'all-MiniLM-L6-v2'): Promise<void> {\n if (this._ready) return;\n if (this._failed) throw new Error('ServerEmbedder previously failed to load');\n\n this._model = model;\n\n // Return cached instance if already loaded\n if (ServerEmbedder._pipelineCache.has(model)) {\n this._ready = true;\n return;\n }\n\n // Return existing loading promise if currently loading\n if (ServerEmbedder._loadingPromises.has(model)) {\n await ServerEmbedder._loadingPromises.get(model)!;\n this._ready = true;\n return;\n }\n\n const modelId = XENOVA_IDS[model];\n const loadPromise = this._loadModel(model, modelId);\n ServerEmbedder._loadingPromises.set(model, loadPromise);\n\n try {\n await loadPromise;\n this._ready = true;\n } catch (err) {\n this._failed = true;\n ServerEmbedder._loadingPromises.delete(model);\n throw err;\n } finally {\n ServerEmbedder._loadingPromises.delete(model);\n }\n }\n\n private async _loadModel(model: SageDeskModel, modelId: string): Promise<void> {\n try {\n // device: 'cpu' is the v3.x name for the WASM/CPU backend (replaces 'wasm' from v2.x)\n const { pipeline } = await import('@huggingface/transformers');\n const pipelineInstance = (await pipeline('feature-extraction', modelId, {\n dtype: 'q8',\n device: 'cpu',\n })) as unknown as PipelineFn;\n\n ServerEmbedder._pipelineCache.set(model, pipelineInstance);\n } catch (err) {\n throw new Error(`Failed to load embedding model ${modelId}: ${String(err)}`);\n }\n }\n\n async embed(text: string): Promise<Float32Array> {\n if (!this._ready) {\n await this.load(this._model);\n }\n\n const pipelineInstance = ServerEmbedder._pipelineCache.get(this._model);\n if (!pipelineInstance) {\n throw new Error(`Embedding model ${this._model} not loaded`);\n }\n\n try {\n const output = await pipelineInstance(text, {\n pooling: 'mean',\n normalize: true,\n });\n return output.data;\n } catch (err) {\n throw new Error(`Embedding failed: ${String(err)}`);\n }\n }\n\n get isReady(): boolean {\n return this._ready;\n }\n\n get hasFailed(): boolean {\n return this._failed;\n }\n\n /** @internal - Reset for testing */\n static _reset(): void {\n ServerEmbedder._pipelineCache.clear();\n ServerEmbedder._loadingPromises.clear();\n }\n}\n","import type { IndexChunk, SearchResult } from './types';\n\n// Both the query vector (embedder.ts, normalize:true) and stored vectors\n// (builder-embedder.ts, normalize:true) are guaranteed unit-length, so\n// cosine similarity reduces to a plain dot product - no norms needed.\nfunction dotProduct(a: Float32Array, b: Float32Array): number {\n if (a.length !== b.length) {\n throw new Error(`Vector dimension mismatch: query(${a.length}) vs index(${b.length})`);\n }\n let dot = 0;\n for (let i = 0; i < a.length; i++) dot += a[i] * b[i];\n return dot;\n}\n\n// Inserts item at the correct descending-score position, then trims to maxLen.\n// Avoids Array.sort overhead on every insertion for small topK arrays.\nfunction insertSorted(arr: SearchResult[], item: SearchResult, maxLen: number): void {\n arr.push(item);\n let i = arr.length - 1;\n while (i > 0 && arr[i - 1].score < arr[i].score) {\n const tmp = arr[i - 1]; arr[i - 1] = arr[i]; arr[i] = tmp;\n i--;\n }\n if (arr.length > maxLen) arr.pop();\n}\n\nexport function search(\n queryVector: Float32Array,\n index: IndexChunk[],\n topK = 3,\n minScore = 0.42\n): SearchResult[] {\n const results: SearchResult[] = [];\n\n for (const chunk of index) {\n const score = dotProduct(queryVector, chunk.vector384 as Float32Array);\n if (score < minScore) continue;\n\n if (results.length < topK) {\n insertSorted(results, { chunk, score }, topK);\n } else if (score > results[topK - 1].score) {\n results[topK - 1] = { chunk, score };\n let i = topK - 1;\n while (i > 0 && results[i - 1].score < results[i].score) {\n const tmp = results[i - 1]; results[i - 1] = results[i]; results[i] = tmp;\n i--;\n }\n }\n }\n\n return results;\n}\n\nexport function keywordSearch(\n query: string,\n index: IndexChunk[],\n topK = 3\n): SearchResult[] {\n const terms = query\n .toLowerCase()\n .split(/\\s+/)\n .filter((w) => w.length > 2)\n .map((w) => w.replace(/[^a-z0-9]/g, ''));\n\n if (terms.length === 0) return [];\n\n const results: SearchResult[] = [];\n\n for (const chunk of index) {\n const chunkLower = chunk.textLower || chunk.text.toLowerCase();\n let matchCount = 0;\n for (const t of terms) {\n if (chunkLower.includes(t)) matchCount++;\n }\n const score = matchCount / terms.length;\n\n if (score <= 0) continue;\n\n if (results.length < topK) {\n insertSorted(results, { chunk, score }, topK);\n } else if (score > results[topK - 1].score) {\n results[topK - 1] = { chunk, score };\n let i = topK - 1;\n while (i > 0 && results[i - 1].score < results[i].score) {\n const tmp = results[i - 1]; results[i - 1] = results[i]; results[i] = tmp;\n i--;\n }\n }\n }\n\n return results;\n}\n\nexport async function loadIndex(url: string): Promise<IndexChunk[]> {\n const res = await fetch(url);\n if (!res.ok) {\n throw new Error(`Failed to fetch index (HTTP ${res.status}): ${url}`);\n }\n const data = await res.json();\n // Support both the new { meta, chunks } format and the legacy bare-array format.\n const chunks: IndexChunk[] = Array.isArray(data)\n ? data\n : (data as { chunks: IndexChunk[] }).chunks;\n\n // Materialize lowercase versions and convert vectors to Float32Array once at load time.\n for (const chunk of chunks) {\n chunk.textLower = chunk.text.toLowerCase();\n if (Array.isArray(chunk.vector384)) {\n chunk.vector384 = new Float32Array(chunk.vector384);\n }\n if (Array.isArray(chunk.vector768)) {\n chunk.vector768 = new Float32Array(chunk.vector768);\n }\n }\n\n return chunks;\n}\n","import type { SearchResult } from './types';\n\nexport function buildAnswer(results: SearchResult[]): string {\n if (results.length === 0) return '';\n // Deduplicate by sourceId: query expansion produces multiple chunks per\n // source entry (same answer, different query phrasings) - show each source once.\n const seen = new Set<string>();\n const parts: string[] = [];\n for (const r of results) {\n if (!seen.has(r.chunk.sourceId)) {\n seen.add(r.chunk.sourceId);\n parts.push(r.chunk.text);\n }\n }\n return parts.join('\\n\\n');\n}\n\nexport function extractChips(\n index: { text: string; question?: string; sourceId?: string }[],\n override?: string[]\n): string[] {\n if (override && override.length > 0) return override.slice(0, 5);\n\n const chips: string[] = [];\n const seenText = new Set<string>();\n const seenSource = new Set<string>();\n\n for (const chunk of index) {\n if (chips.length >= 5) break;\n\n // Deduplicate by sourceId if available to ensure variety of answers.\n if (chunk.sourceId) {\n if (seenSource.has(chunk.sourceId)) continue;\n seenSource.add(chunk.sourceId);\n }\n\n const candidate = chunk.question ?? extractFirstSentence(chunk.text);\n if (candidate && !seenText.has(candidate)) {\n seenText.add(candidate);\n chips.push(candidate);\n }\n }\n\n return chips;\n}\n\nfunction extractFirstSentence(text: string): string {\n const match = text.match(/^[^\\n.!?]{10,80}[.!?\\n]?/);\n if (!match) return text.slice(0, 60);\n return match[0].trim();\n}\n"],"mappings":"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","names":["res","data","answer"]}
|
package/dist/server/index.js
CHANGED
|
@@ -1,63 +1,75 @@
|
|
|
1
1
|
// src/server/index.ts
|
|
2
2
|
import { readFileSync } from "fs";
|
|
3
3
|
|
|
4
|
-
// src/core/embedder.ts
|
|
4
|
+
// src/core/server-embedder.ts
|
|
5
5
|
var XENOVA_IDS = {
|
|
6
6
|
"all-MiniLM-L6-v2": "Xenova/all-MiniLM-L6-v2",
|
|
7
7
|
"bge-small-en-v1-5": "Xenova/bge-small-en-v1.5",
|
|
8
8
|
"paraphrase-multilingual-MiniLM-L12-v2": "Xenova/paraphrase-multilingual-MiniLM-L12-v2",
|
|
9
9
|
"all-mpnet-base-v2": "Xenova/all-mpnet-base-v2"
|
|
10
10
|
};
|
|
11
|
-
var
|
|
11
|
+
var ServerEmbedder = class _ServerEmbedder {
|
|
12
12
|
constructor() {
|
|
13
13
|
this._ready = false;
|
|
14
14
|
this._failed = false;
|
|
15
|
+
this._model = "all-MiniLM-L6-v2";
|
|
15
16
|
}
|
|
16
17
|
static {
|
|
17
|
-
// Module-level singleton
|
|
18
|
-
|
|
19
|
-
this._pipelineInstance = null;
|
|
18
|
+
// Module-level singleton cache — survives across Lambda/Vercel warm invocations
|
|
19
|
+
this._pipelineCache = /* @__PURE__ */ new Map();
|
|
20
20
|
}
|
|
21
21
|
static {
|
|
22
|
-
this.
|
|
22
|
+
this._loadingPromises = /* @__PURE__ */ new Map();
|
|
23
23
|
}
|
|
24
24
|
async load(model = "all-MiniLM-L6-v2") {
|
|
25
25
|
if (this._ready) return;
|
|
26
|
-
if (this._failed) throw new Error("
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
if (this._failed) throw new Error("ServerEmbedder previously failed to load");
|
|
27
|
+
this._model = model;
|
|
28
|
+
if (_ServerEmbedder._pipelineCache.has(model)) {
|
|
29
|
+
this._ready = true;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (_ServerEmbedder._loadingPromises.has(model)) {
|
|
33
|
+
await _ServerEmbedder._loadingPromises.get(model);
|
|
29
34
|
this._ready = true;
|
|
30
35
|
return;
|
|
31
36
|
}
|
|
32
37
|
const modelId = XENOVA_IDS[model];
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const { pipeline } = await import("@huggingface/transformers");
|
|
36
|
-
_EmbedderRuntime._pipelineInstance = await pipeline(
|
|
37
|
-
"feature-extraction",
|
|
38
|
-
modelId,
|
|
39
|
-
{ dtype: "q8" }
|
|
40
|
-
);
|
|
41
|
-
} catch (err) {
|
|
42
|
-
_EmbedderRuntime._loadingPromise = null;
|
|
43
|
-
_EmbedderRuntime._pipelineInstance = null;
|
|
44
|
-
throw err;
|
|
45
|
-
}
|
|
46
|
-
})();
|
|
38
|
+
const loadPromise = this._loadModel(model, modelId);
|
|
39
|
+
_ServerEmbedder._loadingPromises.set(model, loadPromise);
|
|
47
40
|
try {
|
|
48
|
-
await
|
|
41
|
+
await loadPromise;
|
|
49
42
|
this._ready = true;
|
|
50
43
|
} catch (err) {
|
|
51
44
|
this._failed = true;
|
|
45
|
+
_ServerEmbedder._loadingPromises.delete(model);
|
|
52
46
|
throw err;
|
|
47
|
+
} finally {
|
|
48
|
+
_ServerEmbedder._loadingPromises.delete(model);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async _loadModel(model, modelId) {
|
|
52
|
+
try {
|
|
53
|
+
const { pipeline } = await import("@huggingface/transformers");
|
|
54
|
+
const pipelineInstance = await pipeline("feature-extraction", modelId, {
|
|
55
|
+
dtype: "q8",
|
|
56
|
+
device: "cpu"
|
|
57
|
+
});
|
|
58
|
+
_ServerEmbedder._pipelineCache.set(model, pipelineInstance);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
throw new Error(`Failed to load embedding model ${modelId}: ${String(err)}`);
|
|
53
61
|
}
|
|
54
62
|
}
|
|
55
63
|
async embed(text) {
|
|
56
|
-
if (!
|
|
57
|
-
await this.load();
|
|
64
|
+
if (!this._ready) {
|
|
65
|
+
await this.load(this._model);
|
|
66
|
+
}
|
|
67
|
+
const pipelineInstance = _ServerEmbedder._pipelineCache.get(this._model);
|
|
68
|
+
if (!pipelineInstance) {
|
|
69
|
+
throw new Error(`Embedding model ${this._model} not loaded`);
|
|
58
70
|
}
|
|
59
71
|
try {
|
|
60
|
-
const output = await
|
|
72
|
+
const output = await pipelineInstance(text, {
|
|
61
73
|
pooling: "mean",
|
|
62
74
|
normalize: true
|
|
63
75
|
});
|
|
@@ -72,10 +84,10 @@ var EmbedderRuntime = class _EmbedderRuntime {
|
|
|
72
84
|
get hasFailed() {
|
|
73
85
|
return this._failed;
|
|
74
86
|
}
|
|
75
|
-
/** @internal */
|
|
87
|
+
/** @internal - Reset for testing */
|
|
76
88
|
static _reset() {
|
|
77
|
-
|
|
78
|
-
|
|
89
|
+
_ServerEmbedder._pipelineCache.clear();
|
|
90
|
+
_ServerEmbedder._loadingPromises.clear();
|
|
79
91
|
}
|
|
80
92
|
};
|
|
81
93
|
|
|
@@ -88,17 +100,33 @@ function dotProduct(a, b) {
|
|
|
88
100
|
for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
|
|
89
101
|
return dot;
|
|
90
102
|
}
|
|
103
|
+
function insertSorted(arr, item, maxLen) {
|
|
104
|
+
arr.push(item);
|
|
105
|
+
let i = arr.length - 1;
|
|
106
|
+
while (i > 0 && arr[i - 1].score < arr[i].score) {
|
|
107
|
+
const tmp = arr[i - 1];
|
|
108
|
+
arr[i - 1] = arr[i];
|
|
109
|
+
arr[i] = tmp;
|
|
110
|
+
i--;
|
|
111
|
+
}
|
|
112
|
+
if (arr.length > maxLen) arr.pop();
|
|
113
|
+
}
|
|
91
114
|
function search(queryVector, index, topK = 3, minScore = 0.42) {
|
|
92
115
|
const results = [];
|
|
93
116
|
for (const chunk of index) {
|
|
94
117
|
const score = dotProduct(queryVector, chunk.vector384);
|
|
95
118
|
if (score < minScore) continue;
|
|
96
119
|
if (results.length < topK) {
|
|
97
|
-
results
|
|
98
|
-
results.sort((a, b) => b.score - a.score);
|
|
120
|
+
insertSorted(results, { chunk, score }, topK);
|
|
99
121
|
} else if (score > results[topK - 1].score) {
|
|
100
122
|
results[topK - 1] = { chunk, score };
|
|
101
|
-
|
|
123
|
+
let i = topK - 1;
|
|
124
|
+
while (i > 0 && results[i - 1].score < results[i].score) {
|
|
125
|
+
const tmp = results[i - 1];
|
|
126
|
+
results[i - 1] = results[i];
|
|
127
|
+
results[i] = tmp;
|
|
128
|
+
i--;
|
|
129
|
+
}
|
|
102
130
|
}
|
|
103
131
|
}
|
|
104
132
|
return results;
|
|
@@ -145,7 +173,7 @@ function loadIndexFile(indexPath) {
|
|
|
145
173
|
}
|
|
146
174
|
async function getEmbedder(model = "all-MiniLM-L6-v2") {
|
|
147
175
|
if (embedderCache.has(model)) return embedderCache.get(model);
|
|
148
|
-
const embedder = new
|
|
176
|
+
const embedder = new ServerEmbedder();
|
|
149
177
|
await embedder.load(model);
|
|
150
178
|
embedderCache.set(model, embedder);
|
|
151
179
|
return embedder;
|
package/dist/server/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/server/index.ts","../../src/core/embedder.ts","../../src/core/search.ts","../../src/core/renderer.ts"],"sourcesContent":["import { readFileSync } from 'fs';\nimport { EmbedderRuntime } from '../core/embedder.js';\nimport { search } from '../core/search.js';\nimport { buildAnswer } from '../core/renderer.js';\nimport type { IndexChunk, IndexFile, SageDeskModel, FallbackReason } from '../core/types.js';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\nexport interface SageDeskHandlerConfig {\n /** Filesystem path to the pre-built vector index (e.g. \"./public/support-index.json\"). */\n indexPath: string;\n /** LLM provider: 'openai', 'deepseek', 'groq', 'gemini', 'anthropic', or any OpenAI-compatible base URL. */\n provider: string;\n /** API key for the LLM provider. Never sent to the browser. */\n apiKey: string;\n /** LLM model name (e.g. 'deepseek-chat', 'gpt-4o-mini', 'llama3-8b-8192'). */\n model: string;\n /** Embedding model - must match the model used at build time. Defaults to all-MiniLM-L6-v2. */\n embeddingModel?: SageDeskModel;\n /** Number of chunks to retrieve for context. Defaults to 5. */\n topK?: number;\n /** Minimum similarity score for a chunk to be included. Defaults to 0.42. */\n minScore?: number;\n /** Override the system prompt sent to the LLM. */\n systemPrompt?: string;\n /** Timeout for LLM API calls in milliseconds. Defaults to 5000 (5 seconds). */\n llmTimeoutMs?: number;\n}\n\n// ─── Provider URL map ─────────────────────────────────────────────────────────\n\nconst PROVIDER_URLS: Record<string, string> = {\n openai: 'https://api.openai.com/v1/chat/completions',\n deepseek: 'https://api.deepseek.com/chat/completions',\n groq: 'https://api.groq.com/openai/v1/chat/completions',\n gemini: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',\n anthropic: 'https://api.anthropic.com/v1/messages',\n};\n\n// ─── Default system prompt ────────────────────────────────────────────────────\n\nconst DEFAULT_SYSTEM_PROMPT =\n 'You are a helpful support assistant. Answer the user\\'s question based ONLY on the ' +\n 'provided context. If the context does not contain a confident answer, respond with a ' +\n 'friendly message saying you don\\'t have that information right now. Do not make up ' +\n 'information or draw from outside knowledge. Be concise, warm, and helpful.';\n\n// ─── Server-side caches (module-level singletons) ─────────────────────────────\n\nconst indexCache = new Map<string, IndexChunk[]>();\nconst embedderCache = new Map<string, EmbedderRuntime>();\n\nfunction loadIndexFile(indexPath: string): IndexChunk[] {\n if (indexCache.has(indexPath)) return indexCache.get(indexPath)!;\n\n const raw = readFileSync(indexPath, 'utf-8');\n const data = JSON.parse(raw) as IndexFile | IndexChunk[];\n const chunks: IndexChunk[] = Array.isArray(data) ? data : data.chunks;\n\n for (const chunk of chunks) {\n chunk.textLower = chunk.text.toLowerCase();\n if (Array.isArray(chunk.vector384)) {\n chunk.vector384 = new Float32Array(chunk.vector384);\n }\n }\n\n indexCache.set(indexPath, chunks);\n return chunks;\n}\n\nasync function getEmbedder(model: SageDeskModel = 'all-MiniLM-L6-v2'): Promise<EmbedderRuntime> {\n if (embedderCache.has(model)) return embedderCache.get(model)!;\n\n const embedder = new EmbedderRuntime();\n await embedder.load(model);\n embedderCache.set(model, embedder);\n return embedder;\n}\n\n// ─── Helper: Classify error for client-side logging ───────────────────────────\n\nfunction classifyError(error: unknown): FallbackReason {\n const msg = String(error).toLowerCase();\n\n if (msg.includes('401') || msg.includes('403') || msg.includes('unauthorized') || msg.includes('invalid api key')) {\n return 'auth-error';\n }\n if (msg.includes('429') || msg.includes('quota') || msg.includes('rate limit')) {\n return 'quota-exceeded';\n }\n if (msg.includes('timeout') || msg.includes('aborted')) {\n return 'timeout';\n }\n if (msg.includes('malformed') || msg.includes('json')) {\n return 'malformed-response';\n }\n\n return 'api-error';\n}\n\n// ─── LLM call ─────────────────────────────────────────────────────────────────\n\nasync function callLLM(\n provider: string,\n apiKey: string,\n model: string,\n systemPrompt: string,\n query: string,\n context: string,\n timeoutMs: number = 5000\n): Promise<{ answer: string; error?: FallbackReason }> {\n const url = PROVIDER_URLS[provider] ?? provider;\n const controller = new AbortController();\n const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);\n\n try {\n if (provider === 'anthropic') {\n const res = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 512,\n system: systemPrompt,\n messages: [{ role: 'user', content: `Context:\\n${context}\\n\\nQuestion: ${query}` }],\n }),\n signal: controller.signal,\n });\n\n if (!res.ok) {\n const error = classifyError(`${res.status}`);\n return { answer: '', error };\n }\n\n const data = (await res.json()) as { content: Array<{ type: string; text: string }> };\n const answer = data.content?.[0]?.text?.trim() ?? '';\n return { answer };\n }\n\n const res = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model,\n messages: [\n { role: 'system', content: systemPrompt },\n {\n role: 'user',\n content: `Context:\\n${context}\\n\\nQuestion: ${query}`,\n },\n ],\n temperature: 0.3,\n max_tokens: 512,\n }),\n signal: controller.signal,\n });\n\n if (!res.ok) {\n const error = classifyError(`${res.status}`);\n return { answer: '', error };\n }\n\n const data = (await res.json()) as {\n choices: Array<{ message: { content: string } }>;\n };\n const answer = data.choices?.[0]?.message?.content?.trim() ?? '';\n return { answer };\n } catch (err) {\n const error = classifyError(err);\n return { answer: '', error };\n } finally {\n clearTimeout(timeoutHandle);\n }\n}\n\n// ─── Core handler logic ───────────────────────────────────────────────────────\n\nasync function handleQuery(\n query: string,\n config: SageDeskHandlerConfig\n): Promise<{ answer: string; isFallback: boolean; fallbackReason?: FallbackReason }> {\n const {\n indexPath,\n provider,\n apiKey,\n model,\n embeddingModel,\n topK = 5,\n minScore = 0.42,\n systemPrompt = DEFAULT_SYSTEM_PROMPT,\n llmTimeoutMs = 5000,\n } = config;\n\n const index = loadIndexFile(indexPath);\n const embedder = await getEmbedder(embeddingModel);\n\n const queryVector = await embedder.embed(query);\n const results = search(queryVector, index, topK, minScore);\n\n if (results.length === 0) {\n return { answer: '', isFallback: true };\n }\n\n const context = buildAnswer(results);\n const llmResult = await callLLM(\n provider,\n apiKey,\n model,\n systemPrompt,\n query,\n context,\n llmTimeoutMs\n );\n\n if (!llmResult.answer) {\n return {\n answer: '',\n isFallback: true,\n fallbackReason: llmResult.error,\n };\n }\n\n return { answer: llmResult.answer, isFallback: false };\n}\n\n// ─── Next.js App Router handler ───────────────────────────────────────────────\n\n/**\n * Returns a Next.js App Router POST handler.\n *\n * @example\n * // app/api/sagedesk/route.ts\n * import { createSageDeskHandler } from 'sagedesk/server'\n * export const POST = createSageDeskHandler({\n * indexPath: './public/support-index.json',\n * provider: 'deepseek',\n * apiKey: process.env.SAGEDESK_LLM_API_KEY!,\n * model: 'deepseek-chat',\n * })\n */\nexport function createSageDeskHandler(config: SageDeskHandlerConfig) {\n return async function POST(request: Request): Promise<Response> {\n try {\n const body = (await request.json()) as { query?: string };\n const query = body.query?.trim();\n\n if (!query) {\n return Response.json({ error: 'Missing query' }, { status: 400 });\n }\n\n const result = await handleQuery(query, config);\n return Response.json(result);\n } catch (err) {\n console.error('[sagedesk/server] Handler error:', err);\n return Response.json({ answer: '', isFallback: true }, { status: 500 });\n }\n };\n}\n\n// ─── Express / Connect middleware ─────────────────────────────────────────────\n\ntype ExpressRequest = {\n body: { query?: string };\n};\ntype ExpressResponse = {\n status: (code: number) => ExpressResponse;\n json: (data: unknown) => void;\n};\ntype NextFunction = (err?: unknown) => void;\n\n/**\n * Returns an Express (or any Connect-compatible) middleware.\n *\n * @example\n * // server.ts / index.ts\n * import { createSageDeskMiddleware } from 'sagedesk/server'\n * app.use('/api/sagedesk', express.json(), createSageDeskMiddleware({\n * indexPath: './public/support-index.json',\n * provider: 'openai',\n * apiKey: process.env.SAGEDESK_LLM_API_KEY!,\n * model: 'gpt-4o-mini',\n * }))\n */\nexport function createSageDeskMiddleware(config: SageDeskHandlerConfig) {\n return async function sageDeskMiddleware(\n req: ExpressRequest,\n res: ExpressResponse,\n next: NextFunction\n ): Promise<void> {\n try {\n const query = req.body?.query?.trim();\n\n if (!query) {\n res.status(400).json({ error: 'Missing query' });\n return;\n }\n\n const result = await handleQuery(query, config);\n res.json(result);\n } catch (err) {\n console.error('[sagedesk/server] Middleware error:', err);\n next(err);\n }\n };\n}\n","import type { SageDeskModel } from './types';\n\ntype PipelineFn = (\n text: string,\n options: { pooling: string; normalize: boolean }\n) => Promise<{ data: Float32Array }>;\n\n// Maps each supported model alias to its full Xenova/HuggingFace model ID.\nconst XENOVA_IDS: Record<SageDeskModel, string> = {\n 'all-MiniLM-L6-v2': 'Xenova/all-MiniLM-L6-v2',\n 'bge-small-en-v1-5': 'Xenova/bge-small-en-v1.5',\n 'paraphrase-multilingual-MiniLM-L12-v2': 'Xenova/paraphrase-multilingual-MiniLM-L12-v2',\n 'all-mpnet-base-v2': 'Xenova/all-mpnet-base-v2',\n};\n\nexport class EmbedderRuntime {\n private _ready = false;\n private _failed = false;\n\n // Module-level singleton so the WASM model is loaded at most once per page,\n // regardless of how many widget instances exist.\n private static _pipelineInstance: PipelineFn | null = null;\n private static _loadingPromise: Promise<void> | null = null;\n\n async load(model: SageDeskModel = 'all-MiniLM-L6-v2'): Promise<void> {\n if (this._ready) return;\n if (this._failed) throw new Error('EmbedderRuntime previously failed to load');\n\n if (EmbedderRuntime._loadingPromise) {\n await EmbedderRuntime._loadingPromise;\n this._ready = true;\n return;\n }\n\n const modelId = XENOVA_IDS[model];\n\n EmbedderRuntime._loadingPromise = (async () => {\n try {\n const { pipeline } = await import('@huggingface/transformers');\n EmbedderRuntime._pipelineInstance = (await pipeline(\n 'feature-extraction',\n modelId,\n { dtype: 'q8' }\n )) as unknown as PipelineFn;\n } catch (err) {\n EmbedderRuntime._loadingPromise = null;\n EmbedderRuntime._pipelineInstance = null;\n throw err;\n }\n })();\n\n try {\n await EmbedderRuntime._loadingPromise;\n this._ready = true;\n } catch (err) {\n this._failed = true;\n throw err;\n }\n }\n\n async embed(text: string): Promise<Float32Array> {\n if (!EmbedderRuntime._pipelineInstance) {\n await this.load();\n }\n\n try {\n const output = await EmbedderRuntime._pipelineInstance!(text, {\n pooling: 'mean',\n normalize: true,\n });\n return output.data;\n } catch (err) {\n throw new Error(`Embedding failed: ${String(err)}`);\n }\n }\n\n get isReady(): boolean {\n return this._ready;\n }\n\n get hasFailed(): boolean {\n return this._failed;\n }\n\n /** @internal */\n static _reset(): void {\n EmbedderRuntime._pipelineInstance = null;\n EmbedderRuntime._loadingPromise = null;\n }\n}\n","import type { IndexChunk, SearchResult } from './types';\n\n// Both the query vector (embedder.ts, normalize:true) and stored vectors\n// (builder-embedder.ts, normalize:true) are guaranteed unit-length, so\n// cosine similarity reduces to a plain dot product - no norms needed.\nfunction dotProduct(a: Float32Array, b: Float32Array): number {\n if (a.length !== b.length) {\n throw new Error(`Vector dimension mismatch: query(${a.length}) vs index(${b.length})`);\n }\n let dot = 0;\n for (let i = 0; i < a.length; i++) dot += a[i] * b[i];\n return dot;\n}\n\nexport function search(\n queryVector: Float32Array,\n index: IndexChunk[],\n topK = 3,\n minScore = 0.42\n): SearchResult[] {\n // Use a simple selection sort approach for small topK, \n // or a full sort if index is small. For very large indexes, \n // a proper Heap would be better, but O(N * topK) is already better than O(N log N).\n const results: SearchResult[] = [];\n\n for (const chunk of index) {\n const score = dotProduct(queryVector, chunk.vector384 as Float32Array);\n if (score < minScore) continue;\n\n if (results.length < topK) {\n results.push({ chunk, score });\n results.sort((a, b) => b.score - a.score);\n } else if (score > results[topK - 1].score) {\n results[topK - 1] = { chunk, score };\n results.sort((a, b) => b.score - a.score);\n }\n }\n\n return results;\n}\n\nexport function keywordSearch(\n query: string,\n index: IndexChunk[],\n topK = 3\n): SearchResult[] {\n const terms = query\n .toLowerCase()\n .split(/\\s+/)\n .filter((w) => w.length > 2)\n .map((w) => w.replace(/[^a-z0-9]/g, ''));\n\n if (terms.length === 0) return [];\n\n const results: SearchResult[] = [];\n\n for (const chunk of index) {\n const chunkLower = chunk.textLower || chunk.text.toLowerCase();\n const matchCount = terms.filter((t) => chunkLower.includes(t)).length;\n const score = matchCount / terms.length;\n\n if (score <= 0) continue;\n\n if (results.length < topK) {\n results.push({ chunk, score });\n results.sort((a, b) => b.score - a.score);\n } else if (score > results[topK - 1].score) {\n results[topK - 1] = { chunk, score };\n results.sort((a, b) => b.score - a.score);\n }\n }\n\n return results;\n}\n\nexport async function loadIndex(url: string): Promise<IndexChunk[]> {\n const res = await fetch(url);\n if (!res.ok) {\n throw new Error(`Failed to fetch index (HTTP ${res.status}): ${url}`);\n }\n const data = await res.json();\n // Support both the new { meta, chunks } format and the legacy bare-array format.\n const chunks: IndexChunk[] = Array.isArray(data)\n ? data\n : (data as { chunks: IndexChunk[] }).chunks;\n\n // Materialize lowercase versions and convert vectors to Float32Array once at load time.\n for (const chunk of chunks) {\n chunk.textLower = chunk.text.toLowerCase();\n if (Array.isArray(chunk.vector384)) {\n chunk.vector384 = new Float32Array(chunk.vector384);\n }\n if (Array.isArray(chunk.vector768)) {\n chunk.vector768 = new Float32Array(chunk.vector768);\n }\n }\n\n return chunks;\n}\n","import type { SearchResult } from './types';\n\nexport function buildAnswer(results: SearchResult[]): string {\n if (results.length === 0) return '';\n // Deduplicate by sourceId: query expansion produces multiple chunks per\n // source entry (same answer, different query phrasings) - show each source once.\n const seen = new Set<string>();\n const parts: string[] = [];\n for (const r of results) {\n if (!seen.has(r.chunk.sourceId)) {\n seen.add(r.chunk.sourceId);\n parts.push(r.chunk.text);\n }\n }\n return parts.join('\\n\\n');\n}\n\nexport function extractChips(\n index: { text: string; question?: string; sourceId?: string }[],\n override?: string[]\n): string[] {\n if (override && override.length > 0) return override.slice(0, 5);\n\n const chips: string[] = [];\n const seenText = new Set<string>();\n const seenSource = new Set<string>();\n\n for (const chunk of index) {\n if (chips.length >= 5) break;\n\n // Deduplicate by sourceId if available to ensure variety of answers.\n if (chunk.sourceId) {\n if (seenSource.has(chunk.sourceId)) continue;\n seenSource.add(chunk.sourceId);\n }\n\n const candidate = chunk.question ?? extractFirstSentence(chunk.text);\n if (candidate && !seenText.has(candidate)) {\n seenText.add(candidate);\n chips.push(candidate);\n }\n }\n\n return chips;\n}\n\nfunction extractFirstSentence(text: string): string {\n const match = text.match(/^[^\\n.!?]{10,80}[.!?\\n]?/);\n if (!match) return text.slice(0, 60);\n return match[0].trim();\n}\n"],"mappings":"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","names":["res","data","answer"]}
|
|
1
|
+
{"version":3,"sources":["../../src/server/index.ts","../../src/core/server-embedder.ts","../../src/core/search.ts","../../src/core/renderer.ts"],"sourcesContent":["import { readFileSync } from 'fs';\nimport { ServerEmbedder } from '../core/server-embedder.js';\nimport { search } from '../core/search.js';\nimport { buildAnswer } from '../core/renderer.js';\nimport type { IndexChunk, IndexFile, SageDeskModel, FallbackReason } from '../core/types.js';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\nexport interface SageDeskHandlerConfig {\n /** Filesystem path to the pre-built vector index (e.g. \"./public/support-index.json\"). */\n indexPath: string;\n /** LLM provider: 'openai', 'deepseek', 'groq', 'gemini', 'anthropic', or any OpenAI-compatible base URL. */\n provider: string;\n /** API key for the LLM provider. Never sent to the browser. */\n apiKey: string;\n /** LLM model name (e.g. 'deepseek-chat', 'gpt-4o-mini', 'llama3-8b-8192'). */\n model: string;\n /** Embedding model - must match the model used at build time. Defaults to all-MiniLM-L6-v2. */\n embeddingModel?: SageDeskModel;\n /** Number of chunks to retrieve for context. Defaults to 5. */\n topK?: number;\n /** Minimum similarity score for a chunk to be included. Defaults to 0.42. */\n minScore?: number;\n /** Override the system prompt sent to the LLM. */\n systemPrompt?: string;\n /** Timeout for LLM API calls in milliseconds. Defaults to 5000 (5 seconds). */\n llmTimeoutMs?: number;\n}\n\n// ─── Provider URL map ─────────────────────────────────────────────────────────\n\nconst PROVIDER_URLS: Record<string, string> = {\n openai: 'https://api.openai.com/v1/chat/completions',\n deepseek: 'https://api.deepseek.com/chat/completions',\n groq: 'https://api.groq.com/openai/v1/chat/completions',\n gemini: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',\n anthropic: 'https://api.anthropic.com/v1/messages',\n};\n\n// ─── Default system prompt ────────────────────────────────────────────────────\n\nconst DEFAULT_SYSTEM_PROMPT =\n 'You are a helpful support assistant. Answer the user\\'s question based ONLY on the ' +\n 'provided context. If the context does not contain a confident answer, respond with a ' +\n 'friendly message saying you don\\'t have that information right now. Do not make up ' +\n 'information or draw from outside knowledge. Be concise, warm, and helpful.';\n\n// ─── Server-side caches (module-level singletons) ─────────────────────────────\n\nconst indexCache = new Map<string, IndexChunk[]>();\nconst embedderCache = new Map<string, ServerEmbedder>();\n\nfunction loadIndexFile(indexPath: string): IndexChunk[] {\n if (indexCache.has(indexPath)) return indexCache.get(indexPath)!;\n\n const raw = readFileSync(indexPath, 'utf-8');\n const data = JSON.parse(raw) as IndexFile | IndexChunk[];\n const chunks: IndexChunk[] = Array.isArray(data) ? data : data.chunks;\n\n for (const chunk of chunks) {\n chunk.textLower = chunk.text.toLowerCase();\n if (Array.isArray(chunk.vector384)) {\n chunk.vector384 = new Float32Array(chunk.vector384);\n }\n }\n\n indexCache.set(indexPath, chunks);\n return chunks;\n}\n\nasync function getEmbedder(model: SageDeskModel = 'all-MiniLM-L6-v2'): Promise<ServerEmbedder> {\n if (embedderCache.has(model)) return embedderCache.get(model)!;\n\n const embedder = new ServerEmbedder();\n await embedder.load(model);\n embedderCache.set(model, embedder);\n return embedder;\n}\n\n// ─── Helper: Classify error for client-side logging ───────────────────────────\n\nfunction classifyError(error: unknown): FallbackReason {\n const msg = String(error).toLowerCase();\n\n if (msg.includes('401') || msg.includes('403') || msg.includes('unauthorized') || msg.includes('invalid api key')) {\n return 'auth-error';\n }\n if (msg.includes('429') || msg.includes('quota') || msg.includes('rate limit')) {\n return 'quota-exceeded';\n }\n if (msg.includes('timeout') || msg.includes('aborted')) {\n return 'timeout';\n }\n if (msg.includes('malformed') || msg.includes('json')) {\n return 'malformed-response';\n }\n\n return 'api-error';\n}\n\n// ─── LLM call ─────────────────────────────────────────────────────────────────\n\nasync function callLLM(\n provider: string,\n apiKey: string,\n model: string,\n systemPrompt: string,\n query: string,\n context: string,\n timeoutMs: number = 5000\n): Promise<{ answer: string; error?: FallbackReason }> {\n const url = PROVIDER_URLS[provider] ?? provider;\n const controller = new AbortController();\n const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);\n\n try {\n if (provider === 'anthropic') {\n const res = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 512,\n system: systemPrompt,\n messages: [{ role: 'user', content: `Context:\\n${context}\\n\\nQuestion: ${query}` }],\n }),\n signal: controller.signal,\n });\n\n if (!res.ok) {\n const error = classifyError(`${res.status}`);\n return { answer: '', error };\n }\n\n const data = (await res.json()) as { content: Array<{ type: string; text: string }> };\n const answer = data.content?.[0]?.text?.trim() ?? '';\n return { answer };\n }\n\n const res = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model,\n messages: [\n { role: 'system', content: systemPrompt },\n {\n role: 'user',\n content: `Context:\\n${context}\\n\\nQuestion: ${query}`,\n },\n ],\n temperature: 0.3,\n max_tokens: 512,\n }),\n signal: controller.signal,\n });\n\n if (!res.ok) {\n const error = classifyError(`${res.status}`);\n return { answer: '', error };\n }\n\n const data = (await res.json()) as {\n choices: Array<{ message: { content: string } }>;\n };\n const answer = data.choices?.[0]?.message?.content?.trim() ?? '';\n return { answer };\n } catch (err) {\n const error = classifyError(err);\n return { answer: '', error };\n } finally {\n clearTimeout(timeoutHandle);\n }\n}\n\n// ─── Core handler logic ───────────────────────────────────────────────────────\n\nasync function handleQuery(\n query: string,\n config: SageDeskHandlerConfig\n): Promise<{ answer: string; isFallback: boolean; fallbackReason?: FallbackReason }> {\n const {\n indexPath,\n provider,\n apiKey,\n model,\n embeddingModel,\n topK = 5,\n minScore = 0.42,\n systemPrompt = DEFAULT_SYSTEM_PROMPT,\n llmTimeoutMs = 5000,\n } = config;\n\n const index = loadIndexFile(indexPath);\n const embedder = await getEmbedder(embeddingModel);\n\n const queryVector = await embedder.embed(query);\n const results = search(queryVector, index, topK, minScore);\n\n if (results.length === 0) {\n return { answer: '', isFallback: true };\n }\n\n const context = buildAnswer(results);\n const llmResult = await callLLM(\n provider,\n apiKey,\n model,\n systemPrompt,\n query,\n context,\n llmTimeoutMs\n );\n\n if (!llmResult.answer) {\n return {\n answer: '',\n isFallback: true,\n fallbackReason: llmResult.error,\n };\n }\n\n return { answer: llmResult.answer, isFallback: false };\n}\n\n// ─── Next.js App Router handler ───────────────────────────────────────────────\n\n/**\n * Returns a Next.js App Router POST handler.\n *\n * @example\n * // app/api/sagedesk/route.ts\n * import { createSageDeskHandler } from 'sagedesk/server'\n * export const POST = createSageDeskHandler({\n * indexPath: './public/support-index.json',\n * provider: 'deepseek',\n * apiKey: process.env.SAGEDESK_LLM_API_KEY!,\n * model: 'deepseek-chat',\n * })\n */\nexport function createSageDeskHandler(config: SageDeskHandlerConfig) {\n return async function POST(request: Request): Promise<Response> {\n try {\n const body = (await request.json()) as { query?: string };\n const query = body.query?.trim();\n\n if (!query) {\n return Response.json({ error: 'Missing query' }, { status: 400 });\n }\n\n const result = await handleQuery(query, config);\n return Response.json(result);\n } catch (err) {\n console.error('[sagedesk/server] Handler error:', err);\n return Response.json({ answer: '', isFallback: true }, { status: 500 });\n }\n };\n}\n\n// ─── Express / Connect middleware ─────────────────────────────────────────────\n\ntype ExpressRequest = {\n body: { query?: string };\n};\ntype ExpressResponse = {\n status: (code: number) => ExpressResponse;\n json: (data: unknown) => void;\n};\ntype NextFunction = (err?: unknown) => void;\n\n/**\n * Returns an Express (or any Connect-compatible) middleware.\n *\n * @example\n * // server.ts / index.ts\n * import { createSageDeskMiddleware } from 'sagedesk/server'\n * app.use('/api/sagedesk', express.json(), createSageDeskMiddleware({\n * indexPath: './public/support-index.json',\n * provider: 'openai',\n * apiKey: process.env.SAGEDESK_LLM_API_KEY!,\n * model: 'gpt-4o-mini',\n * }))\n */\nexport function createSageDeskMiddleware(config: SageDeskHandlerConfig) {\n return async function sageDeskMiddleware(\n req: ExpressRequest,\n res: ExpressResponse,\n next: NextFunction\n ): Promise<void> {\n try {\n const query = req.body?.query?.trim();\n\n if (!query) {\n res.status(400).json({ error: 'Missing query' });\n return;\n }\n\n const result = await handleQuery(query, config);\n res.json(result);\n } catch (err) {\n console.error('[sagedesk/server] Middleware error:', err);\n next(err);\n }\n };\n}\n","import type { SageDeskModel } from './types';\n\ntype PipelineFn = (\n text: string,\n options: { pooling: string; normalize: boolean }\n) => Promise<{ data: Float32Array }>;\n\n// Must stay in sync with embedder.ts and cli/builder-embedder.ts — same model IDs\n// ensure build-time and runtime vectors share the same embedding space.\nconst XENOVA_IDS: Record<SageDeskModel, string> = {\n 'all-MiniLM-L6-v2': 'Xenova/all-MiniLM-L6-v2',\n 'bge-small-en-v1-5': 'Xenova/bge-small-en-v1.5',\n 'paraphrase-multilingual-MiniLM-L12-v2': 'Xenova/paraphrase-multilingual-MiniLM-L12-v2',\n 'all-mpnet-base-v2': 'Xenova/all-mpnet-base-v2',\n};\n\n/**\n * Server-side embedder optimized for serverless environments (Vercel, Lambda, etc).\n * Uses pure WASM with no native ONNX Runtime dependency.\n * Models are cached at the module level to survive across serverless invocations.\n */\nexport class ServerEmbedder {\n private _ready = false;\n private _failed = false;\n private _model: SageDeskModel = 'all-MiniLM-L6-v2';\n\n // Module-level singleton cache — survives across Lambda/Vercel warm invocations\n private static _pipelineCache = new Map<SageDeskModel, PipelineFn>();\n private static _loadingPromises = new Map<SageDeskModel, Promise<void>>();\n\n async load(model: SageDeskModel = 'all-MiniLM-L6-v2'): Promise<void> {\n if (this._ready) return;\n if (this._failed) throw new Error('ServerEmbedder previously failed to load');\n\n this._model = model;\n\n // Return cached instance if already loaded\n if (ServerEmbedder._pipelineCache.has(model)) {\n this._ready = true;\n return;\n }\n\n // Return existing loading promise if currently loading\n if (ServerEmbedder._loadingPromises.has(model)) {\n await ServerEmbedder._loadingPromises.get(model)!;\n this._ready = true;\n return;\n }\n\n const modelId = XENOVA_IDS[model];\n const loadPromise = this._loadModel(model, modelId);\n ServerEmbedder._loadingPromises.set(model, loadPromise);\n\n try {\n await loadPromise;\n this._ready = true;\n } catch (err) {\n this._failed = true;\n ServerEmbedder._loadingPromises.delete(model);\n throw err;\n } finally {\n ServerEmbedder._loadingPromises.delete(model);\n }\n }\n\n private async _loadModel(model: SageDeskModel, modelId: string): Promise<void> {\n try {\n // device: 'cpu' is the v3.x name for the WASM/CPU backend (replaces 'wasm' from v2.x)\n const { pipeline } = await import('@huggingface/transformers');\n const pipelineInstance = (await pipeline('feature-extraction', modelId, {\n dtype: 'q8',\n device: 'cpu',\n })) as unknown as PipelineFn;\n\n ServerEmbedder._pipelineCache.set(model, pipelineInstance);\n } catch (err) {\n throw new Error(`Failed to load embedding model ${modelId}: ${String(err)}`);\n }\n }\n\n async embed(text: string): Promise<Float32Array> {\n if (!this._ready) {\n await this.load(this._model);\n }\n\n const pipelineInstance = ServerEmbedder._pipelineCache.get(this._model);\n if (!pipelineInstance) {\n throw new Error(`Embedding model ${this._model} not loaded`);\n }\n\n try {\n const output = await pipelineInstance(text, {\n pooling: 'mean',\n normalize: true,\n });\n return output.data;\n } catch (err) {\n throw new Error(`Embedding failed: ${String(err)}`);\n }\n }\n\n get isReady(): boolean {\n return this._ready;\n }\n\n get hasFailed(): boolean {\n return this._failed;\n }\n\n /** @internal - Reset for testing */\n static _reset(): void {\n ServerEmbedder._pipelineCache.clear();\n ServerEmbedder._loadingPromises.clear();\n }\n}\n","import type { IndexChunk, SearchResult } from './types';\n\n// Both the query vector (embedder.ts, normalize:true) and stored vectors\n// (builder-embedder.ts, normalize:true) are guaranteed unit-length, so\n// cosine similarity reduces to a plain dot product - no norms needed.\nfunction dotProduct(a: Float32Array, b: Float32Array): number {\n if (a.length !== b.length) {\n throw new Error(`Vector dimension mismatch: query(${a.length}) vs index(${b.length})`);\n }\n let dot = 0;\n for (let i = 0; i < a.length; i++) dot += a[i] * b[i];\n return dot;\n}\n\n// Inserts item at the correct descending-score position, then trims to maxLen.\n// Avoids Array.sort overhead on every insertion for small topK arrays.\nfunction insertSorted(arr: SearchResult[], item: SearchResult, maxLen: number): void {\n arr.push(item);\n let i = arr.length - 1;\n while (i > 0 && arr[i - 1].score < arr[i].score) {\n const tmp = arr[i - 1]; arr[i - 1] = arr[i]; arr[i] = tmp;\n i--;\n }\n if (arr.length > maxLen) arr.pop();\n}\n\nexport function search(\n queryVector: Float32Array,\n index: IndexChunk[],\n topK = 3,\n minScore = 0.42\n): SearchResult[] {\n const results: SearchResult[] = [];\n\n for (const chunk of index) {\n const score = dotProduct(queryVector, chunk.vector384 as Float32Array);\n if (score < minScore) continue;\n\n if (results.length < topK) {\n insertSorted(results, { chunk, score }, topK);\n } else if (score > results[topK - 1].score) {\n results[topK - 1] = { chunk, score };\n let i = topK - 1;\n while (i > 0 && results[i - 1].score < results[i].score) {\n const tmp = results[i - 1]; results[i - 1] = results[i]; results[i] = tmp;\n i--;\n }\n }\n }\n\n return results;\n}\n\nexport function keywordSearch(\n query: string,\n index: IndexChunk[],\n topK = 3\n): SearchResult[] {\n const terms = query\n .toLowerCase()\n .split(/\\s+/)\n .filter((w) => w.length > 2)\n .map((w) => w.replace(/[^a-z0-9]/g, ''));\n\n if (terms.length === 0) return [];\n\n const results: SearchResult[] = [];\n\n for (const chunk of index) {\n const chunkLower = chunk.textLower || chunk.text.toLowerCase();\n let matchCount = 0;\n for (const t of terms) {\n if (chunkLower.includes(t)) matchCount++;\n }\n const score = matchCount / terms.length;\n\n if (score <= 0) continue;\n\n if (results.length < topK) {\n insertSorted(results, { chunk, score }, topK);\n } else if (score > results[topK - 1].score) {\n results[topK - 1] = { chunk, score };\n let i = topK - 1;\n while (i > 0 && results[i - 1].score < results[i].score) {\n const tmp = results[i - 1]; results[i - 1] = results[i]; results[i] = tmp;\n i--;\n }\n }\n }\n\n return results;\n}\n\nexport async function loadIndex(url: string): Promise<IndexChunk[]> {\n const res = await fetch(url);\n if (!res.ok) {\n throw new Error(`Failed to fetch index (HTTP ${res.status}): ${url}`);\n }\n const data = await res.json();\n // Support both the new { meta, chunks } format and the legacy bare-array format.\n const chunks: IndexChunk[] = Array.isArray(data)\n ? data\n : (data as { chunks: IndexChunk[] }).chunks;\n\n // Materialize lowercase versions and convert vectors to Float32Array once at load time.\n for (const chunk of chunks) {\n chunk.textLower = chunk.text.toLowerCase();\n if (Array.isArray(chunk.vector384)) {\n chunk.vector384 = new Float32Array(chunk.vector384);\n }\n if (Array.isArray(chunk.vector768)) {\n chunk.vector768 = new Float32Array(chunk.vector768);\n }\n }\n\n return chunks;\n}\n","import type { SearchResult } from './types';\n\nexport function buildAnswer(results: SearchResult[]): string {\n if (results.length === 0) return '';\n // Deduplicate by sourceId: query expansion produces multiple chunks per\n // source entry (same answer, different query phrasings) - show each source once.\n const seen = new Set<string>();\n const parts: string[] = [];\n for (const r of results) {\n if (!seen.has(r.chunk.sourceId)) {\n seen.add(r.chunk.sourceId);\n parts.push(r.chunk.text);\n }\n }\n return parts.join('\\n\\n');\n}\n\nexport function extractChips(\n index: { text: string; question?: string; sourceId?: string }[],\n override?: string[]\n): string[] {\n if (override && override.length > 0) return override.slice(0, 5);\n\n const chips: string[] = [];\n const seenText = new Set<string>();\n const seenSource = new Set<string>();\n\n for (const chunk of index) {\n if (chips.length >= 5) break;\n\n // Deduplicate by sourceId if available to ensure variety of answers.\n if (chunk.sourceId) {\n if (seenSource.has(chunk.sourceId)) continue;\n seenSource.add(chunk.sourceId);\n }\n\n const candidate = chunk.question ?? extractFirstSentence(chunk.text);\n if (candidate && !seenText.has(candidate)) {\n seenText.add(candidate);\n chips.push(candidate);\n }\n }\n\n return chips;\n}\n\nfunction extractFirstSentence(text: string): string {\n const match = text.match(/^[^\\n.!?]{10,80}[.!?\\n]?/);\n if (!match) return text.slice(0, 60);\n return match[0].trim();\n}\n"],"mappings":"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","names":["res","data","answer"]}
|