chatbotlite 0.3.1 → 0.5.0

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.
@@ -3,8 +3,60 @@ import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
3
3
 
4
4
  // src/react/ChatWidget.tsx
5
5
 
6
+ // src/core/tools.ts
7
+ var MARKER_RE = /\[SKILL:(\w+)((?:\s+\w+=(?:"[^"]*"|[\w./@*+,:-]+))*)\s*\]/g;
8
+ var ARG_RE = /(\w+)=("([^"]*)"|([\w./@*+,:-]+))/g;
9
+ function coerce(value) {
10
+ if (value === "true") return true;
11
+ if (value === "false") return false;
12
+ if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value);
13
+ return value;
14
+ }
15
+ function parseToolMarkers(text) {
16
+ const markers = [];
17
+ let m;
18
+ MARKER_RE.lastIndex = 0;
19
+ while ((m = MARKER_RE.exec(text)) !== null) {
20
+ const name = m[1];
21
+ const argsRaw = m[2] ?? "";
22
+ const args = {};
23
+ let a;
24
+ ARG_RE.lastIndex = 0;
25
+ while ((a = ARG_RE.exec(argsRaw)) !== null) {
26
+ const key = a[1];
27
+ const value = a[3] ?? a[4] ?? "";
28
+ args[key] = coerce(value);
29
+ }
30
+ markers.push({ name, args, raw: m[0] });
31
+ }
32
+ return markers;
33
+ }
34
+ function stripToolMarkers(text) {
35
+ return text.replace(MARKER_RE, "").replace(/\s+\n/g, "\n").trim();
36
+ }
37
+ function buildToolsPromptAddendum(enabledTools) {
38
+ if (enabledTools.length === 0) return "";
39
+ const examples = {
40
+ uploadForReview: '[SKILL:uploadForReview purpose="T4 slip" accept="image/*,application/pdf" maxMb=10] \u2014 collect a document for human review (bytes go to webhook, you never see content)',
41
+ scheduleCallback: '[SKILL:scheduleCallback durationMin=15 timezone="America/Vancouver"] \u2014 let the user pick a callback time slot',
42
+ requestPayment: '[SKILL:requestPayment amount=4250 currency="cad" reason="initial deposit"] \u2014 collect payment via inline card'
43
+ };
44
+ const lines = enabledTools.filter((t) => examples[t]).map((t) => `- ${examples[t]}`);
45
+ if (lines.length === 0) return "";
46
+ return [
47
+ "",
48
+ "## Available tools",
49
+ "When you need one of these workflows, emit the marker INLINE in your reply.",
50
+ "Write a short message first, THEN the marker. The marker will be replaced by an interactive card.",
51
+ "Pause the conversation after emitting \u2014 wait for the tool result before continuing.",
52
+ "",
53
+ ...lines
54
+ ].join("\n");
55
+ }
56
+
6
57
  // src/core/prompts.ts
7
- function buildSystemPrompt(knowledge) {
58
+ function buildSystemPrompt(knowledge, enabledTools = []) {
59
+ const toolsAddendum = buildToolsPromptAddendum(enabledTools);
8
60
  return [
9
61
  "You are an AI assistant on a business website. Use ONLY the knowledge below to answer.",
10
62
  "",
@@ -17,33 +69,23 @@ function buildSystemPrompt(knowledge) {
17
69
  "- For anything not covered in the knowledge above, say the owner will follow up \u2014 do NOT guess.",
18
70
  '- If the caller is clearly a vendor/sales pitch, say: "This does not look like a customer service request, so we will not continue this thread."',
19
71
  `- If wrong number or asked to stop, say: "Sorry about that. We won't text again."`,
20
- "- Match the caller's language automatically."
21
- ].join("\n");
72
+ "- Match the caller's language automatically.",
73
+ toolsAddendum
74
+ ].filter(Boolean).join("\n");
22
75
  }
23
76
 
24
77
  // src/core/guards.ts
25
78
  var FORBIDDEN_PHRASES = [
26
- "help is coming",
27
- "someone is on the way",
28
- "technician is on the way",
29
- "provider is on the way",
30
- "dispatching someone",
31
79
  "i've booked",
80
+ // fake booking
32
81
  "i have booked",
33
- "reservation confirmed",
34
82
  "your appointment is confirmed",
35
- "i've scheduled",
36
- "i have scheduled",
37
- "we've dispatched",
38
- "we have dispatched",
39
- "i can confirm",
40
- "i guarantee",
41
- "guaranteed delivery",
42
- "guaranteed arrival",
43
- "will arrive at",
44
- "arriving at",
45
- "i'll send",
46
- "i will send"
83
+ // fake confirmation
84
+ "reservation confirmed",
85
+ "someone is on the way",
86
+ // false dispatch
87
+ "i guarantee"
88
+ // legal liability
47
89
  ];
48
90
  function checkForbiddenPhrases(reply) {
49
91
  const lower = reply.toLowerCase();
@@ -68,6 +110,33 @@ function stripForbidden(reply) {
68
110
  return trimmed;
69
111
  }
70
112
 
113
+ // src/core/judges.ts
114
+ async function runJudge(config, apiKey, endpointUrl, content, fetcher) {
115
+ const res = await fetcher(`${endpointUrl}/chat/completions`, {
116
+ method: "POST",
117
+ headers: {
118
+ Authorization: `Bearer ${apiKey}`,
119
+ "Content-Type": "application/json"
120
+ },
121
+ body: JSON.stringify({
122
+ model: config.model,
123
+ messages: [
124
+ { role: "system", content: config.prompt },
125
+ { role: "user", content }
126
+ ],
127
+ temperature: 0,
128
+ max_tokens: 10
129
+ })
130
+ });
131
+ if (!res.ok) {
132
+ return { decision: "PASS", raw: `judge HTTP ${res.status} \u2014 fail-open` };
133
+ }
134
+ const data = await res.json();
135
+ const raw = (data.choices?.[0]?.message?.content ?? "").trim().toUpperCase();
136
+ const decision = raw.startsWith("BLOCK") ? "BLOCK" : "PASS";
137
+ return { decision, raw };
138
+ }
139
+
71
140
  // src/client/types.ts
72
141
  var PROVIDER_NAMES = /* @__PURE__ */ new Set([
73
142
  "openai",
@@ -88,22 +157,34 @@ function isKnownProvider(name) {
88
157
 
89
158
  // src/client/providers.ts
90
159
  var PROVIDER_ENDPOINTS = {
91
- openai: { baseUrl: "https://api.openai.com/v1", defaultModel: "gpt-4o-mini" },
160
+ openai: { baseUrl: "https://api.openai.com/v1", defaultModel: "gpt-4o-mini", visionModel: "gpt-4o" },
92
161
  deepseek: { baseUrl: "https://api.deepseek.com/v1", defaultModel: "deepseek-chat" },
93
- groq: { baseUrl: "https://api.groq.com/openai/v1", defaultModel: "llama-3.3-70b-versatile" },
94
- gemini: { baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", defaultModel: "gemini-2.5-flash" },
95
- anthropic: { baseUrl: "https://api.anthropic.com/v1", defaultModel: "claude-haiku-4-5" },
162
+ groq: { baseUrl: "https://api.groq.com/openai/v1", defaultModel: "llama-3.3-70b-versatile", visionModel: "llama-3.2-90b-vision-preview" },
163
+ gemini: { baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", defaultModel: "gemini-2.5-flash", visionModel: "gemini-2.5-flash" },
164
+ anthropic: { baseUrl: "https://api.anthropic.com/v1", defaultModel: "claude-haiku-4-5", visionModel: "claude-haiku-4-5" },
96
165
  cerebras: { baseUrl: "https://api.cerebras.ai/v1", defaultModel: "qwen-3-235b-a22b-instruct-2507" },
97
166
  sambanova: { baseUrl: "https://api.sambanova.ai/v1", defaultModel: "Meta-Llama-3.3-70B-Instruct" },
98
167
  fireworks: { baseUrl: "https://api.fireworks.ai/inference/v1", defaultModel: "accounts/fireworks/models/llama-v3p3-70b-instruct" },
99
168
  mistral: { baseUrl: "https://api.mistral.ai/v1", defaultModel: "mistral-small-latest" },
100
- openrouter: { baseUrl: "https://openrouter.ai/api/v1", defaultModel: "deepseek/deepseek-chat" },
101
- moonshot: { baseUrl: "https://api.moonshot.ai/v1", defaultModel: "moonshot-v1-32k" }
169
+ openrouter: { baseUrl: "https://openrouter.ai/api/v1", defaultModel: "deepseek/deepseek-chat", visionModel: "openai/gpt-4o" },
170
+ moonshot: { baseUrl: "https://api.moonshot.ai/v1", defaultModel: "moonshot-v1-32k", visionModel: "moonshot-v1-32k-vision-preview" }
102
171
  };
103
172
  function isRetryableError(err) {
104
173
  const msg = err instanceof Error ? err.message : String(err);
105
174
  return /\b(429|rate.?limit|quota|exceed|5\d\d|timeout|ECONNRESET|fetch failed)\b/i.test(msg);
106
175
  }
176
+ async function fileToDataUrl(file) {
177
+ const buf = new Uint8Array(await file.arrayBuffer());
178
+ const base64 = bufferToBase64(buf);
179
+ const mime = file.type || "application/octet-stream";
180
+ return `data:${mime};base64,${base64}`;
181
+ }
182
+ function bufferToBase64(buf) {
183
+ let bin = "";
184
+ for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]);
185
+ if (typeof btoa === "function") return btoa(bin);
186
+ return globalThis.Buffer.from(bin, "binary").toString("base64");
187
+ }
107
188
 
108
189
  // src/client/chatbot.ts
109
190
  var ChatBot = class {
@@ -112,18 +193,258 @@ var ChatBot = class {
112
193
  fetcher;
113
194
  timeoutMs;
114
195
  cachedSystemPrompt;
196
+ guards;
197
+ knowledge;
115
198
  constructor(init) {
116
199
  if (!init.knowledge || typeof init.knowledge !== "string" || init.knowledge.trim().length === 0) {
117
200
  throw new Error("chatbotlite: knowledge is required (a non-empty markdown string).");
118
201
  }
202
+ this.knowledge = init.knowledge;
119
203
  this.keys = init.providers.keys ?? {};
120
204
  this.steps = resolveChain(init.providers);
121
205
  this.fetcher = init.options?.fetch ?? globalThis.fetch.bind(globalThis);
122
206
  this.timeoutMs = init.options?.timeoutMs ?? 3e4;
123
207
  this.cachedSystemPrompt = buildSystemPrompt(init.knowledge);
208
+ this.guards = init.guards ?? {};
209
+ }
210
+ /** Build system prompt for given opts — uses cached if no enabledTools, else rebuilds. */
211
+ resolveSystemPrompt(opts) {
212
+ if (opts.systemPrompt) return opts.systemPrompt;
213
+ if (opts.enabledTools && opts.enabledTools.length > 0) {
214
+ return buildSystemPrompt(this.knowledge, opts.enabledTools);
215
+ }
216
+ return this.cachedSystemPrompt;
217
+ }
218
+ /** Run an LLM judge against content. Fail-open on errors. */
219
+ async judge(config, content) {
220
+ const endpoint = PROVIDER_ENDPOINTS[config.provider];
221
+ const key = this.keys[config.provider];
222
+ if (!key) {
223
+ return { decision: "PASS", raw: `judge provider ${config.provider} has no key \u2014 fail-open` };
224
+ }
225
+ const model = config.model ?? endpoint.defaultModel;
226
+ return runJudge(
227
+ { provider: config.provider, model, prompt: config.prompt },
228
+ key,
229
+ endpoint.baseUrl,
230
+ content,
231
+ this.fetcher
232
+ );
233
+ }
234
+ /**
235
+ * Stream a reply as SSE events. Returns a ReadableStream that yields tokens
236
+ * progressively. Designed to plug into Next.js/Hono/Express route handlers:
237
+ *
238
+ * ```ts
239
+ * export async function POST(req: Request) {
240
+ * const { message, transcript } = await req.json();
241
+ * const stream = await bot.replyStream(message, { history: transcript });
242
+ * return new Response(stream, {
243
+ * headers: { "Content-Type": "text/event-stream" }
244
+ * });
245
+ * }
246
+ * ```
247
+ *
248
+ * Events emitted (one per `data:` line, SSE format):
249
+ * event: token data: "<text fragment>"
250
+ * event: done data: {"reply":"...","usedProvider":"...","usedModel":"...","attempts":[...]}
251
+ * event: error data: {"message":"...","attempts":[...]}
252
+ */
253
+ async replyStream(message, opts = {}) {
254
+ const systemPrompt = this.resolveSystemPrompt(opts);
255
+ const messages = [
256
+ { role: "system", content: systemPrompt },
257
+ ...opts.history ?? [],
258
+ { role: "user", content: message }
259
+ ];
260
+ const steps = this.steps;
261
+ const fetcher = this.fetcher;
262
+ const keys = this.keys;
263
+ const timeoutMs = this.timeoutMs;
264
+ const encoder = new TextEncoder();
265
+ const sse = (event, data) => encoder.encode(`event: ${event}
266
+ data: ${data}
267
+
268
+ `);
269
+ return new ReadableStream({
270
+ async start(controller) {
271
+ const attempts = [];
272
+ let lastError;
273
+ let assembled = "";
274
+ for (const step of steps) {
275
+ const t0 = Date.now();
276
+ const endpoint = PROVIDER_ENDPOINTS[step.provider];
277
+ const key = keys[step.provider];
278
+ if (!key) {
279
+ attempts.push({ provider: step.provider, model: step.model, status: "error", error: "missing key", latencyMs: 0 });
280
+ continue;
281
+ }
282
+ const abortCtrl = new AbortController();
283
+ const timer = setTimeout(() => abortCtrl.abort(), timeoutMs);
284
+ try {
285
+ const res = await fetcher(`${endpoint.baseUrl}/chat/completions`, {
286
+ method: "POST",
287
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
288
+ body: JSON.stringify({ model: step.model, messages, temperature: 0.3, max_tokens: 300, stream: true }),
289
+ signal: abortCtrl.signal
290
+ });
291
+ if (!res.ok) {
292
+ const body = await res.text();
293
+ throw new Error(`${res.status}: ${body.slice(0, 200)}`);
294
+ }
295
+ const reader = res.body.getReader();
296
+ const decoder = new TextDecoder();
297
+ let sseBuffer = "";
298
+ while (true) {
299
+ const { done, value } = await reader.read();
300
+ if (done) break;
301
+ sseBuffer += decoder.decode(value, { stream: true });
302
+ const lines = sseBuffer.split("\n");
303
+ sseBuffer = lines.pop() ?? "";
304
+ for (const line of lines) {
305
+ const trimmed = line.trim();
306
+ if (!trimmed.startsWith("data:")) continue;
307
+ const payload = trimmed.slice(5).trim();
308
+ if (payload === "[DONE]") continue;
309
+ try {
310
+ const obj = JSON.parse(payload);
311
+ const delta = obj.choices?.[0]?.delta?.content ?? obj.choices?.[0]?.delta?.reasoning_content ?? "";
312
+ if (delta) {
313
+ assembled += delta;
314
+ controller.enqueue(sse("token", JSON.stringify(delta)));
315
+ }
316
+ } catch {
317
+ }
318
+ }
319
+ }
320
+ attempts.push({ provider: step.provider, model: step.model, status: "ok", latencyMs: Date.now() - t0 });
321
+ const guard = checkForbiddenPhrases(assembled);
322
+ const finalReply = guard.ok ? assembled : stripForbidden(assembled);
323
+ controller.enqueue(sse("done", JSON.stringify({
324
+ reply: finalReply,
325
+ usedProvider: step.provider,
326
+ usedModel: step.model,
327
+ guardWarnings: guard.violations,
328
+ attempts
329
+ })));
330
+ controller.close();
331
+ return;
332
+ } catch (err) {
333
+ lastError = err;
334
+ const errMsg = err instanceof Error ? err.message : String(err);
335
+ attempts.push({ provider: step.provider, model: step.model, status: "error", error: errMsg, latencyMs: Date.now() - t0 });
336
+ assembled = "";
337
+ if (!isRetryableError(err)) {
338
+ controller.enqueue(sse("error", JSON.stringify({ message: `${step.label} failed (non-retryable): ${errMsg}`, attempts })));
339
+ controller.close();
340
+ return;
341
+ }
342
+ } finally {
343
+ clearTimeout(timer);
344
+ }
345
+ }
346
+ const summary = attempts.map((a) => `${a.provider}/${a.model}:${a.error ?? "ok"}`).join(" \u2192 ");
347
+ controller.enqueue(sse("error", JSON.stringify({
348
+ message: `all chain steps failed. Trace: ${summary}. Last error: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
349
+ attempts
350
+ })));
351
+ controller.close();
352
+ }
353
+ });
354
+ }
355
+ /**
356
+ * Reply to a message with optional image attachments. Routes through vision-capable
357
+ * providers only — chain steps without `visionModel` are skipped (logged in attempts).
358
+ *
359
+ * Images are inlined as data: URLs. Keep total payload modest (<10MB).
360
+ */
361
+ async replyWithMedia(message, opts = {}) {
362
+ const images = opts.images ?? [];
363
+ if (images.length === 0) {
364
+ return this.reply(message, opts);
365
+ }
366
+ const dataUrls = await Promise.all(images.map(fileToDataUrl));
367
+ const systemPrompt = this.resolveSystemPrompt(opts);
368
+ const userContent = [];
369
+ if (message) userContent.push({ type: "text", text: message });
370
+ for (const url of dataUrls) userContent.push({ type: "image_url", image_url: { url } });
371
+ const messages = [
372
+ { role: "system", content: systemPrompt },
373
+ ...opts.history ?? [],
374
+ { role: "user", content: userContent }
375
+ ];
376
+ const attempts = [];
377
+ let lastError;
378
+ for (const step of this.steps) {
379
+ const endpoint = PROVIDER_ENDPOINTS[step.provider];
380
+ if (!endpoint.visionModel) {
381
+ attempts.push({ provider: step.provider, model: step.model, status: "error", error: "no vision support", latencyMs: 0 });
382
+ continue;
383
+ }
384
+ const t0 = Date.now();
385
+ const visionStep = { provider: step.provider, model: endpoint.visionModel, label: `${step.provider}/${endpoint.visionModel}` };
386
+ try {
387
+ const key = this.keys[step.provider];
388
+ if (!key) throw new Error(`Missing API key for provider: ${step.provider}`);
389
+ const controller = new AbortController();
390
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
391
+ try {
392
+ const res = await this.fetcher(`${endpoint.baseUrl}/chat/completions`, {
393
+ method: "POST",
394
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
395
+ body: JSON.stringify({ model: visionStep.model, messages, temperature: 0.3, max_tokens: 400 }),
396
+ signal: controller.signal
397
+ });
398
+ if (!res.ok) {
399
+ const body = await res.text();
400
+ throw new Error(`${res.status}: ${body.slice(0, 200)}`);
401
+ }
402
+ const data = await res.json();
403
+ const reply = (data.choices?.[0]?.message?.content ?? "").trim();
404
+ if (!reply) throw new Error("empty vision reply");
405
+ attempts.push({ provider: visionStep.provider, model: visionStep.model, status: "ok", latencyMs: Date.now() - t0 });
406
+ const guard = checkForbiddenPhrases(reply);
407
+ const finalReply = guard.ok ? reply : stripForbidden(reply);
408
+ return {
409
+ reply: finalReply,
410
+ usedProvider: visionStep.provider,
411
+ usedModel: visionStep.model,
412
+ ...data.usage ? { usage: data.usage } : {},
413
+ guardWarnings: guard.violations,
414
+ attempts
415
+ };
416
+ } finally {
417
+ clearTimeout(timer);
418
+ }
419
+ } catch (err) {
420
+ lastError = err;
421
+ const errMsg = err instanceof Error ? err.message : String(err);
422
+ attempts.push({ provider: visionStep.provider, model: visionStep.model, status: "error", error: errMsg, latencyMs: Date.now() - t0 });
423
+ if (!isRetryableError(err)) {
424
+ throw new Error(`chatbotlite: ${visionStep.label} vision failed (non-retryable). ${errMsg}`);
425
+ }
426
+ }
427
+ }
428
+ const summary = attempts.map((a) => `${a.provider}/${a.model}:${a.error ?? "ok"}`).join(" \u2192 ");
429
+ throw new Error(`chatbotlite: no vision-capable provider succeeded. Trace: ${summary}. Last error: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
124
430
  }
125
431
  async reply(message, opts = {}) {
126
- const systemPrompt = opts.systemPrompt ?? this.cachedSystemPrompt;
432
+ let inputVerdict;
433
+ if (this.guards.inputJudge) {
434
+ inputVerdict = await this.judge(this.guards.inputJudge, message);
435
+ if (inputVerdict.decision === "BLOCK") {
436
+ return {
437
+ reply: "I can't process that request. Please ask in a different way.",
438
+ usedProvider: this.steps[0].provider,
439
+ usedModel: this.steps[0].model,
440
+ guardWarnings: [],
441
+ judges: { input: inputVerdict },
442
+ attempts: [],
443
+ blockedByInputJudge: true
444
+ };
445
+ }
446
+ }
447
+ const systemPrompt = this.resolveSystemPrompt(opts);
127
448
  const messages = [
128
449
  { role: "system", content: systemPrompt },
129
450
  ...opts.history ?? [],
@@ -137,13 +458,21 @@ var ChatBot = class {
137
458
  const result = await this.callProvider(step, messages);
138
459
  attempts.push({ provider: step.provider, model: step.model, status: "ok", latencyMs: Date.now() - t0 });
139
460
  const guard = checkForbiddenPhrases(result.reply);
140
- const finalReply = guard.ok ? result.reply : stripForbidden(result.reply);
461
+ let finalReply = guard.ok ? result.reply : stripForbidden(result.reply);
462
+ let outputVerdict;
463
+ if (this.guards.outputJudge) {
464
+ outputVerdict = await this.judge(this.guards.outputJudge, finalReply);
465
+ if (outputVerdict.decision === "BLOCK") {
466
+ finalReply = "Let me check with the owner and get back to you on that.";
467
+ }
468
+ }
141
469
  return {
142
470
  reply: finalReply,
143
471
  usedProvider: step.provider,
144
472
  usedModel: step.model,
145
473
  ...result.usage ? { usage: result.usage } : {},
146
474
  guardWarnings: guard.violations,
475
+ ...inputVerdict || outputVerdict ? { judges: { ...inputVerdict ? { input: inputVerdict } : {}, ...outputVerdict ? { output: outputVerdict } : {} } } : {},
147
476
  attempts
148
477
  };
149
478
  } catch (err) {
@@ -228,6 +557,458 @@ function normalizeChainEntry(entry, keys) {
228
557
  }
229
558
  return { provider, model, label: `${provider}/${model}` };
230
559
  }
560
+ var CLOUD_ICON = "M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12";
561
+ function UploadForReview(props) {
562
+ const {
563
+ purpose = "Files",
564
+ accept = "*",
565
+ maxMb = 10,
566
+ primary,
567
+ onPrimary,
568
+ border,
569
+ surface,
570
+ surfaceMuted,
571
+ textBody,
572
+ textMuted,
573
+ onSubmit,
574
+ onCancel,
575
+ submitting = false,
576
+ submitted = false
577
+ } = props;
578
+ const inputRef = useRef(null);
579
+ const [files, setFiles] = useState([]);
580
+ const [drag, setDrag] = useState(false);
581
+ function add(picked) {
582
+ const arr = Array.from(picked).filter((f) => f.size <= maxMb * 1024 * 1024);
583
+ setFiles((p) => [...p, ...arr]);
584
+ }
585
+ function remove(i) {
586
+ setFiles((p) => p.filter((_, j) => j !== i));
587
+ }
588
+ if (submitted) {
589
+ return /* @__PURE__ */ jsxs("div", { style: {
590
+ padding: "12px 16px",
591
+ borderRadius: 14,
592
+ background: surface,
593
+ border: `1px solid ${border}`,
594
+ boxShadow: "0 1px 2px rgba(15,23,42,0.04)",
595
+ color: textBody,
596
+ fontSize: 13
597
+ }, children: [
598
+ "\u2713 ",
599
+ files.length || 1,
600
+ " file",
601
+ files.length === 1 ? "" : "s",
602
+ " submitted for review."
603
+ ] });
604
+ }
605
+ return /* @__PURE__ */ jsxs("div", { style: {
606
+ padding: 16,
607
+ borderRadius: 14,
608
+ background: surface,
609
+ border: `1px solid ${border}`,
610
+ boxShadow: "0 2px 8px -2px rgba(15,23,42,0.08)"
611
+ }, children: [
612
+ /* @__PURE__ */ jsxs("p", { style: { margin: 0, marginBottom: 12, fontSize: 13, fontWeight: 600, color: textBody }, children: [
613
+ "Upload your ",
614
+ purpose
615
+ ] }),
616
+ /* @__PURE__ */ jsxs(
617
+ "div",
618
+ {
619
+ onClick: () => inputRef.current?.click(),
620
+ onDragOver: (e) => {
621
+ e.preventDefault();
622
+ setDrag(true);
623
+ },
624
+ onDragLeave: () => setDrag(false),
625
+ onDrop: (e) => {
626
+ e.preventDefault();
627
+ setDrag(false);
628
+ if (e.dataTransfer.files) add(e.dataTransfer.files);
629
+ },
630
+ style: {
631
+ border: `1.5px dashed ${drag ? primary : border}`,
632
+ borderRadius: 12,
633
+ padding: "20px 12px",
634
+ textAlign: "center",
635
+ cursor: "pointer",
636
+ background: drag ? `${primary}0a` : surfaceMuted,
637
+ transition: "border-color 120ms ease, background 120ms ease"
638
+ },
639
+ children: [
640
+ /* @__PURE__ */ jsx("svg", { width: "28", height: "28", viewBox: "0 0 24 24", fill: "none", stroke: primary, strokeWidth: "1.5", style: { display: "block", margin: "0 auto 6px" }, children: /* @__PURE__ */ jsx("path", { d: CLOUD_ICON, strokeLinecap: "round", strokeLinejoin: "round" }) }),
641
+ /* @__PURE__ */ jsxs("p", { style: { margin: 0, fontSize: 13, fontWeight: 500, color: textBody }, children: [
642
+ "Drop file",
643
+ maxMb > 0 ? "s" : "",
644
+ " here or click to browse"
645
+ ] }),
646
+ /* @__PURE__ */ jsxs("p", { style: { margin: "4px 0 0", fontSize: 11, color: textMuted }, children: [
647
+ accept === "*" ? "Any file" : accept,
648
+ " \xB7 max ",
649
+ maxMb,
650
+ "MB"
651
+ ] }),
652
+ /* @__PURE__ */ jsx(
653
+ "input",
654
+ {
655
+ ref: inputRef,
656
+ type: "file",
657
+ multiple: true,
658
+ accept,
659
+ style: { display: "none" },
660
+ onChange: (e) => {
661
+ if (e.target.files) add(e.target.files);
662
+ e.target.value = "";
663
+ }
664
+ }
665
+ )
666
+ ]
667
+ }
668
+ ),
669
+ files.length > 0 && /* @__PURE__ */ jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 6, marginTop: 10 }, children: files.map((f, i) => /* @__PURE__ */ jsxs(
670
+ "span",
671
+ {
672
+ style: {
673
+ display: "inline-flex",
674
+ alignItems: "center",
675
+ gap: 6,
676
+ padding: "4px 8px 4px 10px",
677
+ borderRadius: 8,
678
+ background: surfaceMuted,
679
+ border: `1px solid ${border}`,
680
+ fontSize: 12,
681
+ color: textBody,
682
+ maxWidth: 220
683
+ },
684
+ children: [
685
+ /* @__PURE__ */ jsxs("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: [
686
+ "\u{1F4C4} ",
687
+ f.name
688
+ ] }),
689
+ /* @__PURE__ */ jsx(
690
+ "button",
691
+ {
692
+ onClick: () => remove(i),
693
+ "aria-label": `Remove ${f.name}`,
694
+ style: { background: "transparent", border: "none", cursor: "pointer", color: textMuted, fontSize: 14, lineHeight: 1, padding: 0 },
695
+ children: "\xD7"
696
+ }
697
+ )
698
+ ]
699
+ },
700
+ `${f.name}-${i}`
701
+ )) }),
702
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 8, marginTop: 12 }, children: [
703
+ onCancel && /* @__PURE__ */ jsx(
704
+ "button",
705
+ {
706
+ onClick: onCancel,
707
+ disabled: submitting,
708
+ style: {
709
+ padding: "8px 14px",
710
+ borderRadius: 10,
711
+ background: "transparent",
712
+ color: textMuted,
713
+ border: `1px solid ${border}`,
714
+ fontSize: 13,
715
+ cursor: submitting ? "default" : "pointer"
716
+ },
717
+ children: "Cancel"
718
+ }
719
+ ),
720
+ /* @__PURE__ */ jsx(
721
+ "button",
722
+ {
723
+ onClick: () => void onSubmit(files),
724
+ disabled: submitting || files.length === 0,
725
+ style: {
726
+ flex: 1,
727
+ padding: "9px 16px",
728
+ borderRadius: 10,
729
+ background: primary,
730
+ color: onPrimary,
731
+ border: "none",
732
+ fontSize: 13,
733
+ fontWeight: 600,
734
+ cursor: submitting || files.length === 0 ? "default" : "pointer",
735
+ opacity: submitting || files.length === 0 ? 0.4 : 1
736
+ },
737
+ children: submitting ? "Submitting\u2026" : "Submit"
738
+ }
739
+ )
740
+ ] })
741
+ ] });
742
+ }
743
+ function ScheduleCallback(props) {
744
+ const {
745
+ durationMin = 15,
746
+ timezone = "UTC",
747
+ primary,
748
+ onPrimary,
749
+ border,
750
+ surface,
751
+ surfaceMuted,
752
+ textBody,
753
+ textMuted,
754
+ getAvailableSlots,
755
+ onConfirm,
756
+ submitting = false,
757
+ submitted = false,
758
+ submittedSlot
759
+ } = props;
760
+ const [slots, setSlots] = useState([]);
761
+ const [loading, setLoading] = useState(true);
762
+ const [selected, setSelected] = useState(null);
763
+ useEffect(() => {
764
+ let cancelled = false;
765
+ setLoading(true);
766
+ getAvailableSlots({ durationMin, timezone }).then((s) => {
767
+ if (cancelled) return;
768
+ setSlots(s);
769
+ setLoading(false);
770
+ }).catch(() => {
771
+ if (!cancelled) setLoading(false);
772
+ });
773
+ return () => {
774
+ cancelled = true;
775
+ };
776
+ }, [durationMin, timezone, getAvailableSlots]);
777
+ if (submitted && submittedSlot) {
778
+ return /* @__PURE__ */ jsxs("div", { style: {
779
+ padding: "12px 16px",
780
+ borderRadius: 14,
781
+ background: surface,
782
+ border: `1px solid ${border}`,
783
+ fontSize: 13,
784
+ color: textBody
785
+ }, children: [
786
+ "\u2713 Booked for ",
787
+ formatSlot(submittedSlot)
788
+ ] });
789
+ }
790
+ return /* @__PURE__ */ jsxs("div", { style: {
791
+ padding: 16,
792
+ borderRadius: 14,
793
+ background: surface,
794
+ border: `1px solid ${border}`,
795
+ boxShadow: "0 2px 8px -2px rgba(15,23,42,0.08)"
796
+ }, children: [
797
+ /* @__PURE__ */ jsxs("p", { style: { margin: 0, marginBottom: 12, fontSize: 13, fontWeight: 600, color: textBody }, children: [
798
+ "Pick a ",
799
+ durationMin,
800
+ "-minute slot"
801
+ ] }),
802
+ loading ? /* @__PURE__ */ jsx("p", { style: { fontSize: 12, color: textMuted }, children: "Loading availability\u2026" }) : slots.length === 0 ? /* @__PURE__ */ jsx("p", { style: { fontSize: 12, color: textMuted }, children: "No slots available \u2014 the owner will follow up." }) : /* @__PURE__ */ jsx("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 6 }, children: slots.map((slot) => {
803
+ const isSel = selected === slot;
804
+ return /* @__PURE__ */ jsx(
805
+ "button",
806
+ {
807
+ onClick: () => setSelected(slot),
808
+ style: {
809
+ padding: "8px 10px",
810
+ borderRadius: 10,
811
+ fontSize: 12,
812
+ fontWeight: isSel ? 600 : 500,
813
+ cursor: "pointer",
814
+ background: isSel ? `${primary}0d` : surfaceMuted,
815
+ color: textBody,
816
+ border: `1px solid ${isSel ? primary : border}`,
817
+ transition: "border-color 120ms ease, background 120ms ease"
818
+ },
819
+ children: formatSlot(slot)
820
+ },
821
+ slot
822
+ );
823
+ }) }),
824
+ /* @__PURE__ */ jsx(
825
+ "button",
826
+ {
827
+ onClick: () => {
828
+ if (selected) void onConfirm(selected);
829
+ },
830
+ disabled: !selected || submitting,
831
+ style: {
832
+ width: "100%",
833
+ marginTop: 12,
834
+ padding: "9px 16px",
835
+ borderRadius: 10,
836
+ background: primary,
837
+ color: onPrimary,
838
+ border: "none",
839
+ fontSize: 13,
840
+ fontWeight: 600,
841
+ cursor: selected && !submitting ? "pointer" : "default",
842
+ opacity: selected && !submitting ? 1 : 0.4
843
+ },
844
+ children: submitting ? "Booking\u2026" : "Confirm"
845
+ }
846
+ )
847
+ ] });
848
+ }
849
+ function formatSlot(iso) {
850
+ try {
851
+ const d = new Date(iso);
852
+ return d.toLocaleString(void 0, {
853
+ weekday: "short",
854
+ month: "short",
855
+ day: "numeric",
856
+ hour: "numeric",
857
+ minute: "2-digit"
858
+ });
859
+ } catch {
860
+ return iso;
861
+ }
862
+ }
863
+ function formatAmount(amountMinor, currency = "USD") {
864
+ try {
865
+ return new Intl.NumberFormat(void 0, {
866
+ style: "currency",
867
+ currency: currency.toUpperCase(),
868
+ minimumFractionDigits: 2
869
+ }).format(amountMinor / 100);
870
+ } catch {
871
+ return `${currency.toUpperCase()} ${(amountMinor / 100).toFixed(2)}`;
872
+ }
873
+ }
874
+ function RequestPayment(props) {
875
+ const {
876
+ amount,
877
+ currency = "USD",
878
+ reason,
879
+ primary,
880
+ border,
881
+ surface,
882
+ textBody,
883
+ textMuted,
884
+ showInterac = true,
885
+ stripeLink,
886
+ onPick,
887
+ submitting = false,
888
+ submitted = false,
889
+ submittedMethod
890
+ } = props;
891
+ const formatted = formatAmount(amount, currency);
892
+ if (submitted) {
893
+ return /* @__PURE__ */ jsxs("div", { style: {
894
+ padding: "12px 16px",
895
+ borderRadius: 14,
896
+ background: surface,
897
+ border: `1px solid ${border}`,
898
+ fontSize: 13,
899
+ color: textBody
900
+ }, children: [
901
+ "\u2713 ",
902
+ submittedMethod === "interac" ? "Interac request sent \u2014 instructions to follow" : "Payment opened",
903
+ " \xB7 ",
904
+ formatted
905
+ ] });
906
+ }
907
+ return /* @__PURE__ */ jsxs("div", { style: {
908
+ padding: 16,
909
+ borderRadius: 14,
910
+ background: surface,
911
+ border: `1px solid ${border}`,
912
+ boxShadow: "0 2px 8px -2px rgba(15,23,42,0.08)"
913
+ }, children: [
914
+ /* @__PURE__ */ jsxs("div", { style: { marginBottom: 12 }, children: [
915
+ /* @__PURE__ */ jsx("p", { style: { margin: 0, fontSize: 13, fontWeight: 600, color: textBody }, children: reason || "Payment required" }),
916
+ /* @__PURE__ */ jsx("p", { style: { margin: "2px 0 0", fontSize: 20, fontWeight: 700, color: textBody, letterSpacing: "-0.01em" }, children: formatted })
917
+ ] }),
918
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [
919
+ showInterac && /* @__PURE__ */ jsxs(
920
+ "button",
921
+ {
922
+ onClick: () => void onPick("interac"),
923
+ disabled: submitting,
924
+ style: {
925
+ display: "flex",
926
+ alignItems: "center",
927
+ gap: 12,
928
+ padding: 12,
929
+ borderRadius: 12,
930
+ background: surface,
931
+ border: `1px solid ${border}`,
932
+ cursor: submitting ? "default" : "pointer",
933
+ opacity: submitting ? 0.5 : 1,
934
+ textAlign: "left",
935
+ transition: "border-color 120ms ease"
936
+ },
937
+ onMouseEnter: (e) => {
938
+ e.currentTarget.style.borderColor = primary;
939
+ },
940
+ onMouseLeave: (e) => {
941
+ e.currentTarget.style.borderColor = border;
942
+ },
943
+ children: [
944
+ /* @__PURE__ */ jsx("div", { style: {
945
+ width: 40,
946
+ height: 40,
947
+ borderRadius: 10,
948
+ background: "#FFBE2E1f",
949
+ display: "flex",
950
+ alignItems: "center",
951
+ justifyContent: "center",
952
+ fontSize: 18,
953
+ fontWeight: 700,
954
+ color: "#B8860B",
955
+ flexShrink: 0
956
+ }, children: "$" }),
957
+ /* @__PURE__ */ jsxs("div", { style: { flex: 1, minWidth: 0 }, children: [
958
+ /* @__PURE__ */ jsx("p", { style: { margin: 0, fontSize: 13, fontWeight: 600, color: textBody }, children: "Interac e-Transfer" }),
959
+ /* @__PURE__ */ jsx("p", { style: { margin: "2px 0 0", fontSize: 11, color: textMuted }, children: "Instant, no fees" })
960
+ ] })
961
+ ]
962
+ }
963
+ ),
964
+ /* @__PURE__ */ jsxs(
965
+ "button",
966
+ {
967
+ onClick: () => {
968
+ if (stripeLink) window.open(stripeLink, "_blank", "noopener");
969
+ void onPick("stripe");
970
+ },
971
+ disabled: submitting,
972
+ style: {
973
+ display: "flex",
974
+ alignItems: "center",
975
+ gap: 12,
976
+ padding: 12,
977
+ borderRadius: 12,
978
+ background: surface,
979
+ border: `1px solid ${border}`,
980
+ cursor: submitting ? "default" : "pointer",
981
+ opacity: submitting ? 0.5 : 1,
982
+ textAlign: "left",
983
+ transition: "border-color 120ms ease"
984
+ },
985
+ onMouseEnter: (e) => {
986
+ e.currentTarget.style.borderColor = primary;
987
+ },
988
+ onMouseLeave: (e) => {
989
+ e.currentTarget.style.borderColor = border;
990
+ },
991
+ children: [
992
+ /* @__PURE__ */ jsx("div", { style: {
993
+ width: 40,
994
+ height: 40,
995
+ borderRadius: 10,
996
+ background: "#635BFF1a",
997
+ display: "flex",
998
+ alignItems: "center",
999
+ justifyContent: "center",
1000
+ flexShrink: 0
1001
+ }, children: /* @__PURE__ */ jsx("span", { style: { color: "#635BFF", fontSize: 14, fontWeight: 700 }, children: "\u{1F4B3}" }) }),
1002
+ /* @__PURE__ */ jsxs("div", { style: { flex: 1, minWidth: 0 }, children: [
1003
+ /* @__PURE__ */ jsx("p", { style: { margin: 0, fontSize: 13, fontWeight: 600, color: textBody }, children: "Pay by card" }),
1004
+ /* @__PURE__ */ jsx("p", { style: { margin: "2px 0 0", fontSize: 11, color: textMuted }, children: "Visa \xB7 Mastercard \xB7 Amex" })
1005
+ ] })
1006
+ ]
1007
+ }
1008
+ )
1009
+ ] })
1010
+ ] });
1011
+ }
231
1012
  var BOLT = "\u26A1";
232
1013
  var DEFAULT_PRIMARY = "#0f172a";
233
1014
  var DEFAULT_ON_PRIMARY = "#ffffff";
@@ -244,7 +1025,9 @@ var KEYFRAMES = `
244
1025
  @keyframes chatbotlite-slide { 0% { opacity: 0; transform: translateY(16px) scale(0.98); } 100% { opacity: 1; transform: translateY(0) scale(1); } }
245
1026
  @keyframes chatbotlite-fade-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
246
1027
  @keyframes chatbotlite-dot { 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } 30% { transform: translateY(-4px); opacity: 1; } }
247
- .chatbotlite-launcher { transition: transform 180ms cubic-bezier(0.4, 0, 0.2, 1), box-shadow 180ms cubic-bezier(0.4, 0, 0.2, 1); }
1028
+ @keyframes chatbotlite-cursor { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
1029
+ @keyframes chatbotlite-pulse { 0%, 100% { box-shadow: 0 12px 28px -8px rgba(15,23,42,0.32), 0 4px 8px -2px rgba(15,23,42,0.12); } 50% { box-shadow: 0 14px 32px -8px rgba(15,23,42,0.36), 0 6px 12px -2px rgba(15,23,42,0.16); } }
1030
+ .chatbotlite-launcher { transition: transform 180ms cubic-bezier(0.4, 0, 0.2, 1), box-shadow 180ms cubic-bezier(0.4, 0, 0.2, 1); animation: chatbotlite-pop 320ms cubic-bezier(0.34, 1.56, 0.64, 1), chatbotlite-pulse 3.6s ease-in-out 1.2s 2; }
248
1031
  .chatbotlite-launcher:hover { transform: translateY(-2px) scale(1.04); }
249
1032
  .chatbotlite-launcher:active { transform: translateY(0) scale(0.98); }
250
1033
  .chatbotlite-close { transition: background 120ms ease; }
@@ -255,6 +1038,9 @@ var KEYFRAMES = `
255
1038
  .chatbotlite-input:focus { box-shadow: 0 0 0 3px rgba(15,23,42,0.06); }
256
1039
  .chatbotlite-msg { animation: chatbotlite-fade-in 220ms cubic-bezier(0.4, 0, 0.2, 1); }
257
1040
  .chatbotlite-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: ${TEXT_FAINT}; margin-right: 4px; animation: chatbotlite-dot 1.2s ease-in-out infinite; }
1041
+ .chatbotlite-cursor { display: inline-block; width: 2px; height: 1em; background: currentColor; vertical-align: text-bottom; margin-left: 1px; animation: chatbotlite-cursor 1s steps(1) infinite; }
1042
+ .chatbotlite-attach-btn:hover:not(:disabled), .chatbotlite-voice-btn:hover:not(:disabled) { background: ${BORDER}; }
1043
+ .chatbotlite-attach-btn:active:not(:disabled), .chatbotlite-voice-btn:active:not(:disabled) { transform: scale(0.96); }
258
1044
  .chatbotlite-dot:nth-child(2) { animation-delay: 0.15s; }
259
1045
  .chatbotlite-dot:nth-child(3) { animation-delay: 0.3s; margin-right: 0; }
260
1046
  .chatbotlite-brand:hover { color: ${TEXT_MUTED} !important; }
@@ -281,14 +1067,105 @@ function ChatWidget(props) {
281
1067
  const resolvedGreeting = greeting ?? "Hi! How can we help?";
282
1068
  const primary = themeOverrides?.primary ?? DEFAULT_PRIMARY;
283
1069
  const onPrimary = themeOverrides?.onPrimary ?? DEFAULT_ON_PRIMARY;
1070
+ const attachCfg = props.attach;
1071
+ const attachEnabled = attachCfg?.enabled === true;
1072
+ const acceptAttr = attachCfg?.accept?.join(",");
1073
+ const maxSizeMb = attachCfg?.maxSizeMb ?? 10;
1074
+ const maxFiles = attachCfg?.maxFiles ?? 5;
1075
+ const voiceCfg = props.voice;
1076
+ const voiceEnabled = voiceCfg?.enabled === true;
1077
+ const voiceLang = voiceCfg?.lang ?? "en-US";
1078
+ const speechSupported = typeof window !== "undefined" && (Boolean(window.SpeechRecognition) || Boolean(window.webkitSpeechRecognition));
284
1079
  const [open, setOpen] = useState(false);
285
1080
  const [messages, setMessages] = useState([
286
1081
  { id: "g0", role: "assistant", content: resolvedGreeting, ts: Date.now() }
287
1082
  ]);
288
1083
  const [input, setInput] = useState("");
289
1084
  const [sending, setSending] = useState(false);
1085
+ const [files, setFiles] = useState([]);
290
1086
  const scrollRef = useRef(null);
291
1087
  const inputRef = useRef(null);
1088
+ const fileInputRef = useRef(null);
1089
+ const [pendingTools, setPendingTools] = useState([]);
1090
+ const tools = props.tools ?? {};
1091
+ const [voiceListening, setVoiceListening] = useState(false);
1092
+ const recognitionRef = useRef(null);
1093
+ async function continueAfterTool(toolName, result) {
1094
+ const ctxMsg = `[Tool ${toolName} result: ${JSON.stringify(result)}]`;
1095
+ setSending(true);
1096
+ const assistantId = `a${Date.now()}`;
1097
+ setMessages((prev) => [...prev, { id: assistantId, role: "assistant", content: "", ts: Date.now() }]);
1098
+ const appendToken = (tok) => {
1099
+ setMessages(
1100
+ (prev) => prev.map((m) => m.id === assistantId ? { ...m, content: m.content + tok } : m)
1101
+ );
1102
+ };
1103
+ try {
1104
+ const history = messages.filter((m) => m.role !== "system").map((m) => ({ role: m.role, content: m.content }));
1105
+ const reply = isEndpointMode ? await fetchReplyFromEndpoint(ctxMsg, history, [], appendToken) : (await bot.reply(ctxMsg, { history })).reply;
1106
+ const markers = parseToolMarkers(reply);
1107
+ const cleanReply = stripToolMarkers(reply);
1108
+ setMessages(
1109
+ (prev) => prev.map((m) => m.id === assistantId ? { ...m, content: cleanReply } : m)
1110
+ );
1111
+ if (markers.length > 0) {
1112
+ setPendingTools((prev) => [
1113
+ ...prev,
1114
+ ...markers.map((marker) => ({ messageId: assistantId, marker, status: "pending" }))
1115
+ ]);
1116
+ }
1117
+ } catch (err) {
1118
+ const errMsg = err instanceof Error ? err.message : String(err);
1119
+ setMessages(
1120
+ (prev) => prev.map(
1121
+ (m) => m.id === assistantId ? { ...m, content: `Sorry \u2014 something went wrong. (${errMsg})` } : m
1122
+ )
1123
+ );
1124
+ } finally {
1125
+ setSending(false);
1126
+ }
1127
+ }
1128
+ async function handleToolSubmit(toolName, idx, result) {
1129
+ setPendingTools(
1130
+ (prev) => prev.map((p, i) => i === idx ? { ...p, status: "submitted", result } : p)
1131
+ );
1132
+ await continueAfterTool(toolName, result);
1133
+ }
1134
+ function toggleVoice() {
1135
+ if (!speechSupported) return;
1136
+ if (voiceListening) {
1137
+ recognitionRef.current?.stop();
1138
+ return;
1139
+ }
1140
+ const Ctor = window.SpeechRecognition ?? window.webkitSpeechRecognition;
1141
+ if (!Ctor) return;
1142
+ const rec = new Ctor();
1143
+ rec.lang = voiceLang;
1144
+ rec.continuous = false;
1145
+ rec.interimResults = true;
1146
+ rec.onresult = (e) => {
1147
+ let transcript = "";
1148
+ for (let i = e.resultIndex; i < e.results.length; i++) {
1149
+ transcript += e.results[i][0].transcript;
1150
+ }
1151
+ setInput(transcript);
1152
+ };
1153
+ rec.onend = () => setVoiceListening(false);
1154
+ rec.onerror = () => setVoiceListening(false);
1155
+ recognitionRef.current = rec;
1156
+ setVoiceListening(true);
1157
+ rec.start();
1158
+ }
1159
+ function addFiles(picked) {
1160
+ const arr = Array.from(picked).filter((f) => f.size <= maxSizeMb * 1024 * 1024);
1161
+ setFiles((prev) => {
1162
+ const combined = [...prev, ...arr];
1163
+ return combined.slice(0, maxFiles);
1164
+ });
1165
+ }
1166
+ function removeFile(idx) {
1167
+ setFiles((prev) => prev.filter((_, i) => i !== idx));
1168
+ }
292
1169
  useEffect(() => {
293
1170
  ensureStyles();
294
1171
  }, []);
@@ -309,13 +1186,71 @@ function ChatWidget(props) {
309
1186
  }
310
1187
  return void 0;
311
1188
  }, [open]);
312
- async function fetchReplyFromEndpoint(text, history) {
313
- const res = await fetch(props.endpoint, {
314
- method: "POST",
315
- headers: { "Content-Type": "application/json" },
316
- body: JSON.stringify({ message: text, transcript: history })
317
- });
1189
+ async function fetchReplyFromEndpoint(text, history, attachedFiles, onToken) {
1190
+ const enabledTools = Object.keys(tools);
1191
+ let body;
1192
+ const headers = { Accept: "text/event-stream, application/json" };
1193
+ if (attachedFiles.length > 0) {
1194
+ const form = new FormData();
1195
+ form.append("message", text);
1196
+ form.append("transcript", JSON.stringify(history));
1197
+ form.append("enabledTools", JSON.stringify(enabledTools));
1198
+ for (const f of attachedFiles) form.append("attachments", f, f.name);
1199
+ body = form;
1200
+ } else {
1201
+ headers["Content-Type"] = "application/json";
1202
+ body = JSON.stringify({ message: text, transcript: history, enabledTools });
1203
+ }
1204
+ const res = await fetch(props.endpoint, { method: "POST", headers, body });
318
1205
  if (!res.ok) throw new Error(`Endpoint ${res.status}: ${await res.text().catch(() => "")}`);
1206
+ const contentType = res.headers.get("Content-Type") ?? "";
1207
+ if (contentType.includes("text/event-stream") && res.body) {
1208
+ const reader = res.body.getReader();
1209
+ const decoder = new TextDecoder();
1210
+ let buffer = "";
1211
+ let assembled = "";
1212
+ let lastError = null;
1213
+ while (true) {
1214
+ const { done, value } = await reader.read();
1215
+ if (done) break;
1216
+ buffer += decoder.decode(value, { stream: true });
1217
+ const events = buffer.split("\n\n");
1218
+ buffer = events.pop() ?? "";
1219
+ for (const evt of events) {
1220
+ const lines = evt.split("\n");
1221
+ let evtName = "message";
1222
+ let data2 = "";
1223
+ for (const line of lines) {
1224
+ if (line.startsWith("event:")) evtName = line.slice(6).trim();
1225
+ else if (line.startsWith("data:")) data2 = line.slice(5).trim();
1226
+ }
1227
+ if (!data2) continue;
1228
+ if (evtName === "token") {
1229
+ try {
1230
+ const tok = JSON.parse(data2);
1231
+ assembled += tok;
1232
+ onToken(tok);
1233
+ } catch {
1234
+ }
1235
+ } else if (evtName === "done") {
1236
+ try {
1237
+ const obj = JSON.parse(data2);
1238
+ if (obj.reply) return obj.reply;
1239
+ } catch {
1240
+ }
1241
+ } else if (evtName === "error") {
1242
+ try {
1243
+ const obj = JSON.parse(data2);
1244
+ lastError = obj.message ?? "stream error";
1245
+ } catch {
1246
+ lastError = "stream error";
1247
+ }
1248
+ }
1249
+ }
1250
+ }
1251
+ if (lastError) throw new Error(lastError);
1252
+ return assembled;
1253
+ }
319
1254
  const data = await res.json();
320
1255
  if (data.error) throw new Error(data.error);
321
1256
  if (!data.reply) throw new Error("Endpoint returned no reply.");
@@ -323,18 +1258,42 @@ function ChatWidget(props) {
323
1258
  }
324
1259
  async function send() {
325
1260
  const text = input.trim();
326
- if (!text || sending) return;
1261
+ const attached = files;
1262
+ if (!text && attached.length === 0 || sending) return;
327
1263
  setInput("");
328
- const userMsg = { id: `u${Date.now()}`, role: "user", content: text, ts: Date.now() };
1264
+ setFiles([]);
1265
+ const userContent = attached.length > 0 ? `${text}${text ? "\n" : ""}\u{1F4CE} ${attached.map((f) => f.name).join(", ")}` : text;
1266
+ const userMsg = { id: `u${Date.now()}`, role: "user", content: userContent, ts: Date.now() };
329
1267
  setMessages((prev) => [...prev, userMsg]);
330
1268
  setSending(true);
1269
+ const assistantId = `a${Date.now()}`;
1270
+ setMessages((prev) => [...prev, { id: assistantId, role: "assistant", content: "", ts: Date.now() }]);
1271
+ const appendToken = (tok) => {
1272
+ setMessages(
1273
+ (prev) => prev.map((m) => m.id === assistantId ? { ...m, content: m.content + tok } : m)
1274
+ );
1275
+ };
331
1276
  try {
332
1277
  const history = messages.filter((m) => m.role !== "system").map((m) => ({ role: m.role, content: m.content }));
333
- const reply = isEndpointMode ? await fetchReplyFromEndpoint(text, history) : (await bot.reply(text, { history })).reply;
334
- setMessages((prev) => [...prev, { id: `a${Date.now()}`, role: "assistant", content: reply, ts: Date.now() }]);
1278
+ const reply = isEndpointMode ? await fetchReplyFromEndpoint(text, history, attached, appendToken) : (await bot.reply(text, { history })).reply;
1279
+ const markers = parseToolMarkers(reply);
1280
+ const cleanReply = stripToolMarkers(reply);
1281
+ setMessages(
1282
+ (prev) => prev.map((m) => m.id === assistantId ? { ...m, content: cleanReply } : m)
1283
+ );
1284
+ if (markers.length > 0) {
1285
+ setPendingTools((prev) => [
1286
+ ...prev,
1287
+ ...markers.map((marker) => ({ messageId: assistantId, marker, status: "pending" }))
1288
+ ]);
1289
+ }
335
1290
  } catch (err) {
336
1291
  const errMsg = err instanceof Error ? err.message : String(err);
337
- setMessages((prev) => [...prev, { id: `e${Date.now()}`, role: "assistant", content: `Sorry \u2014 something went wrong. (${errMsg})`, ts: Date.now() }]);
1292
+ setMessages(
1293
+ (prev) => prev.map(
1294
+ (m) => m.id === assistantId ? { ...m, content: `Sorry \u2014 something went wrong. (${errMsg})` } : m
1295
+ )
1296
+ );
338
1297
  } finally {
339
1298
  setSending(false);
340
1299
  }
@@ -448,29 +1407,122 @@ function ChatWidget(props) {
448
1407
  background: SURFACE_MUTED
449
1408
  },
450
1409
  children: [
451
- messages.map((m) => /* @__PURE__ */ jsx(
452
- "div",
453
- {
454
- className: "chatbotlite-msg",
455
- style: {
456
- alignSelf: m.role === "user" ? "flex-end" : "flex-start",
457
- maxWidth: "82%",
458
- padding: "9px 13px",
459
- borderRadius: m.role === "user" ? "18px 18px 4px 18px" : "18px 18px 18px 4px",
460
- background: m.role === "user" ? primary : SURFACE,
461
- color: m.role === "user" ? onPrimary : TEXT_BODY,
462
- border: m.role === "user" ? "none" : `1px solid ${BORDER}`,
463
- fontSize: 14,
464
- lineHeight: 1.5,
465
- letterSpacing: "-0.005em",
466
- whiteSpace: "pre-wrap",
467
- wordBreak: "break-word",
468
- boxShadow: m.role === "user" ? "0 1px 2px rgba(15,23,42,0.12)" : "0 1px 2px rgba(15,23,42,0.04)"
469
- },
470
- children: m.content
471
- },
472
- m.id
473
- )),
1410
+ messages.map((m) => /* @__PURE__ */ jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6, alignItems: m.role === "user" ? "flex-end" : "stretch" }, children: [
1411
+ m.content && /* @__PURE__ */ jsxs(
1412
+ "div",
1413
+ {
1414
+ className: "chatbotlite-msg",
1415
+ style: {
1416
+ alignSelf: m.role === "user" ? "flex-end" : "flex-start",
1417
+ maxWidth: "82%",
1418
+ padding: "9px 13px",
1419
+ borderRadius: m.role === "user" ? "18px 18px 4px 18px" : "18px 18px 18px 4px",
1420
+ background: m.role === "user" ? primary : SURFACE,
1421
+ color: m.role === "user" ? onPrimary : TEXT_BODY,
1422
+ border: m.role === "user" ? "none" : `1px solid ${BORDER}`,
1423
+ fontSize: 14,
1424
+ lineHeight: 1.5,
1425
+ letterSpacing: "-0.005em",
1426
+ whiteSpace: "pre-wrap",
1427
+ wordBreak: "break-word",
1428
+ boxShadow: m.role === "user" ? "0 1px 2px rgba(15,23,42,0.12)" : "0 1px 2px rgba(15,23,42,0.04)"
1429
+ },
1430
+ children: [
1431
+ m.content,
1432
+ sending && m.role === "assistant" && m === messages[messages.length - 1] && /* @__PURE__ */ jsx("span", { className: "chatbotlite-cursor", "aria-hidden": "true" })
1433
+ ]
1434
+ }
1435
+ ),
1436
+ pendingTools.map((pt, originalIdx) => ({ pt, originalIdx })).filter(({ pt }) => pt.messageId === m.id).map(({ pt, originalIdx }) => {
1437
+ const toolCommonStyle = { className: "chatbotlite-msg", style: { alignSelf: "stretch" } };
1438
+ const palette = {
1439
+ primary,
1440
+ onPrimary,
1441
+ border: BORDER,
1442
+ surface: SURFACE,
1443
+ surfaceMuted: SURFACE_MUTED,
1444
+ textBody: TEXT_BODY,
1445
+ textMuted: TEXT_MUTED
1446
+ };
1447
+ if (pt.marker.name === "uploadForReview" && tools.uploadForReview) {
1448
+ return /* @__PURE__ */ jsx("div", { ...toolCommonStyle, children: /* @__PURE__ */ jsx(
1449
+ UploadForReview,
1450
+ {
1451
+ ...palette,
1452
+ purpose: String(pt.marker.args.purpose ?? "files"),
1453
+ accept: String(pt.marker.args.accept ?? "*"),
1454
+ maxMb: Number(pt.marker.args.maxMb ?? 10),
1455
+ submitting: pt.status === "submitting",
1456
+ submitted: pt.status === "submitted",
1457
+ onSubmit: async (files2) => {
1458
+ setPendingTools(
1459
+ (prev) => prev.map((p, i) => i === originalIdx ? { ...p, status: "submitting" } : p)
1460
+ );
1461
+ try {
1462
+ const result = await tools.uploadForReview.handler({
1463
+ files: files2,
1464
+ purpose: String(pt.marker.args.purpose ?? "files")
1465
+ });
1466
+ await handleToolSubmit("uploadForReview", originalIdx, result);
1467
+ } catch (err) {
1468
+ setPendingTools(
1469
+ (prev) => prev.map((p, i) => i === originalIdx ? { ...p, status: "pending" } : p)
1470
+ );
1471
+ throw err;
1472
+ }
1473
+ }
1474
+ }
1475
+ ) }, `tool-${originalIdx}`);
1476
+ }
1477
+ if (pt.marker.name === "scheduleCallback" && tools.scheduleCallback) {
1478
+ return /* @__PURE__ */ jsx("div", { ...toolCommonStyle, children: /* @__PURE__ */ jsx(
1479
+ ScheduleCallback,
1480
+ {
1481
+ ...palette,
1482
+ durationMin: Number(pt.marker.args.durationMin ?? 15),
1483
+ timezone: String(pt.marker.args.timezone ?? "UTC"),
1484
+ submitting: pt.status === "submitting",
1485
+ submitted: pt.status === "submitted",
1486
+ ...pt.result?.confirmedAt ? { submittedSlot: String(pt.result.confirmedAt) } : {},
1487
+ getAvailableSlots: tools.scheduleCallback.getAvailableSlots,
1488
+ onConfirm: async (slot) => {
1489
+ setPendingTools(
1490
+ (prev) => prev.map((p, i) => i === originalIdx ? { ...p, status: "submitting" } : p)
1491
+ );
1492
+ const result = await tools.scheduleCallback.onConfirm({ slot });
1493
+ await handleToolSubmit("scheduleCallback", originalIdx, result);
1494
+ }
1495
+ }
1496
+ ) }, `tool-${originalIdx}`);
1497
+ }
1498
+ if (pt.marker.name === "requestPayment" && tools.requestPayment) {
1499
+ return /* @__PURE__ */ jsx("div", { ...toolCommonStyle, children: /* @__PURE__ */ jsx(
1500
+ RequestPayment,
1501
+ {
1502
+ ...palette,
1503
+ amount: Number(pt.marker.args.amount ?? 0),
1504
+ currency: String(pt.marker.args.currency ?? "USD"),
1505
+ ...pt.marker.args.reason ? { reason: String(pt.marker.args.reason) } : {},
1506
+ showInterac: tools.requestPayment.showInterac ?? true,
1507
+ ...tools.requestPayment.stripeLink ? { stripeLink: tools.requestPayment.stripeLink } : {},
1508
+ submitting: pt.status === "submitting",
1509
+ submitted: pt.status === "submitted",
1510
+ ...pt.result?.method ? { submittedMethod: pt.result.method } : {},
1511
+ onPick: async (method) => {
1512
+ setPendingTools(
1513
+ (prev) => prev.map((p, i) => i === originalIdx ? { ...p, status: "submitting" } : p)
1514
+ );
1515
+ const amount = Number(pt.marker.args.amount ?? 0);
1516
+ const currency = String(pt.marker.args.currency ?? "USD");
1517
+ const result = await tools.requestPayment.onPick({ method, amount, currency });
1518
+ await handleToolSubmit("requestPayment", originalIdx, { ...result, method });
1519
+ }
1520
+ }
1521
+ ) }, `tool-${originalIdx}`);
1522
+ }
1523
+ return null;
1524
+ })
1525
+ ] }, m.id)),
474
1526
  sending && /* @__PURE__ */ jsxs(
475
1527
  "div",
476
1528
  {
@@ -495,66 +1547,169 @@ function ChatWidget(props) {
495
1547
  ),
496
1548
  /* @__PURE__ */ jsxs("div", { style: {
497
1549
  display: "flex",
1550
+ flexDirection: "column",
498
1551
  padding: 12,
499
1552
  gap: 8,
500
1553
  background: SURFACE,
501
1554
  borderTop: `1px solid ${BORDER}`
502
1555
  }, children: [
503
- /* @__PURE__ */ jsx(
504
- "input",
1556
+ files.length > 0 && /* @__PURE__ */ jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 6 }, children: files.map((f, i) => /* @__PURE__ */ jsxs(
1557
+ "span",
505
1558
  {
506
- ref: inputRef,
507
- className: "chatbotlite-input",
508
- type: "text",
509
- value: input,
510
- onChange: (e) => setInput(e.target.value),
511
- onKeyDown: (e) => {
512
- if (e.key === "Enter" && !e.shiftKey) {
513
- e.preventDefault();
514
- void send();
515
- }
516
- },
517
- placeholder: "Type a message\u2026",
518
- disabled: sending,
519
1559
  style: {
520
- flex: 1,
521
- padding: "10px 14px",
522
- borderRadius: 12,
523
- border: `1px solid ${BORDER}`,
1560
+ display: "inline-flex",
1561
+ alignItems: "center",
1562
+ gap: 6,
1563
+ padding: "4px 8px 4px 10px",
1564
+ borderRadius: 8,
524
1565
  background: SURFACE_MUTED,
525
- fontSize: 14,
526
- fontFamily: FONT_STACK,
1566
+ border: `1px solid ${BORDER}`,
1567
+ fontSize: 12,
527
1568
  color: TEXT_BODY,
528
- outline: "none",
529
- transition: "box-shadow 120ms ease, border-color 120ms ease"
530
- }
531
- }
532
- ),
533
- /* @__PURE__ */ jsx(
534
- "button",
535
- {
536
- className: "chatbotlite-send",
537
- onClick: () => void send(),
538
- disabled: sending || !input.trim(),
539
- "aria-label": "Send message",
540
- style: {
541
- padding: "0 16px",
542
- height: 40,
543
- minWidth: 64,
544
- borderRadius: 12,
545
- background: primary,
546
- color: onPrimary,
547
- border: "none",
548
- fontSize: 14,
549
- fontWeight: 600,
550
- fontFamily: FONT_STACK,
551
- cursor: sending || !input.trim() ? "default" : "pointer",
552
- opacity: sending || !input.trim() ? 0.4 : 1,
553
- boxShadow: "0 2px 6px -1px rgba(15,23,42,0.18)"
1569
+ maxWidth: 200
554
1570
  },
555
- children: "Send"
556
- }
557
- )
1571
+ children: [
1572
+ /* @__PURE__ */ jsxs("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: [
1573
+ "\u{1F4CE} ",
1574
+ f.name
1575
+ ] }),
1576
+ /* @__PURE__ */ jsx(
1577
+ "button",
1578
+ {
1579
+ onClick: () => removeFile(i),
1580
+ "aria-label": `Remove ${f.name}`,
1581
+ style: {
1582
+ background: "transparent",
1583
+ border: "none",
1584
+ cursor: "pointer",
1585
+ color: TEXT_MUTED,
1586
+ fontSize: 14,
1587
+ lineHeight: 1,
1588
+ padding: 0
1589
+ },
1590
+ children: "\xD7"
1591
+ }
1592
+ )
1593
+ ]
1594
+ },
1595
+ `${f.name}-${i}`
1596
+ )) }),
1597
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 8 }, children: [
1598
+ attachEnabled && /* @__PURE__ */ jsxs(Fragment, { children: [
1599
+ /* @__PURE__ */ jsx(
1600
+ "input",
1601
+ {
1602
+ ref: fileInputRef,
1603
+ type: "file",
1604
+ multiple: true,
1605
+ accept: acceptAttr,
1606
+ style: { display: "none" },
1607
+ onChange: (e) => {
1608
+ if (e.target.files) addFiles(e.target.files);
1609
+ e.target.value = "";
1610
+ }
1611
+ }
1612
+ ),
1613
+ /* @__PURE__ */ jsx(
1614
+ "button",
1615
+ {
1616
+ className: "chatbotlite-attach-btn",
1617
+ onClick: () => fileInputRef.current?.click(),
1618
+ disabled: sending || files.length >= maxFiles,
1619
+ "aria-label": "Attach file",
1620
+ style: {
1621
+ width: 40,
1622
+ height: 40,
1623
+ borderRadius: 10,
1624
+ background: SURFACE_MUTED,
1625
+ border: `1px solid ${BORDER}`,
1626
+ cursor: sending || files.length >= maxFiles ? "default" : "pointer",
1627
+ opacity: sending || files.length >= maxFiles ? 0.4 : 1,
1628
+ fontSize: 16,
1629
+ transition: "background 120ms ease, transform 80ms ease"
1630
+ },
1631
+ children: "\u{1F4CE}"
1632
+ }
1633
+ )
1634
+ ] }),
1635
+ voiceEnabled && speechSupported && /* @__PURE__ */ jsx(
1636
+ "button",
1637
+ {
1638
+ className: "chatbotlite-voice-btn",
1639
+ onClick: toggleVoice,
1640
+ disabled: sending,
1641
+ "aria-label": voiceListening ? "Stop recording" : "Start voice input",
1642
+ style: {
1643
+ width: 40,
1644
+ height: 40,
1645
+ borderRadius: 10,
1646
+ background: voiceListening ? primary : SURFACE_MUTED,
1647
+ color: voiceListening ? onPrimary : TEXT_BODY,
1648
+ border: `1px solid ${voiceListening ? primary : BORDER}`,
1649
+ cursor: sending ? "default" : "pointer",
1650
+ opacity: sending ? 0.4 : 1,
1651
+ fontSize: 16,
1652
+ transition: "background 120ms ease, color 120ms ease, border-color 120ms ease, transform 80ms ease"
1653
+ },
1654
+ children: "\u{1F399}\uFE0F"
1655
+ }
1656
+ ),
1657
+ /* @__PURE__ */ jsx(
1658
+ "input",
1659
+ {
1660
+ ref: inputRef,
1661
+ className: "chatbotlite-input",
1662
+ type: "text",
1663
+ value: input,
1664
+ onChange: (e) => setInput(e.target.value),
1665
+ onKeyDown: (e) => {
1666
+ if (e.key === "Enter" && !e.shiftKey) {
1667
+ e.preventDefault();
1668
+ void send();
1669
+ }
1670
+ },
1671
+ placeholder: "Type a message\u2026",
1672
+ disabled: sending,
1673
+ style: {
1674
+ flex: 1,
1675
+ padding: "10px 14px",
1676
+ borderRadius: 12,
1677
+ border: `1px solid ${BORDER}`,
1678
+ background: SURFACE_MUTED,
1679
+ fontSize: 14,
1680
+ fontFamily: FONT_STACK,
1681
+ color: TEXT_BODY,
1682
+ outline: "none",
1683
+ transition: "box-shadow 120ms ease, border-color 120ms ease"
1684
+ }
1685
+ }
1686
+ ),
1687
+ /* @__PURE__ */ jsx(
1688
+ "button",
1689
+ {
1690
+ className: "chatbotlite-send",
1691
+ onClick: () => void send(),
1692
+ disabled: sending || !input.trim() && files.length === 0,
1693
+ "aria-label": "Send message",
1694
+ style: {
1695
+ padding: "0 16px",
1696
+ height: 40,
1697
+ minWidth: 64,
1698
+ borderRadius: 12,
1699
+ background: primary,
1700
+ color: onPrimary,
1701
+ border: "none",
1702
+ fontSize: 14,
1703
+ fontWeight: 600,
1704
+ fontFamily: FONT_STACK,
1705
+ cursor: sending || !input.trim() && files.length === 0 ? "default" : "pointer",
1706
+ opacity: sending || !input.trim() && files.length === 0 ? 0.4 : 1,
1707
+ boxShadow: "0 2px 6px -1px rgba(15,23,42,0.18)"
1708
+ },
1709
+ children: "Send"
1710
+ }
1711
+ )
1712
+ ] })
558
1713
  ] }),
559
1714
  showBranding && /* @__PURE__ */ jsxs(
560
1715
  "a",