kanha-ai 0.1.4 → 0.1.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/README.md ADDED
@@ -0,0 +1,171 @@
1
+ # kanha-ai
2
+
3
+ Drop-in AI chatbot widget for bots custom-trained on your own website content with [Kanha](https://kanha.ai).
4
+
5
+ The model runs on-device in the visitor's browser over WebGPU. There is no inference API in the loop, so conversations stay on the device and you are not billed per message.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install kanha-ai
11
+ ```
12
+
13
+ React is a peer dependency for the React entry point. The CDN widget and Web Component have no framework dependency.
14
+
15
+ ## React component
16
+
17
+ ```tsx
18
+ import { KanhaBot } from "kanha-ai";
19
+
20
+ export default function App() {
21
+ return (
22
+ <KanhaBot
23
+ modelUrl="https://huggingface.co/your-org/your-bot/resolve/main/"
24
+ botName="Acme Assistant"
25
+ welcomeMessage="Ask me anything about Acme."
26
+ suggestions={["What do you sell?", "How does pricing work?"]}
27
+ theme={{ primaryColor: "#0d9488", position: "bottom-right" }}
28
+ />
29
+ );
30
+ }
31
+ ```
32
+
33
+ ## React hook
34
+
35
+ `useKanhaChat` gives you the same engine with no UI, so you can build your own. `loadProgress` is a
36
+ percentage that restarts on each phase of the first load, so pair it with `loadStage` ("Downloading
37
+ model", "Loading cached model", "Preparing GPU", "Almost ready") to explain the number going down.
38
+
39
+ ```tsx
40
+ import { useKanhaChat } from "kanha-ai";
41
+
42
+ function Chat() {
43
+ const { messages, input, setInput, send, stop, clear, isLoading, mode, loadProgress, loadStage, error } =
44
+ useKanhaChat({ modelUrl: "https://huggingface.co/your-org/your-bot/resolve/main/" });
45
+
46
+ return (
47
+ <form onSubmit={(e) => { e.preventDefault(); send(); }}>
48
+ {messages.map((m, i) => <p key={i}><b>{m.role}</b>: {m.content}</p>)}
49
+ <input value={input} onChange={(e) => setInput(e.target.value)} disabled={isLoading} />
50
+ </form>
51
+ );
52
+ }
53
+ ```
54
+
55
+ ## CDN and vanilla JS
56
+
57
+ ```html
58
+ <div id="chat"></div>
59
+ <script type="module">
60
+ import { mount } from "https://cdn.jsdelivr.net/npm/kanha-ai/dist/widget.js";
61
+
62
+ const bot = mount("#chat", {
63
+ modelUrl: "https://huggingface.co/your-org/your-bot/resolve/main/",
64
+ botName: "Acme Assistant",
65
+ });
66
+ </script>
67
+ ```
68
+
69
+ `mount()` returns `{ stop, clear, destroy }` so you can tear the widget down again.
70
+
71
+ The widget build has no bundler requirement. It pulls the WebGPU runtime at load time from
72
+ `https://cdn.jsdelivr.net/npm/@mlc-ai/web-llm@<version>/+esm`, pinned at publish time to the version
73
+ the package was built against, so a plain `<script type="module">` tag is all you need.
74
+
75
+ ## Web Component
76
+
77
+ Loading `widget.js` registers `<kanha-bot>` automatically.
78
+
79
+ ```html
80
+ <script type="module" src="https://cdn.jsdelivr.net/npm/kanha-ai/dist/widget.js"></script>
81
+
82
+ <kanha-bot
83
+ model-url="https://huggingface.co/your-org/your-bot/resolve/main/"
84
+ bot-name="Acme Assistant"
85
+ primary-color="#0d9488"
86
+ position="bottom-right"
87
+ ></kanha-bot>
88
+ ```
89
+
90
+ Props map to kebab-case attributes (`modelUrl` becomes `model-url`, `ragCorpusUrl` becomes `rag-corpus-url`, `repetitionPenalty` becomes `repetition-penalty`). `suggestions` takes a JSON array string. `theme.primaryColor` and `theme.position` are flattened to `primary-color` and `position`.
91
+
92
+ ## Grounded answers with sources
93
+
94
+ Point `ragCorpusUrl` at the grounding corpus published with your bot and the widget retrieves the
95
+ matching passages before every answer, restricts the model to that context, and renders the source
96
+ pages under the reply.
97
+
98
+ ```html
99
+ <kanha-bot
100
+ model-url="https://huggingface.co/your-org/your-bot/resolve/main/"
101
+ rag-corpus-url="https://huggingface.co/your-org/your-bot/resolve/main/rag-corpus.json"
102
+ bot-name="Acme Assistant"
103
+ ></kanha-bot>
104
+ ```
105
+
106
+ When nothing in the corpus matches the question the bot says so instead of guessing, and no model
107
+ call is made. Assistant messages carry the pages they were drawn from as `sources`, so a custom UI
108
+ built on `useKanhaChat` can render its own citations:
109
+
110
+ ```tsx
111
+ {messages.map((m, i) => (
112
+ <div key={i}>
113
+ {m.content}
114
+ {m.sources?.map((s) => <a key={s.url} href={s.url}>{s.title}</a>)}
115
+ </div>
116
+ ))}
117
+ ```
118
+
119
+ `onRetrieveContext` takes precedence when both are set.
120
+
121
+ ## Props
122
+
123
+ | Prop | Type | Default | Description |
124
+ |------|------|---------|-------------|
125
+ | `modelUrl` | `string` | - | Base URL for your bot's model artifacts |
126
+ | `modelLib` | `string` | auto | URL of the matching WebGPU library file, resolved from `modelSize` when omitted |
127
+ | `modelSize` | `string` | `"small"` | Model identity used for runtime selection and metrics |
128
+ | `systemPrompt` | `string` | - | System prompt for the bot |
129
+ | `temperature` | `number` | `0.7` | Sampling temperature (0-2) |
130
+ | `topP` | `number` | `0.8` | Top-p sampling threshold |
131
+ | `repetitionPenalty` | `number` | `1.1` | Penalty on already generated tokens, which suppresses repeated phrasing |
132
+ | `maxTokens` | `number` | `1024` | Max tokens generated per response |
133
+ | `enableThinking` | `boolean` | `false` | Hidden reasoning where the bot supports it |
134
+ | `maxHistoryMessages` | `number` | `12` | Non-system messages sent to the model |
135
+ | `streamUpdateIntervalMs` | `number` | `50` | Minimum delay between visible streaming updates |
136
+ | `cacheBackend` | `"cache" \| "indexeddb"` | `"indexeddb"` | Browser cache backend for downloaded weights |
137
+ | `workerUrl` | `string \| URL` | - | Run inference in a dedicated Web Worker |
138
+ | `contextWindowSize` | `number` | - | Context window override |
139
+ | `onMetrics` | `(m: KanhaChatMetrics) => void` | - | Token usage and latency callback |
140
+ | `onRetrieveContext` | `(query: string) => Promise<string \| null>` | - | Retrieval hook called before generating |
141
+ | `ragPromptTemplate` | `string` | - | Template for retrieved context, with `{context}` and `{query}` |
142
+ | `ragCorpusUrl` | `string` | - | Grounding corpus to retrieve from, which also turns on source links |
143
+ | `botName` | `string` | `"AI Assistant"` | Display name (widget only) |
144
+ | `welcomeMessage` | `string` | `"Ask me anything!"` | Empty-state message (widget only) |
145
+ | `suggestions` | `string[]` | `[]` | Suggested prompts (widget only) |
146
+ | `theme` | `{ primaryColor?, position? }` | teal, bottom-right | Widget theming |
147
+
148
+ `minRamGb` and `weightBytes` are deprecated and ignored.
149
+
150
+ ## Optional entry points
151
+
152
+ | Import | What it gives you |
153
+ |--------|-------------------|
154
+ | `kanha-ai` | `KanhaBot`, `useKanhaChat`, types |
155
+ | `kanha-ai/widget` | `mount()` and the `<kanha-bot>` element, no React |
156
+ | `kanha-ai/rag` | `LocalRAG` for in-browser retrieval over your own documents |
157
+ | `kanha-ai/worker` | Prebuilt Web Worker script to pass as `workerUrl` |
158
+
159
+ `LocalRAG` needs the optional `@huggingface/transformers` peer dependency installed. Pair it with `onRetrieveContext` to ground answers in documents you supply at runtime.
160
+
161
+ ## Browser requirements
162
+
163
+ WebGPU is required. That means a recent Chrome, Edge, or Chromium-based browser, Safari 18+, or Firefox with WebGPU enabled. Model weights download once and are cached in the browser, so the first message is slower than the rest.
164
+
165
+ ## Docs
166
+
167
+ Full setup guide, including how to get your bot's model URLs: [kanha.ai/docs/sdk](https://kanha.ai/docs/sdk)
168
+
169
+ ## License
170
+
171
+ MIT
package/dist/index.cjs CHANGED
@@ -12,6 +12,7 @@ var ReactMarkdown__default = /*#__PURE__*/_interopDefault(ReactMarkdown);
12
12
 
13
13
  // src/webllm.ts
14
14
  var MODEL_ID = "kanha-custom-model";
15
+ var MINICPM5_SYSTEM_PROMPT = "Answer only from the supplied context. Be concise. If the answer is absent from the context, respond exactly: I can't answer that from the provided context.";
15
16
  var PREBUILT_MODEL_BY_SIZE = {
16
17
  small: "Qwen3-0.6B-q4f16_1-MLC",
17
18
  medium: "Qwen3-1.7B-q4f16_1-MLC",
@@ -71,6 +72,15 @@ function createAppConfig(webllm, config) {
71
72
  useIndexedDBCache: (config.cacheBackend ?? "indexeddb") === "indexeddb"
72
73
  };
73
74
  }
75
+ function describeLoadStage(text) {
76
+ const report = text?.trim();
77
+ if (!report) return "Loading model";
78
+ if (/from cache/i.test(report)) return "Loading cached model";
79
+ if (/fetch|download/i.test(report)) return "Downloading model";
80
+ if (/finish/i.test(report)) return "Almost ready";
81
+ if (/shader|gpu|compil/i.test(report)) return "Preparing GPU";
82
+ return report;
83
+ }
74
84
  async function createEngine(webllm, config, initProgressCallback) {
75
85
  const appConfig = createAppConfig(webllm, config);
76
86
  if (config.workerUrl) {
@@ -121,14 +131,30 @@ function emitMetrics(usage, config) {
121
131
  } catch {
122
132
  }
123
133
  }
124
- function resetEngine(engine) {
134
+ async function resetEngine(engine) {
135
+ await engine.resetChat();
136
+ }
137
+ async function resetEngineBestEffort(engine) {
125
138
  try {
126
- void engine.resetChat().catch(() => {
127
- });
139
+ await resetEngine(engine);
128
140
  } catch {
129
141
  }
130
142
  }
131
- function stripThinkTokens(text) {
143
+ function usesMiniCPMModel(config) {
144
+ return config.modelSize?.startsWith("minicpm5-") === true;
145
+ }
146
+ function usesPrefilledMiniCPMThinking(config) {
147
+ return config.enableThinking === true && usesMiniCPMModel(config);
148
+ }
149
+ function resolveSystemPrompt(config) {
150
+ if (config.systemPrompt?.trim()) return config.systemPrompt;
151
+ return usesMiniCPMModel(config) ? MINICPM5_SYSTEM_PROMPT : void 0;
152
+ }
153
+ function stripThinkTokens(text, startsInsideThinking = false) {
154
+ if (startsInsideThinking && !text.includes("</think>")) return "";
155
+ if (startsInsideThinking && !text.includes("<think>")) {
156
+ text = text.slice(text.indexOf("</think>") + "</think>".length);
157
+ }
132
158
  let cleaned = text.replace(/<think>[\s\S]*?<\/think>/g, "");
133
159
  cleaned = cleaned.replace(/<think>[\s\S]*$/g, "");
134
160
  return cleaned.trimStart();
@@ -150,6 +176,141 @@ function buildEngineHistory(messages, maxHistoryMessages, currentUserContent) {
150
176
  return history;
151
177
  }
152
178
 
179
+ // src/grounded-corpus.ts
180
+ var GROUNDED_SYSTEM_PROMPT = "Answer only from the supplied context. Be concise. If the answer is not in the context, say you don't have that information on this site and suggest where to look.";
181
+ var GROUNDED_NO_MATCH_REPLY = "I don't have information about that on this site.";
182
+ var CONTEXT_CHARACTER_BUDGET = 2400;
183
+ var STOP_WORDS = /* @__PURE__ */ new Set([
184
+ "a",
185
+ "an",
186
+ "and",
187
+ "are",
188
+ "as",
189
+ "at",
190
+ "be",
191
+ "but",
192
+ "by",
193
+ "can",
194
+ "did",
195
+ "do",
196
+ "does",
197
+ "for",
198
+ "from",
199
+ "has",
200
+ "have",
201
+ "how",
202
+ "i",
203
+ "in",
204
+ "is",
205
+ "it",
206
+ "its",
207
+ "me",
208
+ "my",
209
+ "of",
210
+ "on",
211
+ "or",
212
+ "that",
213
+ "the",
214
+ "their",
215
+ "them",
216
+ "then",
217
+ "there",
218
+ "these",
219
+ "they",
220
+ "this",
221
+ "to",
222
+ "was",
223
+ "we",
224
+ "what",
225
+ "when",
226
+ "where",
227
+ "which",
228
+ "who",
229
+ "why",
230
+ "will",
231
+ "with",
232
+ "you",
233
+ "your"
234
+ ]);
235
+ function normalizeTerms(text) {
236
+ return text.toLowerCase().split(/[^a-z0-9]+/).filter((term) => term.length > 1 && !STOP_WORDS.has(term));
237
+ }
238
+ function chunkTerms(chunk) {
239
+ const source = [chunk.title, chunk.text, ...chunk.aliases ?? []].join(" ");
240
+ return new Set(normalizeTerms(source));
241
+ }
242
+ function documentFrequencies(chunks, termSets) {
243
+ const perDocument = /* @__PURE__ */ new Map();
244
+ chunks.forEach((chunk, index) => {
245
+ const existing = perDocument.get(chunk.document_id);
246
+ const target = existing ?? /* @__PURE__ */ new Set();
247
+ for (const term of termSets[index]) target.add(term);
248
+ if (!existing) perDocument.set(chunk.document_id, target);
249
+ });
250
+ const frequencies = /* @__PURE__ */ new Map();
251
+ for (const terms of perDocument.values()) {
252
+ for (const term of terms) frequencies.set(term, (frequencies.get(term) ?? 0) + 1);
253
+ }
254
+ return { frequencies, documentCount: perDocument.size };
255
+ }
256
+ function selectGroundedChunks(corpus, query, characterBudget = CONTEXT_CHARACTER_BUDGET) {
257
+ const queryTerms = new Set(normalizeTerms(query));
258
+ if (queryTerms.size === 0) return [];
259
+ const chunks = corpus.chunks ?? [];
260
+ const termSets = chunks.map(chunkTerms);
261
+ const { frequencies, documentCount } = documentFrequencies(chunks, termSets);
262
+ const scored = chunks.map((chunk, index) => {
263
+ let score = 0;
264
+ let matched = 0;
265
+ for (const term of queryTerms) {
266
+ if (!termSets[index].has(term)) continue;
267
+ matched += 1;
268
+ score += Math.log(1 + documentCount / (1 + (frequencies.get(term) ?? 0)));
269
+ }
270
+ return { chunk, score, matched, index };
271
+ }).filter((entry) => entry.matched > 0).sort((a, b) => b.score - a.score || b.matched - a.matched || a.index - b.index);
272
+ const selected = [];
273
+ let used = 0;
274
+ for (const entry of scored) {
275
+ const cost = entry.chunk.text.length;
276
+ if (selected.length > 0 && used + cost > characterBudget) break;
277
+ selected.push(entry.chunk);
278
+ used += cost;
279
+ }
280
+ return selected;
281
+ }
282
+ function buildGroundedTurn(corpus, query, characterBudget = CONTEXT_CHARACTER_BUDGET) {
283
+ const chunks = selectGroundedChunks(corpus, query, characterBudget);
284
+ if (chunks.length === 0) return null;
285
+ const seen = /* @__PURE__ */ new Set();
286
+ const sources = [];
287
+ for (const chunk of chunks) {
288
+ if (seen.has(chunk.document_id)) continue;
289
+ seen.add(chunk.document_id);
290
+ sources.push({ title: chunk.title, url: chunk.url });
291
+ }
292
+ return {
293
+ context: chunks.map((chunk) => `${chunk.title} (${chunk.url}): ${chunk.text}`).join("\n\n"),
294
+ sources
295
+ };
296
+ }
297
+ function buildGroundedUserContent(context, question) {
298
+ return `Context:
299
+ ${context}
300
+
301
+ Question:
302
+ ${question}`;
303
+ }
304
+ async function fetchGroundedCorpus(url) {
305
+ const response = await fetch(url, { cache: "force-cache" });
306
+ if (!response.ok) throw new Error(`Failed to load grounding corpus (${response.status})`);
307
+ const payload = await response.json();
308
+ if (!Array.isArray(payload?.chunks) || payload.chunks.length === 0) {
309
+ throw new Error("Grounding corpus has no chunks");
310
+ }
311
+ return payload;
312
+ }
313
+
153
314
  // src/use-kanha-chat.ts
154
315
  function isMemoryError(err) {
155
316
  if (!(err instanceof Error)) return false;
@@ -163,11 +324,14 @@ function useKanhaChat(config) {
163
324
  const [isThinking, setIsThinking] = react.useState(false);
164
325
  const [mode, setMode] = react.useState("detecting");
165
326
  const [loadProgress, setLoadProgress] = react.useState(0);
327
+ const [loadStage, setLoadStage] = react.useState("Loading model");
166
328
  const [error, setError] = react.useState(null);
167
329
  const engineRef = react.useRef(null);
168
330
  const workerRef = react.useRef(null);
169
331
  const streamTimerRef = react.useRef(null);
170
332
  const pendingTextRef = react.useRef(null);
333
+ const pendingSourcesRef = react.useRef(void 0);
334
+ const corpusRef = react.useRef(null);
171
335
  const generationRef = react.useRef(0);
172
336
  const configRef = react.useRef(config);
173
337
  configRef.current = config;
@@ -185,16 +349,24 @@ function useKanhaChat(config) {
185
349
  return;
186
350
  }
187
351
  setIsThinking(false);
352
+ const sources = pendingSourcesRef.current;
188
353
  setMessages((prev) => {
189
354
  const next = [...prev];
355
+ const message = { role: "assistant", content: cleaned, sources };
190
356
  if (next[next.length - 1]?.role === "assistant") {
191
- next[next.length - 1] = { role: "assistant", content: cleaned };
357
+ next[next.length - 1] = message;
192
358
  } else {
193
- next.push({ role: "assistant", content: cleaned });
359
+ next.push(message);
194
360
  }
195
361
  return next;
196
362
  });
197
363
  }, []);
364
+ const loadCorpus = react.useCallback((url) => {
365
+ if (corpusRef.current?.url !== url) {
366
+ corpusRef.current = { url, corpus: fetchGroundedCorpus(url) };
367
+ }
368
+ return corpusRef.current.corpus;
369
+ }, []);
198
370
  const scheduleAssistantMessage = react.useCallback((text) => {
199
371
  pendingTextRef.current = text;
200
372
  if (streamTimerRef.current) return;
@@ -233,11 +405,12 @@ function useKanhaChat(config) {
233
405
  return;
234
406
  }
235
407
  setMode("loading");
236
- setMode("loading");
237
408
  try {
238
409
  const webllm = await import('@mlc-ai/web-llm');
239
410
  const result = await createEngine(webllm, initConfig, (report) => {
240
- if (!cancelled) setLoadProgress(Math.round(report.progress * 100));
411
+ if (cancelled) return;
412
+ setLoadProgress(Math.round(report.progress * 100));
413
+ setLoadStage(describeLoadStage(report.text));
241
414
  });
242
415
  if (cancelled) {
243
416
  await disposeEngine(result.engine, result.worker);
@@ -306,10 +479,12 @@ function useKanhaChat(config) {
306
479
  setError(null);
307
480
  try {
308
481
  const currentConfig = configRef.current;
309
- const systemMessages = currentConfig.systemPrompt ? [{ role: "system", content: currentConfig.systemPrompt }] : [];
482
+ let systemPrompt = resolveSystemPrompt(currentConfig);
310
483
  let engineUserContent = userText;
484
+ let groundedTemperature = null;
311
485
  let ragTime = 0;
312
486
  let ragContextLength = 0;
487
+ pendingSourcesRef.current = void 0;
313
488
  if (currentConfig.onRetrieveContext) {
314
489
  try {
315
490
  const ragStartTime = performance.now();
@@ -322,21 +497,53 @@ function useKanhaChat(config) {
322
497
  } catch (err) {
323
498
  console.error("[KanhaBot] RAG Retrieval Failed:", err);
324
499
  }
500
+ } else if (currentConfig.ragCorpusUrl) {
501
+ const ragStartTime = performance.now();
502
+ let corpus = null;
503
+ try {
504
+ corpus = await loadCorpus(currentConfig.ragCorpusUrl);
505
+ } catch (err) {
506
+ console.error("[KanhaBot] Grounding corpus failed to load:", err);
507
+ }
508
+ ragTime = (performance.now() - ragStartTime) / 1e3;
509
+ if (generationRef.current !== generation) return;
510
+ if (corpus) {
511
+ const grounded = buildGroundedTurn(corpus, userText);
512
+ if (!grounded) {
513
+ setMessages((prev) => [
514
+ ...prev,
515
+ { role: "assistant", content: GROUNDED_NO_MATCH_REPLY }
516
+ ]);
517
+ return;
518
+ }
519
+ ragContextLength = grounded.context.length;
520
+ engineUserContent = buildGroundedUserContent(grounded.context, userText);
521
+ systemPrompt = GROUNDED_SYSTEM_PROMPT;
522
+ groundedTemperature = 0;
523
+ pendingSourcesRef.current = grounded.sources;
524
+ }
325
525
  }
326
526
  if (generationRef.current !== generation) return;
527
+ const systemMessages = systemPrompt ? [{ role: "system", content: systemPrompt }] : [];
327
528
  const engineHistory = buildEngineHistory(
328
529
  allMessages,
329
530
  currentConfig.maxHistoryMessages,
330
531
  engineUserContent
331
532
  );
533
+ const prefilledMiniCPMThinking = usesPrefilledMiniCPMThinking(currentConfig);
534
+ if (usesMiniCPMModel(currentConfig)) {
535
+ await resetEngine(engineRef.current);
536
+ if (generationRef.current !== generation) return;
537
+ }
332
538
  const startTime = performance.now();
333
539
  let firstTokenTime = null;
334
540
  let contentChunkCount = 0;
335
541
  let completionTokens = null;
336
542
  const completion = await engineRef.current.chat.completions.create({
337
543
  messages: [...systemMessages, ...engineHistory],
338
- temperature: currentConfig.temperature ?? 0.7,
544
+ temperature: groundedTemperature ?? currentConfig.temperature ?? 0.7,
339
545
  top_p: currentConfig.topP ?? 0.8,
546
+ repetition_penalty: currentConfig.repetitionPenalty ?? 1.1,
340
547
  max_tokens: currentConfig.maxTokens ?? 1024,
341
548
  stream: true,
342
549
  stream_options: { include_usage: true },
@@ -353,7 +560,7 @@ function useKanhaChat(config) {
353
560
  if (firstTokenTime === null) firstTokenTime = performance.now();
354
561
  contentChunkCount += 1;
355
562
  text += delta;
356
- scheduleAssistantMessage(text);
563
+ scheduleAssistantMessage(stripThinkTokens(text, prefilledMiniCPMThinking));
357
564
  }
358
565
  if (chunk.usage) {
359
566
  completionTokens = chunk.usage.completion_tokens;
@@ -371,7 +578,7 @@ function useKanhaChat(config) {
371
578
  const decodeTime = firstTokenTime ? (endTime - firstTokenTime) / 1e3 : 0;
372
579
  const tps = decodeTime > 0 ? Math.max(0, outputTokens - 1) / decodeTime : 0;
373
580
  const totalTime = (endTime - startTime) / 1e3;
374
- const ragStatsLog = currentConfig.onRetrieveContext ? `RAG Retrieval Time: ${ragTime.toFixed(3)}s
581
+ const ragStatsLog = ragContextLength > 0 ? `RAG Retrieval Time: ${ragTime.toFixed(3)}s
375
582
  RAG Context Inserted: ${ragContextLength} characters
376
583
  ` : "";
377
584
  console.info(
@@ -401,10 +608,10 @@ Output Tokens: ${outputTokens}`
401
608
  setIsThinking(false);
402
609
  }
403
610
  }
404
- }, [input, mode, isLoading, messages, scheduleAssistantMessage, flushAssistantMessage]);
611
+ }, [input, mode, isLoading, messages, scheduleAssistantMessage, flushAssistantMessage, loadCorpus]);
405
612
  const clear = react.useCallback(() => {
406
613
  stop();
407
- if (engineRef.current) resetEngine(engineRef.current);
614
+ if (engineRef.current) void resetEngineBestEffort(engineRef.current);
408
615
  setMessages([]);
409
616
  setError(null);
410
617
  }, [stop]);
@@ -416,6 +623,7 @@ Output Tokens: ${outputTokens}`
416
623
  isThinking,
417
624
  mode,
418
625
  loadProgress,
626
+ loadStage,
419
627
  error,
420
628
  send,
421
629
  stop,
@@ -607,6 +815,27 @@ function progressBarFill(progress, primaryColor) {
607
815
  transition: "width 0.3s ease"
608
816
  };
609
817
  }
818
+ var sourcesContainerStyle = {
819
+ display: "flex",
820
+ flexWrap: "wrap",
821
+ gap: 6,
822
+ marginTop: 8,
823
+ paddingTop: 8,
824
+ borderTop: "1px solid #f0f1f2"
825
+ };
826
+ var sourceLinkStyle = {
827
+ fontSize: 11,
828
+ lineHeight: 1.4,
829
+ color: "#4b5563",
830
+ textDecoration: "none",
831
+ border: "1px solid #e5e7eb",
832
+ borderRadius: 999,
833
+ padding: "2px 8px",
834
+ maxWidth: "100%",
835
+ overflow: "hidden",
836
+ textOverflow: "ellipsis",
837
+ whiteSpace: "nowrap"
838
+ };
610
839
  var ChatIcon = () => /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" }) });
611
840
  var CloseIcon = () => /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
612
841
  /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" }),
@@ -645,6 +874,7 @@ function KanhaBot({
645
874
  systemPrompt,
646
875
  temperature,
647
876
  topP,
877
+ repetitionPenalty,
648
878
  maxTokens,
649
879
  enableThinking,
650
880
  maxHistoryMessages,
@@ -659,7 +889,8 @@ function KanhaBot({
659
889
  suggestions = [],
660
890
  theme = {},
661
891
  onRetrieveContext,
662
- ragPromptTemplate
892
+ ragPromptTemplate,
893
+ ragCorpusUrl
663
894
  }) {
664
895
  const primaryColor = theme.primaryColor ?? "#0d9488";
665
896
  const position = theme.position ?? "bottom-right";
@@ -671,6 +902,7 @@ function KanhaBot({
671
902
  isThinking,
672
903
  mode,
673
904
  loadProgress,
905
+ loadStage,
674
906
  error,
675
907
  send,
676
908
  clear
@@ -681,6 +913,7 @@ function KanhaBot({
681
913
  systemPrompt,
682
914
  temperature,
683
915
  topP,
916
+ repetitionPenalty,
684
917
  maxTokens,
685
918
  enableThinking,
686
919
  maxHistoryMessages,
@@ -691,8 +924,10 @@ function KanhaBot({
691
924
  contextWindowSize,
692
925
  minRamGb,
693
926
  onRetrieveContext,
694
- ragPromptTemplate
927
+ ragPromptTemplate,
928
+ ragCorpusUrl
695
929
  });
930
+ const loadingLabel = `${loadStage} (${loadProgress}%)`;
696
931
  const [isOpen, setIsOpen] = react.useState(false);
697
932
  const messagesEndRef = react.useRef(null);
698
933
  const textareaRef = react.useRef(null);
@@ -721,7 +956,7 @@ function KanhaBot({
721
956
  /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { ...headerStyle, background: primaryColor }, children: [
722
957
  /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
723
958
  /* @__PURE__ */ jsxRuntime.jsx("p", { style: headerTitleStyle, children: botName }),
724
- /* @__PURE__ */ jsxRuntime.jsx("p", { style: headerSubtitleStyle, children: isReady ? welcomeMessage : mode === "loading" ? `Loading AI model (${loadProgress}%)` : mode === "error" ? "Failed to load" : "Detecting capabilities..." }),
959
+ /* @__PURE__ */ jsxRuntime.jsx("p", { style: headerSubtitleStyle, children: isReady ? welcomeMessage : mode === "loading" ? loadingLabel : mode === "error" ? "Failed to load" : "Detecting capabilities..." }),
725
960
  mode === "loading" && /* @__PURE__ */ jsxRuntime.jsx("div", { style: progressBarContainer, children: /* @__PURE__ */ jsxRuntime.jsx("div", { style: progressBarFill(loadProgress, "#fff") }) })
726
961
  ] }),
727
962
  /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { display: "flex", gap: 4 }, children: [
@@ -753,11 +988,7 @@ function KanhaBot({
753
988
  )) })
754
989
  ] }) : mode === "loading" ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
755
990
  /* @__PURE__ */ jsxRuntime.jsx(Spinner, { size: 32 }),
756
- /* @__PURE__ */ jsxRuntime.jsxs("p", { style: { marginTop: 8 }, children: [
757
- "Loading AI model (",
758
- loadProgress,
759
- "%)"
760
- ] }),
991
+ /* @__PURE__ */ jsxRuntime.jsx("p", { style: { marginTop: 8 }, children: loadingLabel }),
761
992
  /* @__PURE__ */ jsxRuntime.jsx("p", { style: { fontSize: 12, marginTop: 4 }, children: "First load takes about a minute" })
762
993
  ] }) : mode === "error" ? /* @__PURE__ */ jsxRuntime.jsx("p", { children: error }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
763
994
  /* @__PURE__ */ jsxRuntime.jsx(Spinner, { size: 32 }),
@@ -770,7 +1001,21 @@ function KanhaBot({
770
1001
  display: "flex",
771
1002
  justifyContent: msg.role === "user" ? "flex-end" : "flex-start"
772
1003
  },
773
- children: /* @__PURE__ */ jsxRuntime.jsx("div", { style: msg.role === "user" ? userBubbleStyle(primaryColor) : assistantBubbleStyle, children: msg.role === "assistant" ? /* @__PURE__ */ jsxRuntime.jsx(ReactMarkdown__default.default, { children: msg.content }) : /* @__PURE__ */ jsxRuntime.jsx("span", { style: { whiteSpace: "pre-wrap" }, children: msg.content }) })
1004
+ children: /* @__PURE__ */ jsxRuntime.jsx("div", { style: msg.role === "user" ? userBubbleStyle(primaryColor) : assistantBubbleStyle, children: msg.role === "assistant" ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1005
+ /* @__PURE__ */ jsxRuntime.jsx(ReactMarkdown__default.default, { children: msg.content }),
1006
+ msg.sources && msg.sources.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { style: sourcesContainerStyle, children: msg.sources.map((source) => /* @__PURE__ */ jsxRuntime.jsx(
1007
+ "a",
1008
+ {
1009
+ href: source.url,
1010
+ target: "_blank",
1011
+ rel: "noopener",
1012
+ title: source.url,
1013
+ style: sourceLinkStyle,
1014
+ children: source.title
1015
+ },
1016
+ source.url
1017
+ )) })
1018
+ ] }) : /* @__PURE__ */ jsxRuntime.jsx("span", { style: { whiteSpace: "pre-wrap" }, children: msg.content }) })
774
1019
  },
775
1020
  i
776
1021
  )),