@wallavi/widget 1.5.3 → 1.6.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.
package/dist/index.js CHANGED
@@ -212,7 +212,7 @@ function useChat({
212
212
  });
213
213
  }, []);
214
214
  const fetchAndStream = react.useCallback(async (opts) => {
215
- const { input: userInput, msgId, extraMetadata } = opts;
215
+ const { input: userInput, msgId, extraMetadata, attachments } = opts;
216
216
  const isPrivate = Boolean(workspaceId);
217
217
  const token = isPrivate && typeof window !== "undefined" ? await window.Clerk?.session?.getToken() : null;
218
218
  const url = isPrivate ? `${API_URL}/api/threads/${threadId}/stream` : `${API_URL}/api/chat/stream`;
@@ -227,6 +227,7 @@ function useChat({
227
227
  agentId,
228
228
  ...isPrivate ? { workspaceId, ...playgroundOverrides ? { playgroundOverrides } : {} } : { threadId },
229
229
  source,
230
+ ...attachments?.length ? { attachments } : {},
230
231
  ...userContext?.userName ? { userName: userContext.userName } : {},
231
232
  ...userContext?.userEmail ? { userEmail: userContext.userEmail } : {},
232
233
  userMetadata: {
@@ -247,6 +248,7 @@ function useChat({
247
248
  if (!res.body) throw new Error("No stream body");
248
249
  await consumeStream(res.body, (proto) => applyStreamEvent(proto, msgId));
249
250
  }, [agentId, workspaceId, source, threadId, userContext, playgroundOverrides, applyStreamEvent]);
251
+ const pendingAttachmentsRef = react.useRef([]);
250
252
  const send = react.useCallback(
251
253
  async (text) => {
252
254
  const userInput = (text ?? input).trim();
@@ -261,8 +263,10 @@ function useChat({
261
263
  const assistantMsgId = newId();
262
264
  streamingMsgIdRef.current = assistantMsgId;
263
265
  setMessages((prev) => [...prev, { id: assistantMsgId, role: "assistant", parts: [] }]);
266
+ const attachments = pendingAttachmentsRef.current.length > 0 ? [...pendingAttachmentsRef.current] : void 0;
267
+ pendingAttachmentsRef.current = [];
264
268
  try {
265
- await fetchAndStream({ input: userInput, msgId: assistantMsgId });
269
+ await fetchAndStream({ input: userInput, msgId: assistantMsgId, attachments });
266
270
  } catch {
267
271
  setMessages((prev) => {
268
272
  const idx = prev.findIndex((m) => m.id === assistantMsgId);
@@ -312,8 +316,11 @@ function useChat({
312
316
  setStreaming(false);
313
317
  streamingMsgIdRef.current = null;
314
318
  },
315
- [streaming, fetchAndStream]
319
+ [input, streaming, fetchAndStream]
316
320
  );
321
+ const queueAttachments = react.useCallback((payloads) => {
322
+ pendingAttachmentsRef.current = payloads;
323
+ }, []);
317
324
  const regenerate = react.useCallback(async () => {
318
325
  if (streaming) return;
319
326
  const lastUser = [...messages].reverse().find((m) => m.role === "user");
@@ -327,7 +334,176 @@ function useChat({
327
334
  });
328
335
  await send(lastText);
329
336
  }, [streaming, messages, send]);
330
- return { messages, input, setInput, streaming, threadId, send, regenerate, reset, selectPickerOption };
337
+ return { messages, input, setInput, streaming, threadId, send, queueAttachments, regenerate, reset, selectPickerOption };
338
+ }
339
+ function getPreferredMimeType() {
340
+ if (typeof MediaRecorder === "undefined") return "";
341
+ const candidates = [
342
+ "audio/webm;codecs=opus",
343
+ "audio/webm",
344
+ "audio/ogg;codecs=opus",
345
+ "audio/ogg",
346
+ "audio/mp4"
347
+ ];
348
+ return candidates.find((t) => MediaRecorder.isTypeSupported(t)) ?? "";
349
+ }
350
+ function mimeTypeToExtension(mimeType) {
351
+ if (mimeType.includes("ogg")) return "ogg";
352
+ if (mimeType.includes("mp4")) return "mp4";
353
+ return "webm";
354
+ }
355
+ var DEFAULT_API_URL = process.env.NEXT_PUBLIC_API_URL ?? "https://wallavi-production.up.railway.app";
356
+ function useVoice({ agentId, apiUrl, onTranscript, onError }) {
357
+ const [voiceState, setVoiceState] = react.useState("idle");
358
+ const recorderRef = react.useRef(null);
359
+ const chunksRef = react.useRef([]);
360
+ const streamRef = react.useRef(null);
361
+ const errorTimerRef = react.useRef(null);
362
+ const isSupported = typeof window !== "undefined" && typeof MediaRecorder !== "undefined" && !!navigator?.mediaDevices?.getUserMedia;
363
+ const base = apiUrl ?? DEFAULT_API_URL;
364
+ const transcribeBlob = react.useCallback(
365
+ async (blob, mimeType) => {
366
+ setVoiceState("transcribing");
367
+ try {
368
+ const ext = mimeTypeToExtension(mimeType);
369
+ const form = new FormData();
370
+ form.append("audio", blob, `recording.${ext}`);
371
+ form.append("agentId", agentId);
372
+ const res = await fetch(`${base}/api/chat/transcribe`, { method: "POST", body: form });
373
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
374
+ const data = await res.json();
375
+ if (!data.text?.trim()) throw new Error(data.error ?? "Empty transcript");
376
+ onTranscript(data.text.trim());
377
+ setVoiceState("idle");
378
+ } catch (err) {
379
+ const msg = err instanceof Error ? err.message : "Transcription failed";
380
+ onError?.(msg);
381
+ setVoiceState("error");
382
+ errorTimerRef.current = setTimeout(() => setVoiceState("idle"), 2500);
383
+ }
384
+ },
385
+ [agentId, base, onTranscript, onError]
386
+ );
387
+ const start = react.useCallback(async () => {
388
+ if (!isSupported || voiceState !== "idle") return;
389
+ try {
390
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
391
+ streamRef.current = stream;
392
+ const mimeType = getPreferredMimeType();
393
+ const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : void 0);
394
+ chunksRef.current = [];
395
+ recorder.ondataavailable = (e) => {
396
+ if (e.data.size > 0) chunksRef.current.push(e.data);
397
+ };
398
+ recorder.onstop = async () => {
399
+ stream.getTracks().forEach((t) => t.stop());
400
+ const blob = new Blob(chunksRef.current, { type: mimeType || "audio/webm" });
401
+ if (blob.size === 0) {
402
+ onError?.("Recording was empty \u2014 please try again.");
403
+ setVoiceState("idle");
404
+ return;
405
+ }
406
+ await transcribeBlob(blob, mimeType || "audio/webm");
407
+ };
408
+ recorder.start(250);
409
+ recorderRef.current = recorder;
410
+ setVoiceState("recording");
411
+ } catch (err) {
412
+ const msg = err instanceof Error ? err.message : "Microphone access denied";
413
+ onError?.(msg);
414
+ setVoiceState("idle");
415
+ }
416
+ }, [isSupported, voiceState, transcribeBlob, onError]);
417
+ const stop = react.useCallback(() => {
418
+ if (recorderRef.current?.state === "recording") {
419
+ recorderRef.current.stop();
420
+ recorderRef.current = null;
421
+ }
422
+ }, []);
423
+ react.useEffect(() => {
424
+ return () => {
425
+ if (errorTimerRef.current) clearTimeout(errorTimerRef.current);
426
+ if (recorderRef.current?.state === "recording") recorderRef.current.stop();
427
+ streamRef.current?.getTracks().forEach((t) => t.stop());
428
+ };
429
+ }, []);
430
+ return { voiceState, isSupported, start, stop };
431
+ }
432
+ var DEFAULT_API_URL2 = process.env.NEXT_PUBLIC_API_URL ?? "https://wallavi-production.up.railway.app";
433
+ function makeId() {
434
+ return `att_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
435
+ }
436
+ function useAttachments({
437
+ agentId,
438
+ apiUrl,
439
+ maxFiles = 5
440
+ }) {
441
+ const [attachments, setAttachments] = react.useState([]);
442
+ const base = apiUrl ?? DEFAULT_API_URL2;
443
+ const uploadOne = react.useCallback(
444
+ async (file, id) => {
445
+ const form = new FormData();
446
+ form.append("file", file);
447
+ form.append("agentId", agentId);
448
+ try {
449
+ const res = await fetch(`${base}/api/chat/upload`, { method: "POST", body: form });
450
+ if (!res.ok) {
451
+ const data = await res.json().catch(() => ({}));
452
+ setAttachments(
453
+ (prev) => prev.map(
454
+ (a) => a.id === id ? {
455
+ ...a,
456
+ status: "error",
457
+ errorMessage: data.error ?? `Upload failed (${res.status})`
458
+ } : a
459
+ )
460
+ );
461
+ return;
462
+ }
463
+ const payload = await res.json();
464
+ setAttachments(
465
+ (prev) => prev.map((a) => a.id === id ? { ...a, status: "ready", payload } : a)
466
+ );
467
+ } catch (err) {
468
+ const msg = err instanceof Error ? err.message : "Upload failed";
469
+ setAttachments(
470
+ (prev) => prev.map(
471
+ (a) => a.id === id ? { ...a, status: "error", errorMessage: msg } : a
472
+ )
473
+ );
474
+ }
475
+ },
476
+ [agentId, base]
477
+ );
478
+ const attach = react.useCallback(
479
+ (files) => {
480
+ const list = Array.from(files);
481
+ setAttachments((prev) => {
482
+ const remaining = maxFiles - prev.filter((a) => a.status !== "error").length;
483
+ if (remaining <= 0) return prev;
484
+ const toAdd = list.slice(0, remaining).map((f) => {
485
+ const id = makeId();
486
+ void uploadOne(f, id);
487
+ return {
488
+ id,
489
+ name: f.name,
490
+ mimeType: f.type,
491
+ sizeBytes: f.size,
492
+ status: "uploading"
493
+ };
494
+ });
495
+ return [...prev, ...toAdd];
496
+ });
497
+ },
498
+ [maxFiles, uploadOne]
499
+ );
500
+ const remove = react.useCallback((id) => {
501
+ setAttachments((prev) => prev.filter((a) => a.id !== id));
502
+ }, []);
503
+ const clear = react.useCallback(() => setAttachments([]), []);
504
+ const isUploading = attachments.some((a) => a.status === "uploading");
505
+ const readyPayloads = attachments.filter((a) => a.status === "ready" && a.payload).map((a) => a.payload);
506
+ return { attachments, attach, remove, clear, isUploading, readyPayloads };
331
507
  }
332
508
  function DefaultIcon() {
333
509
  return /* @__PURE__ */ jsxRuntime.jsxs("svg", { viewBox: "0 0 24 24", fill: "none", style: { width: 26, height: 26 }, children: [
@@ -501,83 +677,112 @@ function ReasoningBlock({ text }) {
501
677
  open && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 text-xs text-muted-foreground/80 whitespace-pre-wrap border-l-2 border-muted pl-2 leading-relaxed", children: text })
502
678
  ] });
503
679
  }
504
- function PickerOption({
505
- opt,
506
- isSelected,
507
- isConsumed,
508
- actionDisabled,
509
- pickerId,
510
- paramName,
511
- onSelect
512
- }) {
513
- const [hovered, setHovered] = react.useState(false);
514
- const inactive = actionDisabled || isConsumed;
515
- return /* @__PURE__ */ jsxRuntime.jsx(
516
- "button",
517
- {
518
- disabled: inactive,
519
- onMouseEnter: () => setHovered(true),
520
- onMouseLeave: () => setHovered(false),
521
- onClick: () => {
522
- if (!inactive) onSelect(pickerId, paramName, opt.value, opt.label);
523
- },
524
- style: {
525
- fontSize: 13,
526
- lineHeight: "1.4",
527
- borderRadius: 100,
528
- padding: "6px 14px",
529
- border: "none",
530
- backgroundColor: isSelected ? "var(--primary, #19191c)" : hovered && !inactive ? "rgba(0,0,0,0.14)" : "rgba(0,0,0,0.08)",
531
- color: isSelected ? "var(--primary-foreground, #fff)" : "var(--foreground, #09090b)",
532
- cursor: inactive ? "default" : "pointer",
533
- opacity: isConsumed ? 0.3 : 1,
534
- transition: "background-color 0.12s ease, opacity 0.12s ease",
535
- fontWeight: isSelected ? 600 : 500,
536
- boxShadow: "none"
537
- },
538
- children: opt.label
539
- }
540
- );
541
- }
542
680
  function PickerSelector({
543
681
  part,
544
682
  disabled,
545
683
  onSelect
546
684
  }) {
547
- return /* @__PURE__ */ jsxRuntime.jsxs(
548
- "div",
549
- {
550
- style: {
551
- display: "flex",
552
- flexDirection: "column",
553
- gap: 10,
554
- padding: "12px 14px",
555
- borderRadius: "0 14px 14px 14px",
556
- backgroundColor: "var(--muted, #f4f4f5)",
557
- border: "1px solid rgba(0,0,0,0.06)"
558
- },
559
- children: [
560
- /* @__PURE__ */ jsxRuntime.jsx("p", { style: { margin: 0, fontSize: 11.5, color: "var(--muted-foreground, #71717a)", fontWeight: 600, letterSpacing: "0.04em", textTransform: "uppercase" }, children: part.label }),
561
- /* @__PURE__ */ jsxRuntime.jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 7 }, children: part.options.map((opt) => {
562
- const isSelected = part.selectedValue === opt.value;
563
- const isConsumed = !!part.selectedValue && !isSelected;
564
- return /* @__PURE__ */ jsxRuntime.jsx(
565
- PickerOption,
566
- {
567
- opt,
568
- isSelected,
569
- isConsumed,
570
- actionDisabled: disabled,
571
- pickerId: part.pickerId,
572
- paramName: part.paramName,
573
- onSelect
574
- },
575
- opt.value
576
- );
577
- }) })
578
- ]
579
- }
580
- );
685
+ const count = part.options.length;
686
+ const mode = count <= 6 ? "pills" : count <= 20 ? "grid" : "list";
687
+ const [query, setQuery] = react.useState("");
688
+ const filtered = react.useMemo(() => {
689
+ if (!query.trim()) return part.options;
690
+ const q = query.toLowerCase();
691
+ return part.options.filter((o) => o.label.toLowerCase().includes(q));
692
+ }, [part.options, query]);
693
+ const isConsumed = !!part.selectedValue;
694
+ const handleClick = (opt) => {
695
+ if (!disabled && !isConsumed) onSelect(part.pickerId, part.paramName, opt.value, opt.label);
696
+ };
697
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: cn(
698
+ "flex flex-col gap-2.5 rounded-xl rounded-tl-none border bg-muted/60 p-3",
699
+ "border-border/50"
700
+ ), children: [
701
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-[10.5px] font-semibold uppercase tracking-widest text-muted-foreground/70 leading-none", children: part.label }),
702
+ mode === "list" && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative", children: [
703
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Search, { className: "absolute left-2.5 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground/50 pointer-events-none" }),
704
+ /* @__PURE__ */ jsxRuntime.jsx(
705
+ "input",
706
+ {
707
+ type: "text",
708
+ value: query,
709
+ onChange: (e) => setQuery(e.target.value),
710
+ placeholder: "Search\u2026",
711
+ disabled: disabled || isConsumed,
712
+ className: "w-full rounded-lg border bg-background pl-7 pr-3 py-1.5 text-xs outline-none focus:ring-1 focus:ring-ring/40 placeholder:text-muted-foreground/40 disabled:opacity-50"
713
+ }
714
+ )
715
+ ] }),
716
+ mode === "pills" && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-wrap gap-1.5", children: part.options.map((opt) => {
717
+ const sel = part.selectedValue === opt.value;
718
+ const faded = isConsumed && !sel;
719
+ return /* @__PURE__ */ jsxRuntime.jsxs(
720
+ "button",
721
+ {
722
+ disabled: disabled || isConsumed,
723
+ onClick: () => handleClick(opt),
724
+ className: cn(
725
+ "inline-flex items-center gap-1 rounded-full px-3.5 py-1.5 text-[12.5px] font-medium transition-all duration-150 select-none",
726
+ sel ? "bg-foreground text-background shadow-sm" : faded ? "bg-background/50 text-muted-foreground opacity-35 cursor-default" : "bg-background border border-border/70 text-foreground hover:border-foreground/30 hover:bg-background cursor-pointer",
727
+ disabled && !isConsumed && "opacity-60 pointer-events-none"
728
+ ),
729
+ children: [
730
+ sel && /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Check, { className: "h-3 w-3 shrink-0" }),
731
+ opt.label
732
+ ]
733
+ },
734
+ opt.value
735
+ );
736
+ }) }),
737
+ mode === "grid" && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "grid grid-cols-2 gap-1.5 max-h-[168px] overflow-y-auto pr-0.5 scrollbar-thin", children: part.options.map((opt) => {
738
+ const sel = part.selectedValue === opt.value;
739
+ const faded = isConsumed && !sel;
740
+ return /* @__PURE__ */ jsxRuntime.jsxs(
741
+ "button",
742
+ {
743
+ disabled: disabled || isConsumed,
744
+ onClick: () => handleClick(opt),
745
+ className: cn(
746
+ "flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[12px] font-medium text-left transition-all duration-150 select-none truncate",
747
+ sel ? "bg-foreground text-background" : faded ? "bg-background/40 text-muted-foreground opacity-35 cursor-default" : "bg-background border border-border/60 text-foreground hover:border-foreground/25 cursor-pointer",
748
+ disabled && !isConsumed && "opacity-60 pointer-events-none"
749
+ ),
750
+ children: [
751
+ sel ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Check, { className: "h-3 w-3 shrink-0" }) : /* @__PURE__ */ jsxRuntime.jsx("span", { className: "h-3 w-3 shrink-0" }),
752
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate", children: opt.label })
753
+ ]
754
+ },
755
+ opt.value
756
+ );
757
+ }) }),
758
+ mode === "list" && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-0.5 max-h-[180px] overflow-y-auto pr-0.5", children: [
759
+ filtered.length === 0 && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "py-3 text-center text-xs text-muted-foreground/50", children: "No results" }),
760
+ filtered.map((opt) => {
761
+ const sel = part.selectedValue === opt.value;
762
+ const faded = isConsumed && !sel;
763
+ return /* @__PURE__ */ jsxRuntime.jsxs(
764
+ "button",
765
+ {
766
+ disabled: disabled || isConsumed,
767
+ onClick: () => handleClick(opt),
768
+ className: cn(
769
+ "flex items-center gap-2 rounded-lg px-2.5 py-2 text-[12.5px] text-left transition-all duration-100 select-none",
770
+ sel ? "bg-foreground text-background font-medium" : faded ? "opacity-30 cursor-default" : "hover:bg-background text-foreground cursor-pointer",
771
+ disabled && !isConsumed && "opacity-60 pointer-events-none"
772
+ ),
773
+ children: [
774
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: cn(
775
+ "flex h-4 w-4 shrink-0 items-center justify-center rounded-full border text-[10px]",
776
+ sel ? "border-background bg-background/20" : "border-border/50"
777
+ ), children: sel && /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Check, { className: "h-2.5 w-2.5" }) }),
778
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate", children: opt.label })
779
+ ]
780
+ },
781
+ opt.value
782
+ );
783
+ })
784
+ ] })
785
+ ] });
581
786
  }
582
787
  function MessageBubble({
583
788
  message,
@@ -706,6 +911,44 @@ function ChatMessages({
706
911
  ] });
707
912
  }
708
913
  var cn2 = (...inputs) => tailwindMerge.twMerge(clsx.clsx(inputs));
914
+ function formatBytes(bytes) {
915
+ if (bytes < 1024) return `${bytes} B`;
916
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
917
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
918
+ }
919
+ function FileIcon({ mimeType }) {
920
+ if (mimeType.startsWith("image/")) return /* @__PURE__ */ jsxRuntime.jsx(lucideReact.ImageIcon, { className: "h-3 w-3 shrink-0" });
921
+ return /* @__PURE__ */ jsxRuntime.jsx(lucideReact.FileText, { className: "h-3 w-3 shrink-0" });
922
+ }
923
+ function AttachmentChips({ attachments, onRemove }) {
924
+ if (attachments.length === 0) return null;
925
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-wrap gap-1.5 px-1 pb-1.5", children: attachments.map((a) => /* @__PURE__ */ jsxRuntime.jsxs(
926
+ "div",
927
+ {
928
+ className: cn2(
929
+ "flex items-center gap-1.5 rounded-lg border px-2 py-1 text-[11px] max-w-[180px]",
930
+ a.status === "error" ? "border-red-200 bg-red-50 text-red-600 dark:border-red-800 dark:bg-red-950 dark:text-red-400" : "border-border bg-muted/60 text-muted-foreground"
931
+ ),
932
+ children: [
933
+ a.status === "uploading" ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Loader2, { className: "h-3 w-3 shrink-0 animate-spin" }) : a.status === "error" ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.AlertCircle, { className: "h-3 w-3 shrink-0" }) : /* @__PURE__ */ jsxRuntime.jsx(FileIcon, { mimeType: a.mimeType }),
934
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate leading-tight", children: a.status === "error" ? a.errorMessage ?? "Upload failed" : a.name }),
935
+ a.status === "ready" && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "shrink-0 text-muted-foreground/60", children: formatBytes(a.sizeBytes) }),
936
+ /* @__PURE__ */ jsxRuntime.jsx(
937
+ "button",
938
+ {
939
+ type: "button",
940
+ onClick: () => onRemove(a.id),
941
+ className: "shrink-0 rounded-full p-0.5 hover:bg-foreground/10 transition-colors",
942
+ title: "Remove attachment",
943
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, { className: "h-2.5 w-2.5" })
944
+ }
945
+ )
946
+ ]
947
+ },
948
+ a.id
949
+ )) });
950
+ }
951
+ var cn3 = (...inputs) => tailwindMerge.twMerge(clsx.clsx(inputs));
709
952
  function ChatInput({
710
953
  input,
711
954
  setInput,
@@ -714,8 +957,18 @@ function ChatInput({
714
957
  placeholder,
715
958
  accentColor,
716
959
  canRegenerate = false,
717
- onRegenerate
960
+ onRegenerate,
961
+ voiceState,
962
+ onVoiceStart,
963
+ onVoiceStop,
964
+ attachments,
965
+ onAttach,
966
+ onRemoveAttachment,
967
+ isUploading = false
718
968
  }) {
969
+ const hasVoice = onVoiceStart !== void 0;
970
+ const hasAttachments = onAttach !== void 0;
971
+ const fileInputRef = react.useRef(null);
719
972
  const textareaRef = react.useRef(null);
720
973
  react.useEffect(() => {
721
974
  if (!input && textareaRef.current) {
@@ -747,39 +1000,90 @@ function ChatInput({
747
1000
  ]
748
1001
  }
749
1002
  ),
750
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-end gap-2 rounded-2xl border bg-background px-3 py-2 shadow-sm focus-within:ring-1 focus-within:ring-ring/40 transition-shadow", children: [
751
- /* @__PURE__ */ jsxRuntime.jsx(
752
- "textarea",
1003
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col rounded-2xl border bg-background shadow-sm focus-within:ring-1 focus-within:ring-ring/40 transition-shadow", children: [
1004
+ hasAttachments && attachments && attachments.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-2 pt-2", children: /* @__PURE__ */ jsxRuntime.jsx(AttachmentChips, { attachments, onRemove: (id) => onRemoveAttachment?.(id) }) }),
1005
+ hasAttachments && /* @__PURE__ */ jsxRuntime.jsx(
1006
+ "input",
753
1007
  {
754
- id: "wallavi-chat-input",
755
- ref: textareaRef,
756
- rows: 1,
757
- className: "flex-1 resize-none bg-transparent text-sm outline-none placeholder:text-muted-foreground/50 leading-relaxed max-h-32 overflow-y-auto py-0.5",
758
- placeholder: placeholder ?? "Send a message\u2026",
759
- value: input,
1008
+ ref: fileInputRef,
1009
+ type: "file",
1010
+ multiple: true,
1011
+ accept: ".csv,.txt,.tsv,.pdf,text/plain,text/csv,application/pdf,image/jpeg,image/png,image/webp,image/gif",
1012
+ className: "hidden",
760
1013
  onChange: (e) => {
761
- setInput(e.target.value);
762
- e.target.style.height = "auto";
763
- e.target.style.height = `${Math.min(e.target.scrollHeight, 128)}px`;
764
- },
765
- onKeyDown: handleKeyDown,
766
- disabled: streaming,
767
- autoFocus: true
1014
+ if (e.target.files?.length) {
1015
+ onAttach(e.target.files);
1016
+ e.target.value = "";
1017
+ }
1018
+ }
768
1019
  }
769
1020
  ),
770
- /* @__PURE__ */ jsxRuntime.jsx(
771
- "button",
772
- {
773
- onClick: onSend,
774
- disabled: streaming || !hasText,
775
- className: cn2(
776
- "h-7 w-7 shrink-0 rounded-xl flex items-center justify-center transition-all duration-200",
777
- hasText || streaming ? "opacity-100 shadow-sm" : "opacity-30"
778
- ),
779
- style: hasText || streaming ? { backgroundColor: accentColor, color: getContrastColor(accentColor) } : { backgroundColor: "transparent", color: "currentColor" },
780
- children: streaming ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.ArrowUp, { className: "h-3.5 w-3.5" })
781
- }
782
- )
1021
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-end gap-2 px-3 py-2", children: [
1022
+ /* @__PURE__ */ jsxRuntime.jsx(
1023
+ "textarea",
1024
+ {
1025
+ id: "wallavi-chat-input",
1026
+ ref: textareaRef,
1027
+ rows: 1,
1028
+ className: "flex-1 resize-none bg-transparent text-sm outline-none placeholder:text-muted-foreground/50 leading-relaxed max-h-32 overflow-y-auto py-0.5",
1029
+ placeholder: placeholder ?? "Send a message\u2026",
1030
+ value: input,
1031
+ onChange: (e) => {
1032
+ setInput(e.target.value);
1033
+ e.target.style.height = "auto";
1034
+ e.target.style.height = `${Math.min(e.target.scrollHeight, 128)}px`;
1035
+ },
1036
+ onKeyDown: handleKeyDown,
1037
+ disabled: streaming || voiceState === "recording" || voiceState === "transcribing",
1038
+ autoFocus: true
1039
+ }
1040
+ ),
1041
+ hasVoice && /* @__PURE__ */ jsxRuntime.jsx(
1042
+ "button",
1043
+ {
1044
+ type: "button",
1045
+ onClick: voiceState === "recording" ? onVoiceStop : onVoiceStart,
1046
+ disabled: streaming || voiceState === "transcribing",
1047
+ title: voiceState === "recording" ? "Stop recording" : "Record voice message",
1048
+ className: cn3(
1049
+ "h-7 w-7 shrink-0 rounded-xl flex items-center justify-center transition-all duration-200",
1050
+ voiceState === "recording" && "animate-pulse",
1051
+ voiceState === "error" ? "text-red-500 opacity-80" : "text-muted-foreground hover:text-foreground",
1052
+ (streaming || voiceState === "transcribing") && "opacity-40 pointer-events-none"
1053
+ ),
1054
+ style: voiceState === "recording" ? { color: accentColor } : void 0,
1055
+ children: voiceState === "transcribing" ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : voiceState === "recording" ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Square, { className: "h-3.5 w-3.5 fill-current" }) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Mic, { className: "h-3.5 w-3.5" })
1056
+ }
1057
+ ),
1058
+ hasAttachments && /* @__PURE__ */ jsxRuntime.jsx(
1059
+ "button",
1060
+ {
1061
+ type: "button",
1062
+ onClick: () => fileInputRef.current?.click(),
1063
+ disabled: streaming || isUploading,
1064
+ title: "Attach file (CSV, image\u2026)",
1065
+ className: cn3(
1066
+ "h-7 w-7 shrink-0 rounded-xl flex items-center justify-center transition-all duration-200",
1067
+ "text-muted-foreground hover:text-foreground",
1068
+ (streaming || isUploading) && "opacity-40 pointer-events-none"
1069
+ ),
1070
+ children: isUploading ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Paperclip, { className: "h-3.5 w-3.5" })
1071
+ }
1072
+ ),
1073
+ /* @__PURE__ */ jsxRuntime.jsx(
1074
+ "button",
1075
+ {
1076
+ onClick: onSend,
1077
+ disabled: streaming || !hasText || voiceState === "recording" || voiceState === "transcribing",
1078
+ className: cn3(
1079
+ "h-7 w-7 shrink-0 rounded-xl flex items-center justify-center transition-all duration-200",
1080
+ hasText || streaming ? "opacity-100 shadow-sm" : "opacity-30"
1081
+ ),
1082
+ style: hasText || streaming ? { backgroundColor: accentColor, color: getContrastColor(accentColor) } : { backgroundColor: "transparent", color: "currentColor" },
1083
+ children: streaming ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.ArrowUp, { className: "h-3.5 w-3.5" })
1084
+ }
1085
+ )
1086
+ ] })
783
1087
  ] })
784
1088
  ] });
785
1089
  }
@@ -805,6 +1109,9 @@ function ChatWidget({
805
1109
  source = "playground",
806
1110
  userContext,
807
1111
  playgroundOverrides,
1112
+ enableVoice = false,
1113
+ voiceAutoSend = false,
1114
+ enableAttachments = false,
808
1115
  className,
809
1116
  onClose,
810
1117
  onReset,
@@ -812,6 +1119,48 @@ function ChatWidget({
812
1119
  expanded
813
1120
  }) {
814
1121
  const chat = useChat({ agentId, workspaceId, source, userContext, persist, onNavigate, playgroundOverrides });
1122
+ const voice = useVoice({
1123
+ agentId,
1124
+ onTranscript: (text) => {
1125
+ if (voiceAutoSend) {
1126
+ void chat.send(text);
1127
+ } else {
1128
+ chat.setInput(text);
1129
+ }
1130
+ }
1131
+ });
1132
+ const attachmentHook = useAttachments({ agentId });
1133
+ const [isDragOver, setIsDragOver] = react.useState(false);
1134
+ const handleDragOver = react.useCallback((e) => {
1135
+ if (!enableAttachments) return;
1136
+ e.preventDefault();
1137
+ e.stopPropagation();
1138
+ if (e.dataTransfer.types.includes("Files")) setIsDragOver(true);
1139
+ }, [enableAttachments]);
1140
+ const handleDragLeave = react.useCallback((e) => {
1141
+ if (!enableAttachments) return;
1142
+ e.preventDefault();
1143
+ e.stopPropagation();
1144
+ if (!e.currentTarget.contains(e.relatedTarget)) {
1145
+ setIsDragOver(false);
1146
+ }
1147
+ }, [enableAttachments]);
1148
+ const handleDrop = react.useCallback((e) => {
1149
+ if (!enableAttachments) return;
1150
+ e.preventDefault();
1151
+ e.stopPropagation();
1152
+ setIsDragOver(false);
1153
+ const files = e.dataTransfer.files;
1154
+ if (files.length > 0) attachmentHook.attach(files);
1155
+ }, [enableAttachments, attachmentHook]);
1156
+ const handleSend = () => {
1157
+ const payloads = attachmentHook.readyPayloads;
1158
+ if (payloads.length > 0) {
1159
+ chat.queueAttachments(payloads);
1160
+ attachmentHook.clear();
1161
+ }
1162
+ void chat.send();
1163
+ };
815
1164
  const canRegenerate = regenerateMessage && chat.messages.length > 0 && chat.messages.at(-1)?.role === "assistant" && !chat.streaming;
816
1165
  const title = displayName || agentName;
817
1166
  const headerBg = userMessageColor;
@@ -824,11 +1173,21 @@ function ChatWidget({
824
1173
  "div",
825
1174
  {
826
1175
  className: cn(
827
- "flex flex-col overflow-hidden rounded-2xl border shadow-xl bg-background h-full",
1176
+ "flex flex-col overflow-hidden rounded-2xl border shadow-xl bg-background h-full relative",
1177
+ isDragOver && "ring-2 ring-inset ring-primary/60",
828
1178
  className
829
1179
  ),
830
1180
  style: { colorScheme: theme },
1181
+ onDragOver: handleDragOver,
1182
+ onDragEnter: handleDragOver,
1183
+ onDragLeave: handleDragLeave,
1184
+ onDrop: handleDrop,
831
1185
  children: [
1186
+ isDragOver && enableAttachments && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute inset-0 z-50 flex flex-col items-center justify-center gap-2 rounded-2xl bg-background/90 backdrop-blur-sm pointer-events-none", children: [
1187
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.UploadCloud, { className: "h-8 w-8 text-primary/70" }),
1188
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-sm font-medium text-foreground/70", children: "Drop files to attach" }),
1189
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs text-muted-foreground", children: "CSV, TXT, PDF, JPG, PNG, WebP" })
1190
+ ] }),
832
1191
  /* @__PURE__ */ jsxRuntime.jsx(
833
1192
  ChatHeader,
834
1193
  {
@@ -862,12 +1221,23 @@ function ChatWidget({
862
1221
  {
863
1222
  input: chat.input,
864
1223
  setInput: chat.setInput,
865
- onSend: () => chat.send(),
1224
+ onSend: handleSend,
866
1225
  streaming: chat.streaming,
867
1226
  placeholder: messagePlaceholder,
868
1227
  accentColor: userMessageColor,
869
1228
  canRegenerate: !!canRegenerate,
870
- onRegenerate: () => void chat.regenerate()
1229
+ onRegenerate: () => void chat.regenerate(),
1230
+ ...enableVoice && voice.isSupported ? {
1231
+ voiceState: voice.voiceState,
1232
+ onVoiceStart: () => void voice.start(),
1233
+ onVoiceStop: voice.stop
1234
+ } : {},
1235
+ ...enableAttachments ? {
1236
+ attachments: attachmentHook.attachments,
1237
+ onAttach: attachmentHook.attach,
1238
+ onRemoveAttachment: attachmentHook.remove,
1239
+ isUploading: attachmentHook.isUploading
1240
+ } : {}
871
1241
  }
872
1242
  ),
873
1243
  watermark && /* @__PURE__ */ jsxRuntime.jsxs("footer", { className: "shrink-0 flex items-center justify-center gap-1.5 bg-muted/50 py-1.5 border-t", children: [
@@ -1137,4 +1507,6 @@ exports.BubbleWidget = BubbleWidget;
1137
1507
  exports.ChatWidget = ChatWidget;
1138
1508
  exports.formatToolName = formatToolName;
1139
1509
  exports.getContrastColor = getContrastColor;
1510
+ exports.useAttachments = useAttachments;
1140
1511
  exports.useChat = useChat;
1512
+ exports.useVoice = useVoice;