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 +171 -0
- package/dist/index.cjs +268 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +17 -8
- package/dist/index.d.ts +17 -8
- package/dist/index.js +268 -23
- package/dist/index.js.map +1 -1
- package/dist/widget.d.ts +14 -6
- package/dist/widget.js +247 -17
- package/dist/widget.js.map +1 -1
- package/dist/worker.js +1878 -589
- package/dist/worker.js.map +1 -1
- package/package.json +18 -5
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
|
-
|
|
127
|
-
});
|
|
139
|
+
await resetEngine(engine);
|
|
128
140
|
} catch {
|
|
129
141
|
}
|
|
130
142
|
}
|
|
131
|
-
function
|
|
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] =
|
|
357
|
+
next[next.length - 1] = message;
|
|
192
358
|
} else {
|
|
193
|
-
next.push(
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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)
|
|
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" ?
|
|
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.
|
|
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.
|
|
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
|
)),
|