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