@smart-cloud/ai-kit-ui 1.0.2 → 1.1.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.
@@ -0,0 +1,1398 @@
1
+ import {
2
+ ActionIcon,
3
+ Anchor,
4
+ Button,
5
+ Group,
6
+ Input,
7
+ List,
8
+ Modal,
9
+ Stack,
10
+ Text,
11
+ Textarea,
12
+ } from "@mantine/core";
13
+ import {
14
+ IconMaximize,
15
+ IconMessage,
16
+ IconMinimize,
17
+ IconPaperclip,
18
+ IconPencil,
19
+ IconPlayerStop,
20
+ IconSend,
21
+ IconTrash,
22
+ } from "@tabler/icons-react";
23
+
24
+ import {
25
+ getStoreSelect,
26
+ sendChatMessage,
27
+ sendFeedbackMessage,
28
+ type AiChatbotLabels,
29
+ type AiChatbotProps,
30
+ type AiKitStatusEvent,
31
+ type HistoryStorageMode,
32
+ } from "@smart-cloud/ai-kit-core";
33
+ import { useSelect } from "@wordpress/data";
34
+ import { I18n } from "aws-amplify/utils";
35
+ import React, {
36
+ FC,
37
+ useCallback,
38
+ useEffect,
39
+ useMemo,
40
+ useRef,
41
+ useState,
42
+ } from "react";
43
+ import ReactMarkdown from "react-markdown";
44
+ import remarkGfm from "remark-gfm";
45
+
46
+ import { translations } from "../i18n";
47
+ import { useAiRun } from "../useAiRun";
48
+ import { AiKitShellInjectedProps, withAiKitShell } from "../withAiKitShell";
49
+
50
+ I18n.putVocabularies(translations);
51
+
52
+ const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
53
+
54
+ // New: history storage support
55
+ const DEFAULT_HISTORY_STORAGE: HistoryStorageMode = "localstorage";
56
+ const HISTORY_STORAGE_KEY = `ai-kit-chatbot-history-v1:${
57
+ typeof window !== "undefined" ? window.location.hostname : "unknown"
58
+ }`;
59
+
60
+ const DEFAULT_LABELS: Required<AiChatbotLabels> = {
61
+ modalTitle: "AI Assistant",
62
+
63
+ userLabel: "User",
64
+ assistantLabel: "Assistant",
65
+
66
+ askMeLabel: "Ask me",
67
+
68
+ sendLabel: "Send",
69
+ cancelLabel: "Cancel",
70
+
71
+ resetLabel: "Reset",
72
+ confirmLabel: "Confirm",
73
+ clickAgainToConfirmLabel: "Click again to confirm",
74
+
75
+ notSentLabel: "Not sent",
76
+ editLabel: "Edit",
77
+
78
+ readyLabel: "Ready.",
79
+ readyEmptyLabel: "I'm ready to assist you.",
80
+
81
+ addLabel: "Add",
82
+ addImageLabel: "Add image",
83
+ removeImageLabel: "Remove image",
84
+
85
+ closeChatLabel: "Close chat",
86
+ maximizeLabel: "Maximize",
87
+ restoreSizeLabel: "Restore size",
88
+
89
+ referencesLabel: "References",
90
+ referenceLabel: "Reference",
91
+
92
+ acceptResponseLabel: "Accept response",
93
+ rejectResponseLabel: "Reject response",
94
+
95
+ placeholder: "Ask anything…",
96
+
97
+ emptyResponseLabel: "Empty response",
98
+ unexpectedErrorLabel: "Unexpected error",
99
+ };
100
+
101
+ type CitationLike = {
102
+ url?: string;
103
+ sourceUrl?: string;
104
+ title?: string;
105
+ snippet?: string;
106
+ };
107
+
108
+ type ChatResponse = {
109
+ result: string;
110
+ sessionId?: string;
111
+ citations?: CitationLike[];
112
+ metadata?: {
113
+ citationCount?: number;
114
+ modelId?: string;
115
+ requestId?: string;
116
+ messageId?: string;
117
+ };
118
+ };
119
+
120
+ type ChatMessage = {
121
+ id: string;
122
+ role: "user" | "assistant";
123
+ content: string;
124
+ citations?: CitationLike[];
125
+ createdAt: number;
126
+ feedback?: "accepted" | "rejected";
127
+ clientStatus?: "pending" | "canceled";
128
+ };
129
+
130
+ type ActiveOp = "chat" | "feedback" | null;
131
+
132
+ function createMessageId(prefix: string) {
133
+ return `${prefix}-${Math.random().toString(36).slice(2)}-${Date.now().toString(
134
+ 36,
135
+ )}`;
136
+ }
137
+
138
+ const DEFAULT_MAX_IMAGES = 4;
139
+ const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
140
+
141
+ const isAbortLike = (e: Error & { code?: string }) => {
142
+ const name = (e?.name || "").toString();
143
+ const code = (e?.code || "").toString();
144
+ const msg = (e?.message || "").toString();
145
+ return (
146
+ name === "AbortError" ||
147
+ code === "ABORT_ERR" ||
148
+ /abort|aborted|cancel/i.test(msg)
149
+ );
150
+ };
151
+
152
+ const formatStatusEvent = (event?: AiKitStatusEvent | null): string | null => {
153
+ if (!event) return null;
154
+
155
+ const step = event.step;
156
+ const msg = I18n.get((event.message ?? "").trim());
157
+ const p = typeof event.progress === "number" ? event.progress : null;
158
+ const pct = p == null ? null : Math.round(p * 100);
159
+
160
+ switch (step) {
161
+ case "decide":
162
+ return msg || I18n.get("Checking capabilities...");
163
+ case "on-device:init":
164
+ return msg || I18n.get("Initializing on-device AI...");
165
+ case "on-device:download":
166
+ return pct == null
167
+ ? msg || I18n.get("Downloading model...")
168
+ : msg || `${I18n.get("Downloading model...")} ${pct}%`;
169
+ case "on-device:ready":
170
+ return msg || I18n.get("On-device model ready...");
171
+ case "on-device:run":
172
+ return msg || I18n.get("Running on-device...");
173
+ case "backend:request":
174
+ return msg || I18n.get("Sending request to server...");
175
+ case "backend:waiting":
176
+ return msg || I18n.get("Waiting for response...");
177
+ case "backend:response":
178
+ return msg || I18n.get("Receiving response...");
179
+ case "done":
180
+ return msg || I18n.get("Done.");
181
+ case "error":
182
+ return msg || I18n.get("An error occurred.");
183
+ default:
184
+ return msg || null;
185
+ }
186
+ };
187
+
188
+ // New: small helpers for storage
189
+ function getHistoryStorage(mode: HistoryStorageMode): Storage | null {
190
+ if (typeof window === "undefined") return null;
191
+ try {
192
+ if (mode === "localstorage") return window.localStorage;
193
+ if (mode === "sessionstorage") return window.sessionStorage;
194
+ return null;
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+
200
+ type PersistedChat = {
201
+ version: 1;
202
+ lastUserSentAt: number | null;
203
+ session?: { id: string; storedAt: number } | null;
204
+ messages: ChatMessage[];
205
+ };
206
+
207
+ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
208
+ const {
209
+ rootElement,
210
+ store,
211
+
212
+ // AiWorkerProps (formatting/behavior)
213
+ previewMode,
214
+ title,
215
+ openButtonTitle,
216
+ openButtonIcon,
217
+ showOpenButtonTitle = true,
218
+ showOpenButtonIcon = true,
219
+ className,
220
+ colorMode,
221
+ language,
222
+ onClose,
223
+
224
+ // AiChatbotProps
225
+ placeholder,
226
+ maxImages,
227
+ maxImageBytes,
228
+
229
+ // New
230
+ historyStorage = DEFAULT_HISTORY_STORAGE,
231
+ labels: labelsOverride,
232
+ openButtonIconLayout = "top",
233
+ openButtonPosition = "bottom-right",
234
+ } = props;
235
+
236
+ const labels = useMemo(
237
+ () => ({ ...DEFAULT_LABELS, ...(labelsOverride || {}) }),
238
+ [labelsOverride],
239
+ );
240
+
241
+ // NOTE: showOpenButton is intentionally ignored for AiChatbot (always true).
242
+ // NOTE: variation is intentionally ignored for AiChatbot (always "modal").
243
+
244
+ const ai = useAiRun();
245
+
246
+ const [question, setQuestion] = useState("");
247
+ const [images, setImages] = useState<File[]>([]);
248
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
249
+ const [statusLineError, setStatusLineError] = useState<string | null>(null);
250
+ const [resetDialogOpen, setResetDialogOpen] = useState(false);
251
+ const [isMaximized, setIsMaximized] = useState(false);
252
+ const [maxEnter, setMaxEnter] = useState(false);
253
+ const [opened, setOpened] = useState(false);
254
+ const [stickToBottom, setStickToBottom] = useState(true);
255
+
256
+ const [activeOp, setActiveOp] = useState<ActiveOp>(null);
257
+ const activeOpRef = useRef<ActiveOp>(activeOp);
258
+ useEffect(() => {
259
+ activeOpRef.current = activeOp;
260
+ }, [activeOp]);
261
+
262
+ const maxEnterRafRef = useRef<number | null>(null);
263
+ const cancelRequestedRef = useRef(false);
264
+
265
+ const [hoveredMessageId, setHoveredMessageId] = useState<string | null>(null);
266
+
267
+ const fileInputRef = useRef<HTMLInputElement>(null);
268
+ const questionInputRef = useRef<HTMLTextAreaElement>(null);
269
+ const sessionRef = useRef<{ id: string; storedAt: number } | null>(null);
270
+ const chatScrollRef = useRef<HTMLDivElement>(null);
271
+ const chatContainerRef = useRef<HTMLDivElement>(null);
272
+
273
+ // New: persist timestamp of last actually-sent user message
274
+ const [lastUserSentAt, setLastUserSentAt] = useState<number | null>(null);
275
+
276
+ // Keep latest values in refs for stable callbacks
277
+ const questionRef = useRef(question);
278
+ const imagesRef = useRef(images);
279
+ const messagesRef = useRef(messages);
280
+ const lastUserSentAtRef = useRef(lastUserSentAt);
281
+
282
+ useEffect(() => {
283
+ questionRef.current = question;
284
+ }, [question]);
285
+ useEffect(() => {
286
+ imagesRef.current = images;
287
+ }, [images]);
288
+ useEffect(() => {
289
+ messagesRef.current = messages;
290
+ }, [messages]);
291
+ useEffect(() => {
292
+ lastUserSentAtRef.current = lastUserSentAt;
293
+ }, [lastUserSentAt]);
294
+
295
+ useEffect(() => {
296
+ if (language) {
297
+ console.log(`AiChatbot: setting language to ${language}`);
298
+ I18n.setLanguage(language || "en");
299
+ }
300
+ }, [language]);
301
+
302
+ const showChatbotPreview: boolean = useSelect(() =>
303
+ getStoreSelect(store).isShowChatbotPreview(),
304
+ );
305
+
306
+ const resolvedMaxImages = useMemo(
307
+ () => Math.max(0, maxImages ?? DEFAULT_MAX_IMAGES),
308
+ [maxImages],
309
+ );
310
+
311
+ const resolvedMaxBytes = useMemo(
312
+ () => Math.max(0, maxImageBytes ?? DEFAULT_MAX_BYTES),
313
+ [maxImageBytes],
314
+ );
315
+
316
+ const hasMessages = messages.length > 0;
317
+
318
+ const isChatBusy = useMemo(
319
+ () => ai.busy && activeOp === "chat",
320
+ [ai.busy, activeOp],
321
+ );
322
+
323
+ const canSend = useMemo(() => {
324
+ if (isChatBusy) return false;
325
+ return question.trim().length > 0;
326
+ }, [question, isChatBusy]);
327
+
328
+ const openButtonLabel = useMemo(() => {
329
+ const raw = openButtonTitle ? openButtonTitle : labels.askMeLabel;
330
+ return I18n.get(raw);
331
+ }, [openButtonTitle, labels.askMeLabel, language]);
332
+
333
+ const modalTitle = useMemo(() => {
334
+ const raw = title ? title : labels.modalTitle;
335
+ return I18n.get(raw);
336
+ }, [title, labels.modalTitle, language]);
337
+
338
+ const textareaPlaceholder = useMemo(() => {
339
+ const raw = placeholder ? placeholder : labels.placeholder;
340
+ return I18n.get(raw);
341
+ }, [placeholder, labels.placeholder, language]);
342
+
343
+ const rootClassName = useMemo(() => {
344
+ const base = "ai-docs-ask";
345
+ const pos = `ai-open-btn--${openButtonPosition}`;
346
+ return className ? `${base} ${pos} ${className}` : `${base} ${pos}`;
347
+ }, [className, openButtonPosition]);
348
+
349
+ const adjustChatHeight = useCallback(() => {
350
+ const el = chatContainerRef.current;
351
+ if (!el) return;
352
+ try {
353
+ const vh = window.innerHeight || document.documentElement.clientHeight;
354
+ const minHeight = 360;
355
+ const maxHeightCap = 1000;
356
+ const viewportMax = Math.floor(vh * 0.8);
357
+ const target = Math.max(minHeight, Math.min(viewportMax, maxHeightCap));
358
+ el.style.height = `${target}px`;
359
+ const scrollEl = chatScrollRef.current;
360
+ if (scrollEl) {
361
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
362
+ scrollEl.offsetHeight;
363
+ }
364
+ } catch {
365
+ // ignore
366
+ }
367
+ }, []);
368
+
369
+ const scrollToBottom = useCallback(() => {
370
+ const el = chatScrollRef.current;
371
+ if (!el) return;
372
+ window.setTimeout(() => {
373
+ try {
374
+ el.scrollTop = el.scrollHeight;
375
+ } catch {
376
+ // ignore
377
+ }
378
+ }, 50);
379
+ }, []);
380
+
381
+ const closeModal = useCallback(() => {
382
+ setOpened(false);
383
+ if (isMaximized) setIsMaximized(false);
384
+ setMaxEnter(false);
385
+ onClose?.();
386
+ }, [isMaximized, onClose]);
387
+
388
+ useEffect(() => {
389
+ if (!opened) return;
390
+ adjustChatHeight();
391
+ window.addEventListener("resize", adjustChatHeight);
392
+ return () => window.removeEventListener("resize", adjustChatHeight);
393
+ }, [opened, adjustChatHeight]);
394
+
395
+ useEffect(() => {
396
+ if (!opened) return;
397
+ document.body.style.overflow = "hidden";
398
+ document.body.onkeydown = (e: KeyboardEvent) => {
399
+ if (e.key === "Escape") {
400
+ e.preventDefault();
401
+ closeModal();
402
+ }
403
+ };
404
+ return () => {
405
+ document.body.style.overflow = "";
406
+ document.body.onkeydown = null;
407
+ };
408
+ }, [opened, closeModal]);
409
+
410
+ const imagePreviews = useMemo(() => {
411
+ if (
412
+ typeof window === "undefined" ||
413
+ typeof URL.createObjectURL !== "function"
414
+ ) {
415
+ return images.map((file) => ({ file, url: "" }));
416
+ }
417
+ return images.map((file) => ({ file, url: URL.createObjectURL(file) }));
418
+ }, [images]);
419
+
420
+ useEffect(() => {
421
+ if (!hasMessages) setStickToBottom(true);
422
+ }, [hasMessages]);
423
+
424
+ useEffect(() => {
425
+ return () => {
426
+ if (
427
+ typeof window === "undefined" ||
428
+ typeof URL.revokeObjectURL !== "function"
429
+ )
430
+ return;
431
+ imagePreviews.forEach(({ url }) => {
432
+ if (url) URL.revokeObjectURL(url);
433
+ });
434
+ };
435
+ }, [imagePreviews]);
436
+
437
+ useEffect(() => {
438
+ const el = chatScrollRef.current;
439
+ if (!el) return;
440
+ const handleScroll = () => {
441
+ const distanceFromBottom =
442
+ el.scrollHeight - (el.scrollTop + el.clientHeight);
443
+ setStickToBottom(distanceFromBottom < 20);
444
+ };
445
+ el.addEventListener("scroll", handleScroll);
446
+ return () => {
447
+ el.removeEventListener("scroll", handleScroll);
448
+ };
449
+ }, [opened]);
450
+
451
+ useEffect(() => {
452
+ if (!stickToBottom) return;
453
+ const el = chatScrollRef.current;
454
+ if (!el) return;
455
+ if (el.scrollHeight > el.clientHeight) {
456
+ el.scrollTop = el.scrollHeight;
457
+ }
458
+ }, [messages, ai.busy, stickToBottom]);
459
+
460
+ const statusText = useMemo(() => {
461
+ if (!ai.busy) return null;
462
+ return formatStatusEvent(ai.statusEvent) || I18n.get("Working…");
463
+ }, [ai.busy, ai.statusEvent, language]);
464
+
465
+ const lastCanceledUserMessageId = useMemo(() => {
466
+ for (let i = messages.length - 1; i >= 0; i--) {
467
+ const m = messages[i];
468
+ if (m.role === "user" && m.clientStatus === "canceled") return m.id;
469
+ }
470
+ return null;
471
+ }, [messages]);
472
+
473
+ const markLastPendingAs = useCallback(
474
+ (status: "canceled" | null) => {
475
+ setMessages((prev) => {
476
+ const idx = [...prev]
477
+ .map((m, i) => ({ m, i }))
478
+ .reverse()
479
+ .find(
480
+ (x) => x.m.role === "user" && x.m.clientStatus === "pending",
481
+ )?.i;
482
+
483
+ if (idx == null) return prev;
484
+
485
+ const next = prev.slice();
486
+ const target = next[idx];
487
+ next[idx] = {
488
+ ...target,
489
+ clientStatus: status ?? undefined,
490
+ };
491
+ return next;
492
+ });
493
+ },
494
+ [setMessages],
495
+ );
496
+
497
+ const cancelChat = useCallback(() => {
498
+ if (!ai.busy || activeOpRef.current !== "chat") return;
499
+
500
+ cancelRequestedRef.current = true;
501
+ try {
502
+ ai.cancel();
503
+ } catch {
504
+ // ignore
505
+ }
506
+
507
+ // UI: treat as "not sent"
508
+ markLastPendingAs("canceled");
509
+ setActiveOp(null);
510
+
511
+ // feedback-only status line error should not persist into chat cancel
512
+ setStatusLineError(null);
513
+
514
+ scrollToBottom();
515
+ }, [ai, markLastPendingAs, scrollToBottom]);
516
+
517
+ const onPickImages = useCallback(
518
+ (e: React.ChangeEvent<HTMLInputElement>) => {
519
+ const existing = imagesRef.current;
520
+ const files = Array.from(e.target.files || []);
521
+ const remaining = Math.max(0, resolvedMaxImages - existing.length);
522
+
523
+ const picked = files.slice(0, remaining).filter((f) => {
524
+ const okType = /image\/(jpeg|png|gif|webp)/i.test(f.type);
525
+ const okSize = f.size <= resolvedMaxBytes;
526
+ const okNew = !existing.find(
527
+ (x) =>
528
+ x.name === f.name &&
529
+ x.size === f.size &&
530
+ x.lastModified === f.lastModified,
531
+ );
532
+ return okType && okSize && okNew;
533
+ });
534
+
535
+ if (picked.length) setImages((prev) => [...prev, ...picked]);
536
+ e.currentTarget.value = "";
537
+ },
538
+ [resolvedMaxImages, resolvedMaxBytes],
539
+ );
540
+
541
+ const removeImage = useCallback((ix: number) => {
542
+ setImages((prev) => prev.filter((_, i) => i !== ix));
543
+ }, []);
544
+
545
+ // New: clear feedback errors back to Ready after a short time
546
+ const statusLineErrorTimerRef = useRef<number | null>(null);
547
+ useEffect(() => {
548
+ if (!statusLineError) return;
549
+ if (statusLineErrorTimerRef.current) {
550
+ window.clearTimeout(statusLineErrorTimerRef.current);
551
+ statusLineErrorTimerRef.current = null;
552
+ }
553
+ statusLineErrorTimerRef.current = window.setTimeout(() => {
554
+ setStatusLineError(null);
555
+ statusLineErrorTimerRef.current = null;
556
+ }, 6000);
557
+ return () => {
558
+ if (statusLineErrorTimerRef.current) {
559
+ window.clearTimeout(statusLineErrorTimerRef.current);
560
+ statusLineErrorTimerRef.current = null;
561
+ }
562
+ };
563
+ }, [statusLineError]);
564
+
565
+ const resetConversation = useCallback(() => {
566
+ setMessages([]);
567
+ setStatusLineError(null);
568
+ sessionRef.current = null;
569
+ setLastUserSentAt(null);
570
+ setStickToBottom(true);
571
+ setResetDialogOpen(false);
572
+
573
+ // clear persisted history immediately
574
+ const storage = getHistoryStorage(historyStorage);
575
+ if (storage) {
576
+ try {
577
+ storage.removeItem(HISTORY_STORAGE_KEY);
578
+ } catch {
579
+ // ignore
580
+ }
581
+ }
582
+ }, [historyStorage]);
583
+
584
+ const handleResetClick = useCallback(() => {
585
+ // Open confirmation dialog
586
+ setResetDialogOpen(true);
587
+ }, []);
588
+
589
+ const confirmReset = useCallback(() => {
590
+ // If a chat is in-flight, cancel first (then reset)
591
+ if (ai.busy && activeOpRef.current === "chat") {
592
+ cancelChat();
593
+ }
594
+ resetConversation();
595
+ }, [ai.busy, cancelChat, resetConversation]);
596
+
597
+ const cancelReset = useCallback(() => {
598
+ setResetDialogOpen(false);
599
+ }, []);
600
+
601
+ const sendFeedbackToServer = useCallback(
602
+ async (messageId: string, feedbackType: "accepted" | "rejected") => {
603
+ // feedback should NOT spam status; only show errors
604
+ if (ai.busy) return;
605
+
606
+ try {
607
+ const activeSessionId =
608
+ sessionRef.current &&
609
+ Date.now() - sessionRef.current.storedAt < TWENTY_FOUR_HOURS_MS
610
+ ? sessionRef.current.id
611
+ : undefined;
612
+ if (!activeSessionId) return;
613
+
614
+ setActiveOp("feedback");
615
+ setStatusLineError(null);
616
+
617
+ await ai.run(async ({ signal, onStatus }) => {
618
+ await sendFeedbackMessage(
619
+ {
620
+ sessionId: activeSessionId,
621
+ feedbackMessageId: messageId,
622
+ feedbackType,
623
+ },
624
+ {
625
+ signal,
626
+ onStatus,
627
+ },
628
+ );
629
+ return null;
630
+ });
631
+
632
+ // success: keep Ready (no extra UI)
633
+ setStatusLineError(null);
634
+ } catch (e) {
635
+ const msg =
636
+ (e as Error)?.message?.trim() || I18n.get("An error occurred.");
637
+ // feedback error: ONLY status line (auto-clears back to Ready)
638
+ setStatusLineError(msg);
639
+ console.error("Failed to send feedback", e);
640
+ } finally {
641
+ setActiveOp((prev) => (prev === "feedback" ? null : prev));
642
+ }
643
+ },
644
+ [ai, language],
645
+ );
646
+
647
+ const updateFeedback = useCallback(
648
+ (messageId: string, verdict: "accepted" | "rejected") => {
649
+ // optimistic toggle (and allow "clear" by clicking same verdict again)
650
+ setMessages((prev) =>
651
+ prev.map((msg) => {
652
+ if (msg.id !== messageId || msg.role !== "assistant") return msg;
653
+ if (msg.feedback === verdict) return { ...msg, feedback: undefined };
654
+ return { ...msg, feedback: verdict };
655
+ }),
656
+ );
657
+ void sendFeedbackToServer(messageId, verdict);
658
+ },
659
+ [sendFeedbackToServer],
660
+ );
661
+
662
+ const ask = useCallback(async () => {
663
+ const trimmed = questionRef.current.trim();
664
+ if (!trimmed || ai.busy) return;
665
+
666
+ cancelRequestedRef.current = false;
667
+ setStatusLineError(null);
668
+ setActiveOp("chat");
669
+
670
+ const selectedImages = imagesRef.current;
671
+
672
+ const userMessageId = createMessageId("user");
673
+ const userMessageCreatedAt = Date.now();
674
+ const userMessage: ChatMessage = {
675
+ id: userMessageId,
676
+ role: "user",
677
+ content: trimmed,
678
+ createdAt: userMessageCreatedAt,
679
+ clientStatus: "pending",
680
+ };
681
+
682
+ // optimistic UI
683
+ setQuestion("");
684
+ setImages([]);
685
+ if (fileInputRef.current) fileInputRef.current.value = "";
686
+ setMessages((prev) => [...prev, userMessage]);
687
+
688
+ if (!opened) setOpened(true);
689
+ scrollToBottom();
690
+
691
+ try {
692
+ const activeSessionId =
693
+ sessionRef.current &&
694
+ Date.now() - sessionRef.current.storedAt < TWENTY_FOUR_HOURS_MS
695
+ ? sessionRef.current.id
696
+ : undefined;
697
+
698
+ const res = (await ai.run(async ({ signal, onStatus }) => {
699
+ const out = await sendChatMessage(
700
+ {
701
+ sessionId: activeSessionId,
702
+ message: trimmed,
703
+ images: selectedImages,
704
+ },
705
+ {
706
+ signal,
707
+ onStatus,
708
+ },
709
+ );
710
+ return out;
711
+ })) as ChatResponse | null;
712
+
713
+ // If user clicked cancel while request was in-flight, ignore output
714
+ if (cancelRequestedRef.current) {
715
+ markLastPendingAs("canceled");
716
+ return;
717
+ }
718
+
719
+ if (!res) throw new Error(I18n.get(labels.emptyResponseLabel));
720
+
721
+ if (res.sessionId) {
722
+ sessionRef.current = {
723
+ id: res.sessionId,
724
+ storedAt: Date.now(),
725
+ };
726
+ }
727
+
728
+ const assistantMessage: ChatMessage = {
729
+ id: res.metadata?.messageId || createMessageId("assistant"),
730
+ role: "assistant",
731
+ content: res.result || "",
732
+ citations: res.citations,
733
+ createdAt: Date.now(),
734
+ };
735
+
736
+ setMessages((prev) => {
737
+ const cleared = prev.map((m) =>
738
+ m.id === userMessageId ? { ...m, clientStatus: undefined } : m,
739
+ );
740
+ return [...cleared, assistantMessage];
741
+ });
742
+
743
+ // mark last sent timestamp on successful request completion
744
+ setLastUserSentAt(userMessageCreatedAt);
745
+ } catch (e) {
746
+ // Cancel: treat as not sent, no error bubble
747
+ if (
748
+ cancelRequestedRef.current ||
749
+ isAbortLike(e as Error & { code?: string })
750
+ ) {
751
+ markLastPendingAs("canceled");
752
+ return;
753
+ }
754
+
755
+ const msg =
756
+ (e as Error)?.message?.trim() || I18n.get(labels.unexpectedErrorLabel);
757
+
758
+ // show error inside chat (assistant side)
759
+ setMessages((prev) => {
760
+ const cleared = prev.map((m) =>
761
+ m.id === userMessageId ? { ...m, clientStatus: undefined } : m,
762
+ );
763
+ return [
764
+ ...cleared,
765
+ {
766
+ id: createMessageId("assistant-error"),
767
+ role: "assistant",
768
+ content: `⚠️ ${msg}`,
769
+ createdAt: Date.now(),
770
+ },
771
+ ];
772
+ });
773
+
774
+ // still consider the message "sent" (server error happened after sending)
775
+ setLastUserSentAt(userMessageCreatedAt);
776
+ } finally {
777
+ setActiveOp((prev) => (prev === "chat" ? null : prev));
778
+ cancelRequestedRef.current = false;
779
+ if (questionInputRef.current) questionInputRef.current.focus();
780
+ scrollToBottom();
781
+ }
782
+ }, [
783
+ ai,
784
+ opened,
785
+ scrollToBottom,
786
+ markLastPendingAs,
787
+ labels.emptyResponseLabel,
788
+ labels.unexpectedErrorLabel,
789
+ language,
790
+ ]);
791
+
792
+ const handleQuestionKeyDown = useCallback(
793
+ (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
794
+ if (e.key === "Enter" && !e.shiftKey) {
795
+ e.preventDefault();
796
+ if (canSend) void ask();
797
+ }
798
+ },
799
+ [ask, canSend],
800
+ );
801
+
802
+ const handleOpenClick = useCallback(() => {
803
+ setOpened(true);
804
+ }, []);
805
+
806
+ useEffect(() => {
807
+ return () => {
808
+ if (maxEnterRafRef.current != null) {
809
+ cancelAnimationFrame(maxEnterRafRef.current);
810
+ maxEnterRafRef.current = null;
811
+ }
812
+ };
813
+ }, []);
814
+
815
+ const handleToggleMaximize = useCallback(() => {
816
+ setIsMaximized((prev) => {
817
+ const next = !prev;
818
+
819
+ if (maxEnterRafRef.current != null) {
820
+ cancelAnimationFrame(maxEnterRafRef.current);
821
+ maxEnterRafRef.current = null;
822
+ }
823
+
824
+ // When maximizing: apply ai-max-enter for one render frame,
825
+ // then remove it so CSS can animate to the final state.
826
+ if (next) {
827
+ setMaxEnter(true);
828
+ requestAnimationFrame(() => {
829
+ maxEnterRafRef.current = requestAnimationFrame(() => {
830
+ setMaxEnter(false);
831
+ maxEnterRafRef.current = null;
832
+ });
833
+ });
834
+ } else {
835
+ // When restoring size: no need for enter helper
836
+ setMaxEnter(false);
837
+ }
838
+
839
+ return next;
840
+ });
841
+ }, []);
842
+
843
+ const handleEditCanceled = useCallback((msg: ChatMessage) => {
844
+ setQuestion(msg.content);
845
+ setMessages((prev) => prev.filter((m) => m.id !== msg.id));
846
+ queueMicrotask(() => questionInputRef.current?.focus());
847
+ }, []);
848
+
849
+ const renderOpenButtonIcon = useMemo(() => {
850
+ if (!showOpenButtonIcon) return null;
851
+ if (openButtonIcon) {
852
+ return <span dangerouslySetInnerHTML={{ __html: openButtonIcon }} />;
853
+ }
854
+ return <IconMessage size={18} />;
855
+ }, [showOpenButtonIcon, openButtonIcon]);
856
+
857
+ const openButtonContent = useMemo(() => {
858
+ const iconEl = renderOpenButtonIcon;
859
+ const textEl = showOpenButtonTitle ? (
860
+ <Text inherit>{openButtonLabel}</Text>
861
+ ) : null;
862
+
863
+ if (!showOpenButtonIcon && !textEl) return null;
864
+ if (!showOpenButtonIcon) return textEl;
865
+ if (!showOpenButtonTitle) return iconEl;
866
+
867
+ switch (openButtonIconLayout) {
868
+ case "top":
869
+ return (
870
+ <Stack gap={4} align="center">
871
+ {iconEl}
872
+ {textEl}
873
+ </Stack>
874
+ );
875
+ case "bottom":
876
+ return (
877
+ <Stack gap={4} align="center">
878
+ {textEl}
879
+ {iconEl}
880
+ </Stack>
881
+ );
882
+ case "right":
883
+ return (
884
+ <Group gap={6} align="center">
885
+ {textEl}
886
+ {iconEl}
887
+ </Group>
888
+ );
889
+ case "left":
890
+ default:
891
+ return (
892
+ <Group gap={6} align="center">
893
+ {iconEl}
894
+ {textEl}
895
+ </Group>
896
+ );
897
+ }
898
+ }, [
899
+ renderOpenButtonIcon,
900
+ showOpenButtonIcon,
901
+ showOpenButtonTitle,
902
+ openButtonLabel,
903
+ openButtonIconLayout,
904
+ ]);
905
+
906
+ const showStatusBubble = useMemo(() => isChatBusy, [isChatBusy]);
907
+
908
+ // Status line: hidden only while waiting for assistant (no duplicate).
909
+ const showStatusLine = useMemo(() => {
910
+ if (isChatBusy) return false;
911
+ return true;
912
+ }, [isChatBusy]);
913
+
914
+ const statusLineText = useMemo(() => {
915
+ if (statusLineError) return statusLineError;
916
+ return hasMessages
917
+ ? I18n.get(labels.readyLabel)
918
+ : I18n.get(labels.readyEmptyLabel);
919
+ }, [
920
+ statusLineError,
921
+ hasMessages,
922
+ labels.readyLabel,
923
+ labels.readyEmptyLabel,
924
+ language,
925
+ ]);
926
+
927
+ const sendOrCancelLabel = useMemo(() => {
928
+ if (isChatBusy) return I18n.get(labels.cancelLabel);
929
+ return I18n.get(labels.sendLabel);
930
+ }, [isChatBusy, labels.cancelLabel, labels.sendLabel, language]);
931
+
932
+ const sendOrCancelIcon = useMemo(() => {
933
+ if (isChatBusy) return <IconPlayerStop size={18} />;
934
+ return <IconSend size={18} />;
935
+ }, [isChatBusy]);
936
+
937
+ const onSendOrCancel = useCallback(() => {
938
+ if (isChatBusy) {
939
+ cancelChat();
940
+ return;
941
+ }
942
+ void ask();
943
+ }, [isChatBusy, cancelChat, ask]);
944
+
945
+ // -----------------------------
946
+ // History persistence
947
+ // -----------------------------
948
+
949
+ useEffect(() => {
950
+ const storage = getHistoryStorage(historyStorage);
951
+ if (!storage) return;
952
+
953
+ try {
954
+ const raw = storage.getItem(HISTORY_STORAGE_KEY);
955
+ if (!raw) return;
956
+
957
+ const parsed = JSON.parse(raw) as PersistedChat;
958
+ const last =
959
+ typeof parsed?.lastUserSentAt === "number"
960
+ ? parsed.lastUserSentAt
961
+ : null;
962
+
963
+ // Only use storage within 24 hours
964
+ if (!last || Date.now() - last > TWENTY_FOUR_HOURS_MS) {
965
+ storage.removeItem(HISTORY_STORAGE_KEY);
966
+ return;
967
+ }
968
+
969
+ const loadedMessages = Array.isArray(parsed.messages)
970
+ ? parsed.messages
971
+ : [];
972
+
973
+ // Normalize stale "pending" to "canceled" after reload
974
+ const normalized = loadedMessages.map((m) => {
975
+ if (m?.role === "user" && m.clientStatus === "pending") {
976
+ return { ...m, clientStatus: "canceled" as const };
977
+ }
978
+ return m;
979
+ });
980
+
981
+ setMessages(normalized);
982
+ setLastUserSentAt(last);
983
+
984
+ if (parsed.session && parsed.session.id) {
985
+ sessionRef.current = parsed.session;
986
+ }
987
+ } catch {
988
+ try {
989
+ storage.removeItem(HISTORY_STORAGE_KEY);
990
+ } catch {
991
+ // ignore
992
+ }
993
+ }
994
+ }, [historyStorage]);
995
+
996
+ useEffect(() => {
997
+ const storage = getHistoryStorage(historyStorage);
998
+ if (!storage) return;
999
+
1000
+ if (historyStorage === "nostorage") {
1001
+ try {
1002
+ storage.removeItem(HISTORY_STORAGE_KEY);
1003
+ } catch {
1004
+ // ignore
1005
+ }
1006
+ return;
1007
+ }
1008
+
1009
+ const last = lastUserSentAtRef.current;
1010
+
1011
+ if (!last) return;
1012
+
1013
+ if (Date.now() - last > TWENTY_FOUR_HOURS_MS) {
1014
+ try {
1015
+ storage.removeItem(HISTORY_STORAGE_KEY);
1016
+ } catch {
1017
+ // ignore
1018
+ }
1019
+ return;
1020
+ }
1021
+
1022
+ const payload: PersistedChat = {
1023
+ version: 1,
1024
+ lastUserSentAt: last,
1025
+ session: sessionRef.current,
1026
+ messages,
1027
+ };
1028
+
1029
+ try {
1030
+ storage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(payload));
1031
+ } catch {
1032
+ // ignore
1033
+ }
1034
+ }, [messages, lastUserSentAt, historyStorage]);
1035
+
1036
+ if (previewMode && !showChatbotPreview) {
1037
+ return null;
1038
+ }
1039
+
1040
+ return (
1041
+ <Group className={rootClassName}>
1042
+ {!opened && (
1043
+ <Button
1044
+ variant="filled"
1045
+ className={
1046
+ showOpenButtonTitle
1047
+ ? "ai-launcher-button ai-launcher-text"
1048
+ : "ai-launcher-button"
1049
+ }
1050
+ onClick={handleOpenClick}
1051
+ aria-label={openButtonLabel}
1052
+ title={openButtonLabel}
1053
+ data-ai-kit-open-button
1054
+ >
1055
+ {openButtonContent}
1056
+ </Button>
1057
+ )}
1058
+
1059
+ {opened && (
1060
+ <Modal.Root
1061
+ ref={chatContainerRef}
1062
+ opened={opened}
1063
+ onClose={closeModal}
1064
+ className={
1065
+ "ai-chat-container" +
1066
+ (isMaximized ? " maximized" : "") +
1067
+ (isMaximized && maxEnter ? " ai-max-enter" : "")
1068
+ }
1069
+ portalProps={{ target: rootElement, reuseTargetNode: true }}
1070
+ data-ai-kit-theme={colorMode}
1071
+ data-ai-kit-variation="modal"
1072
+ >
1073
+ <Modal.Body className="ai-chat-container-internal">
1074
+ <Modal.Header className="ai-chat-header-bar">
1075
+ <Modal.Title className="ai-chat-title">{modalTitle}</Modal.Title>
1076
+ <Group gap="4px" align="center" justify="center">
1077
+ {typeof window !== "undefined" && window.innerWidth > 600 && (
1078
+ <ActionIcon
1079
+ variant="subtle"
1080
+ c="var(--ai-kit-chat-icon-color, var(--ai-kit-color-text))"
1081
+ onClick={handleToggleMaximize}
1082
+ title={
1083
+ isMaximized
1084
+ ? I18n.get(labels.restoreSizeLabel)
1085
+ : I18n.get(labels.maximizeLabel)
1086
+ }
1087
+ aria-label={
1088
+ isMaximized
1089
+ ? I18n.get(labels.restoreSizeLabel)
1090
+ : I18n.get(labels.maximizeLabel)
1091
+ }
1092
+ >
1093
+ {isMaximized ? (
1094
+ <IconMinimize size={16} />
1095
+ ) : (
1096
+ <IconMaximize size={16} />
1097
+ )}
1098
+ </ActionIcon>
1099
+ )}
1100
+ <Modal.CloseButton
1101
+ aria-label={I18n.get(labels.closeChatLabel)}
1102
+ />
1103
+ </Group>
1104
+ </Modal.Header>
1105
+
1106
+ <Modal.Body className="ai-chat-scroll" ref={chatScrollRef}>
1107
+ {messages.map((msg) => {
1108
+ const isUser = msg.role === "user";
1109
+ const isLastCanceled =
1110
+ isUser &&
1111
+ msg.clientStatus === "canceled" &&
1112
+ msg.id === lastCanceledUserMessageId;
1113
+
1114
+ return (
1115
+ <Group
1116
+ key={msg.id}
1117
+ justify={isUser ? "flex-end" : "flex-start"}
1118
+ className={"ai-chat-row " + msg.role}
1119
+ onMouseEnter={() => setHoveredMessageId(msg.id)}
1120
+ onMouseLeave={() =>
1121
+ setHoveredMessageId((cur) =>
1122
+ cur === msg.id ? null : cur,
1123
+ )
1124
+ }
1125
+ >
1126
+ <Stack
1127
+ gap={4}
1128
+ style={{ alignItems: isUser ? "flex-end" : "flex-start" }}
1129
+ >
1130
+ <Stack className="ai-chat-bubble">
1131
+ <Text className="ai-chat-header">
1132
+ <Text fw="bolder" size="xs">
1133
+ {isUser
1134
+ ? I18n.get(labels.userLabel)
1135
+ : I18n.get(labels.assistantLabel)}
1136
+ </Text>
1137
+ <Text size="xs">
1138
+ {new Date(msg.createdAt).toLocaleTimeString([], {
1139
+ hour: "2-digit",
1140
+ minute: "2-digit",
1141
+ })}
1142
+ </Text>
1143
+ </Text>
1144
+
1145
+ {msg.role === "assistant" ? (
1146
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>
1147
+ {msg.content}
1148
+ </ReactMarkdown>
1149
+ ) : (
1150
+ <Text size="sm" miw="100px">
1151
+ {msg.content}
1152
+ </Text>
1153
+ )}
1154
+ </Stack>
1155
+
1156
+ {isLastCanceled && (
1157
+ <Group justify="flex-end" gap="xs">
1158
+ <Text size="xs" c="dimmed">
1159
+ <em>{I18n.get(labels.notSentLabel)}</em>
1160
+ </Text>
1161
+ {hoveredMessageId === msg.id && (
1162
+ <ActionIcon
1163
+ size="sm"
1164
+ variant="subtle"
1165
+ onClick={() => handleEditCanceled(msg)}
1166
+ title={I18n.get(labels.editLabel)}
1167
+ aria-label={I18n.get(labels.editLabel)}
1168
+ >
1169
+ <IconPencil size={14} />
1170
+ </ActionIcon>
1171
+ )}
1172
+ </Group>
1173
+ )}
1174
+
1175
+ {msg.citations && msg.citations.length > 0 && (
1176
+ <Stack className="ai-citations">
1177
+ <Text fw="bold" size="sm" mb="xs">
1178
+ {I18n.get(labels.referencesLabel)}
1179
+ </Text>
1180
+ <List spacing="xs" size="sm">
1181
+ {msg.citations.map((c, i) => {
1182
+ const link = c.sourceUrl || c.url;
1183
+ const citeTitle =
1184
+ c.title ||
1185
+ link ||
1186
+ `${I18n.get(labels.referenceLabel)} #${i + 1}`;
1187
+ return (
1188
+ <List.Item key={i}>
1189
+ {link ? (
1190
+ <Anchor
1191
+ href={link}
1192
+ target="_blank"
1193
+ rel="noreferrer"
1194
+ >
1195
+ {citeTitle}
1196
+ </Anchor>
1197
+ ) : (
1198
+ <Text>{citeTitle}</Text>
1199
+ )}
1200
+ {c.snippet ? (
1201
+ <Text size="xs" c="dimmed" mt={4}>
1202
+ {c.snippet}
1203
+ </Text>
1204
+ ) : null}
1205
+ </List.Item>
1206
+ );
1207
+ })}
1208
+ </List>
1209
+ </Stack>
1210
+ )}
1211
+
1212
+ {msg.role === "assistant" && (
1213
+ <Group className="ai-feedback" gap="xs">
1214
+ <Button
1215
+ className={
1216
+ msg.feedback === "accepted" ? "active" : undefined
1217
+ }
1218
+ onClick={() => updateFeedback(msg.id, "accepted")}
1219
+ aria-label={I18n.get(labels.acceptResponseLabel)}
1220
+ disabled={ai.busy}
1221
+ >
1222
+ 👍
1223
+ </Button>
1224
+ <Button
1225
+ type="button"
1226
+ className={
1227
+ msg.feedback === "rejected" ? "active" : undefined
1228
+ }
1229
+ onClick={() => updateFeedback(msg.id, "rejected")}
1230
+ aria-label={I18n.get(labels.rejectResponseLabel)}
1231
+ disabled={ai.busy}
1232
+ >
1233
+ 👎
1234
+ </Button>
1235
+ </Group>
1236
+ )}
1237
+ </Stack>
1238
+ </Group>
1239
+ );
1240
+ })}
1241
+
1242
+ {/* Progress/status bubble (assistant side) - ONLY while waiting for chat answer */}
1243
+ {showStatusBubble && (
1244
+ <Group
1245
+ justify="flex-start"
1246
+ className="ai-chat-row assistant status"
1247
+ >
1248
+ <Stack className="ai-chat-bubble typing">
1249
+ {statusText ? (
1250
+ <Text size="sm" c="dimmed">
1251
+ <em>{statusText}</em>
1252
+ </Text>
1253
+ ) : null}
1254
+ <div className="typing-indicator">
1255
+ <span />
1256
+ <span />
1257
+ <span />
1258
+ </div>
1259
+ </Stack>
1260
+ </Group>
1261
+ )}
1262
+ </Modal.Body>
1263
+
1264
+ {/* Status line (below bubbles) */}
1265
+ {showStatusLine && (
1266
+ <Group className="ai-status-line">
1267
+ <Text className="ai-status-text">
1268
+ <em>{statusLineText}</em>
1269
+ </Text>
1270
+ </Group>
1271
+ )}
1272
+
1273
+ <Stack className="ai-box ai-box-open">
1274
+ {/* Reset confirmation dialog (Yes/No) */}
1275
+ <Modal
1276
+ opened={resetDialogOpen}
1277
+ onClose={cancelReset}
1278
+ centered
1279
+ title={I18n.get("Reset conversation")}
1280
+ withinPortal={false}
1281
+ >
1282
+ <Text size="sm">
1283
+ {I18n.get("Are you sure you want to reset the conversation?")}
1284
+ </Text>
1285
+ <Group justify="flex-end" mt="md">
1286
+ <Button variant="default" onClick={cancelReset}>
1287
+ {I18n.get("No")}
1288
+ </Button>
1289
+ <Button
1290
+ color="red"
1291
+ onClick={confirmReset}
1292
+ disabled={!hasMessages && !isChatBusy}
1293
+ >
1294
+ {I18n.get("Yes")}
1295
+ </Button>
1296
+ </Group>
1297
+ </Modal>
1298
+
1299
+ <Group>
1300
+ <Textarea
1301
+ className="ai-message"
1302
+ ref={questionInputRef}
1303
+ placeholder={textareaPlaceholder}
1304
+ value={question}
1305
+ onChange={(e) => {
1306
+ setQuestion(e.target.value);
1307
+ }}
1308
+ onKeyDown={handleQuestionKeyDown}
1309
+ rows={3}
1310
+ />
1311
+ </Group>
1312
+
1313
+ <Group className="ai-actions" justify="space-between" w="100%">
1314
+ <Group justify="flex-start">
1315
+ <Button
1316
+ variant="light"
1317
+ leftSection={<IconTrash size={18} />}
1318
+ onClick={handleResetClick}
1319
+ disabled={!hasMessages && !isChatBusy}
1320
+ >
1321
+ {I18n.get(labels.resetLabel)}
1322
+ </Button>
1323
+ </Group>
1324
+
1325
+ <Group justify="flex-end">
1326
+ <Button
1327
+ variant="outline"
1328
+ leftSection={<IconPaperclip size={18} />}
1329
+ onClick={() => fileInputRef.current?.click()}
1330
+ disabled={images.length >= resolvedMaxImages}
1331
+ title={I18n.get(labels.addImageLabel)}
1332
+ >
1333
+ {I18n.get(labels.addLabel)}
1334
+ </Button>
1335
+ <Input
1336
+ ref={fileInputRef}
1337
+ type="file"
1338
+ accept="image/png,image/jpeg,image/gif,image/webp"
1339
+ style={{ display: "none" }}
1340
+ multiple
1341
+ onChange={onPickImages}
1342
+ />
1343
+
1344
+ {/* Send -> Cancel switch (ChatGPT-like) */}
1345
+ <Button
1346
+ leftSection={sendOrCancelIcon}
1347
+ variant="filled"
1348
+ onClick={onSendOrCancel}
1349
+ disabled={!isChatBusy && !canSend}
1350
+ >
1351
+ {sendOrCancelLabel}
1352
+ </Button>
1353
+ </Group>
1354
+ </Group>
1355
+
1356
+ {imagePreviews.length > 0 && (
1357
+ <Group className="ai-thumbs" mt="xs" gap="xs">
1358
+ {imagePreviews.map(({ url }, i) => (
1359
+ <div
1360
+ key={i}
1361
+ className="thumb"
1362
+ style={{
1363
+ backgroundImage: url ? `url(${url})` : undefined,
1364
+ backgroundSize: "cover",
1365
+ backgroundPosition: "center",
1366
+ backgroundRepeat: "no-repeat",
1367
+ overflow: "visible",
1368
+ }}
1369
+ >
1370
+ <Button
1371
+ variant="white"
1372
+ onClick={() => removeImage(i)}
1373
+ aria-label={I18n.get(labels.removeImageLabel)}
1374
+ mt="-xs"
1375
+ mr="-xs"
1376
+ size="xs"
1377
+ p={0}
1378
+ className="remove-image-button"
1379
+ title={I18n.get(labels.removeImageLabel)}
1380
+ >
1381
+ X
1382
+ </Button>
1383
+ </div>
1384
+ ))}
1385
+ </Group>
1386
+ )}
1387
+ </Stack>
1388
+ </Modal.Body>
1389
+ </Modal.Root>
1390
+ )}
1391
+ </Group>
1392
+ );
1393
+ };
1394
+
1395
+ export const AiChatbot = withAiKitShell(AiChatbotBase, {
1396
+ showOpenButton: true,
1397
+ variation: "modal",
1398
+ });