@smart-cloud/ai-kit-ui 1.1.26 → 1.1.29

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.
@@ -46,12 +46,19 @@ import remarkGfm from "remark-gfm";
46
46
  import { translations } from "../i18n";
47
47
  import { useAiRun } from "../useAiRun";
48
48
  import { AiKitShellInjectedProps, withAiKitShell } from "../withAiKitShell";
49
+ import {
50
+ cleanupDanglingAttachments,
51
+ clearAllAttachments,
52
+ loadAttachmentBlob,
53
+ persistAttachmentBlob,
54
+ } from "./attachmentStorage";
49
55
 
50
56
  I18n.putVocabularies(translations);
51
57
 
52
58
  const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
53
59
 
54
60
  // New: history storage support
61
+ const DEFAULT_PRESERVATION_TIME_DAYS = 1;
55
62
  const DEFAULT_HISTORY_STORAGE: HistoryStorageMode = "localstorage";
56
63
  const HISTORY_STORAGE_KEY = `ai-kit-chatbot-history-v1:${
57
64
  typeof window !== "undefined" ? window.location.hostname : "unknown"
@@ -117,6 +124,16 @@ type ChatResponse = {
117
124
  };
118
125
  };
119
126
 
127
+ type ChatMessageAttachment = {
128
+ id: string;
129
+ name: string;
130
+ type: string;
131
+ size: number;
132
+ blobId?: string;
133
+ objectUrl?: string | null;
134
+ blob?: Blob;
135
+ };
136
+
120
137
  type ChatMessage = {
121
138
  id: string;
122
139
  role: "user" | "assistant";
@@ -125,6 +142,19 @@ type ChatMessage = {
125
142
  createdAt: number;
126
143
  feedback?: "accepted" | "rejected";
127
144
  clientStatus?: "pending" | "canceled";
145
+ attachments?: ChatMessageAttachment[];
146
+ };
147
+
148
+ type ComposerImage = {
149
+ id: string;
150
+ file: File;
151
+ objectUrl: string;
152
+ };
153
+
154
+ type PersistedAttachment = Omit<ChatMessageAttachment, "objectUrl" | "blob">;
155
+
156
+ type PersistedChatMessage = Omit<ChatMessage, "attachments"> & {
157
+ attachments?: PersistedAttachment[];
128
158
  };
129
159
 
130
160
  type ActiveOp = "chat" | "feedback" | null;
@@ -197,11 +227,43 @@ function getHistoryStorage(mode: HistoryStorageMode): Storage | null {
197
227
  }
198
228
  }
199
229
 
230
+ const createObjectUrl = (blob: Blob): string | null => {
231
+ if (typeof window === "undefined") return null;
232
+ if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function")
233
+ return null;
234
+ try {
235
+ return URL.createObjectURL(blob);
236
+ } catch {
237
+ return null;
238
+ }
239
+ };
240
+
241
+ const revokeObjectUrlSafe = (url?: string | null) => {
242
+ if (!url) return;
243
+ if (typeof window === "undefined") return;
244
+ if (typeof URL === "undefined" || typeof URL.revokeObjectURL !== "function")
245
+ return;
246
+ try {
247
+ URL.revokeObjectURL(url);
248
+ } catch {
249
+ // ignore
250
+ }
251
+ };
252
+
253
+ const disposeMessageAttachments = (attachments?: ChatMessageAttachment[]) => {
254
+ if (!attachments || attachments.length === 0) return;
255
+ attachments.forEach((att) => revokeObjectUrlSafe(att.objectUrl));
256
+ };
257
+
258
+ const disposeMessagesAttachments = (messages: ChatMessage[]) => {
259
+ messages.forEach((msg) => disposeMessageAttachments(msg.attachments));
260
+ };
261
+
200
262
  type PersistedChat = {
201
263
  version: 1;
202
264
  lastUserSentAt: number | null;
203
265
  session?: { id: string; storedAt: number } | null;
204
- messages: ChatMessage[];
266
+ messages: PersistedChatMessage[];
205
267
  };
206
268
 
207
269
  const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
@@ -227,6 +289,7 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
227
289
 
228
290
  // New
229
291
  historyStorage = DEFAULT_HISTORY_STORAGE,
292
+ emptyHistoryAfterDays = DEFAULT_PRESERVATION_TIME_DAYS,
230
293
  labels: labelsOverride,
231
294
  openButtonIconLayout = "top",
232
295
  openButtonPosition = "bottom-right",
@@ -243,7 +306,7 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
243
306
  const ai = useAiRun();
244
307
 
245
308
  const [question, setQuestion] = useState("");
246
- const [images, setImages] = useState<File[]>([]);
309
+ const [composerImages, setComposerImages] = useState<ComposerImage[]>([]);
247
310
  const [messages, setMessages] = useState<ChatMessage[]>([]);
248
311
  const [statusLineError, setStatusLineError] = useState<string | null>(null);
249
312
  const [resetDialogOpen, setResetDialogOpen] = useState(false);
@@ -251,6 +314,11 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
251
314
  const [maxEnter, setMaxEnter] = useState(false);
252
315
  const [opened, setOpened] = useState(false);
253
316
  const [stickToBottom, setStickToBottom] = useState(true);
317
+ const [previewAttachment, setPreviewAttachment] = useState<{
318
+ url: string;
319
+ title?: string;
320
+ } | null>(null);
321
+ const [historyReady, setHistoryReady] = useState(false);
254
322
 
255
323
  const [wheelHostEl, setWheelHostEl] = useState<HTMLDivElement | null>(null);
256
324
  const [scrollerEl, setScrollerEl] = useState<HTMLDivElement | null>(null);
@@ -272,21 +340,31 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
272
340
  const sessionRef = useRef<{ id: string; storedAt: number } | null>(null);
273
341
  const chatContainerRef = useRef<HTMLDivElement>(null);
274
342
 
343
+ const disposeComposerImageList = useCallback((list: ComposerImage[]) => {
344
+ list.forEach((img) => revokeObjectUrlSafe(img.objectUrl));
345
+ }, []);
346
+
347
+ const clearComposerImages = useCallback(() => {
348
+ disposeComposerImageList(composerImagesRef.current);
349
+ setComposerImages([]);
350
+ if (fileInputRef.current) fileInputRef.current.value = "";
351
+ }, [disposeComposerImageList]);
352
+
275
353
  // New: persist timestamp of last actually-sent user message
276
354
  const [lastUserSentAt, setLastUserSentAt] = useState<number | null>(null);
277
355
 
278
356
  // Keep latest values in refs for stable callbacks
279
357
  const questionRef = useRef(question);
280
- const imagesRef = useRef(images);
281
358
  const messagesRef = useRef(messages);
282
359
  const lastUserSentAtRef = useRef(lastUserSentAt);
360
+ const composerImagesRef = useRef(composerImages);
283
361
 
284
362
  useEffect(() => {
285
363
  questionRef.current = question;
286
364
  }, [question]);
287
365
  useEffect(() => {
288
- imagesRef.current = images;
289
- }, [images]);
366
+ composerImagesRef.current = composerImages;
367
+ }, [composerImages]);
290
368
  useEffect(() => {
291
369
  messagesRef.current = messages;
292
370
  }, [messages]);
@@ -396,15 +474,15 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
396
474
  };
397
475
  }, [opened, isMaximized, closeModal]);
398
476
 
399
- const imagePreviews = useMemo(() => {
400
- if (
401
- typeof window === "undefined" ||
402
- typeof URL.createObjectURL !== "function"
403
- ) {
404
- return images.map((file) => ({ file, url: "" }));
405
- }
406
- return images.map((file) => ({ file, url: URL.createObjectURL(file) }));
407
- }, [images]);
477
+ const composerPreviews = useMemo(
478
+ () =>
479
+ composerImages.map((img) => ({
480
+ id: img.id,
481
+ url: img.objectUrl,
482
+ title: img.file.name,
483
+ })),
484
+ [composerImages],
485
+ );
408
486
 
409
487
  useEffect(() => {
410
488
  if (!hasMessages) setStickToBottom(true);
@@ -412,16 +490,9 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
412
490
 
413
491
  useEffect(() => {
414
492
  return () => {
415
- if (
416
- typeof window === "undefined" ||
417
- typeof URL.revokeObjectURL !== "function"
418
- )
419
- return;
420
- imagePreviews.forEach(({ url }) => {
421
- if (url) URL.revokeObjectURL(url);
422
- });
493
+ disposeComposerImageList(composerImagesRef.current);
423
494
  };
424
- }, [imagePreviews]);
495
+ }, [disposeComposerImageList]);
425
496
 
426
497
  useEffect(() => {
427
498
  const el = scrollerEl;
@@ -446,6 +517,12 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
446
517
  }
447
518
  }, [messages, ai.busy, stickToBottom, scrollerEl]);
448
519
 
520
+ useEffect(() => {
521
+ if (!opened) {
522
+ setPreviewAttachment(null);
523
+ }
524
+ }, [opened]);
525
+
449
526
  const statusText = useMemo(() => {
450
527
  if (!ai.busy) return null;
451
528
  return formatStatusEvent(ai.statusEvent) || I18n.get("Working…");
@@ -505,30 +582,53 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
505
582
 
506
583
  const onPickImages = useCallback(
507
584
  (e: React.ChangeEvent<HTMLInputElement>) => {
508
- const existing = imagesRef.current;
585
+ const existing = composerImagesRef.current;
509
586
  const files = Array.from(e.target.files || []);
510
587
  const remaining = Math.max(0, resolvedMaxImages - existing.length);
511
588
 
512
- const picked = files.slice(0, remaining).filter((f) => {
513
- const okType = /image\/(jpeg|png|gif|webp)/i.test(f.type);
514
- const okSize = f.size <= resolvedMaxBytes;
515
- const okNew = !existing.find(
589
+ if (remaining === 0) {
590
+ e.currentTarget.value = "";
591
+ return;
592
+ }
593
+
594
+ const picked: ComposerImage[] = [];
595
+
596
+ for (const file of files) {
597
+ if (picked.length >= remaining) break;
598
+ const okType = /image\/(jpeg|png|gif|webp)/i.test(file.type);
599
+ const okSize = file.size <= resolvedMaxBytes;
600
+ const duplicate = [...existing, ...picked].some(
516
601
  (x) =>
517
- x.name === f.name &&
518
- x.size === f.size &&
519
- x.lastModified === f.lastModified,
602
+ x.file.name === file.name &&
603
+ x.file.size === file.size &&
604
+ x.file.lastModified === file.lastModified,
520
605
  );
521
- return okType && okSize && okNew;
522
- });
523
606
 
524
- if (picked.length) setImages((prev) => [...prev, ...picked]);
607
+ if (!okType || !okSize || duplicate) continue;
608
+
609
+ const objectUrl = createObjectUrl(file);
610
+ if (!objectUrl) continue;
611
+
612
+ picked.push({
613
+ id: createMessageId("composer-image"),
614
+ file,
615
+ objectUrl,
616
+ });
617
+ }
618
+
619
+ if (picked.length) setComposerImages((prev) => [...prev, ...picked]);
525
620
  e.currentTarget.value = "";
526
621
  },
527
622
  [resolvedMaxImages, resolvedMaxBytes],
528
623
  );
529
624
 
530
625
  const removeImage = useCallback((ix: number) => {
531
- setImages((prev) => prev.filter((_, i) => i !== ix));
626
+ setComposerImages((prev) => {
627
+ if (ix < 0 || ix >= prev.length) return prev;
628
+ const target = prev[ix];
629
+ if (target) revokeObjectUrlSafe(target.objectUrl);
630
+ return prev.filter((_, i) => i !== ix);
631
+ });
532
632
  }, []);
533
633
 
534
634
  // New: clear feedback errors back to Ready after a short time
@@ -551,7 +651,168 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
551
651
  };
552
652
  }, [statusLineError]);
553
653
 
654
+ const buildUserAttachments = useCallback(
655
+ async (sources: ComposerImage[]): Promise<ChatMessageAttachment[]> => {
656
+ if (!sources.length) return [];
657
+
658
+ const shouldPersist = historyStorage !== "nostorage";
659
+
660
+ const built = await Promise.all(
661
+ sources.map(async (img) => {
662
+ const attachmentId = createMessageId("attachment");
663
+ let blobId: string | undefined;
664
+ if (shouldPersist) {
665
+ try {
666
+ const persisted = await persistAttachmentBlob(
667
+ attachmentId,
668
+ img.file,
669
+ {
670
+ name: img.file.name,
671
+ type: img.file.type,
672
+ size: img.file.size,
673
+ },
674
+ );
675
+ blobId = persisted ?? undefined;
676
+ } catch (error) {
677
+ console.warn("[AiChatbot] Failed to persist attachment", error);
678
+ }
679
+ }
680
+
681
+ const objectUrl = createObjectUrl(img.file);
682
+
683
+ return {
684
+ id: attachmentId,
685
+ name: img.file.name,
686
+ type: img.file.type || "application/octet-stream",
687
+ size: img.file.size,
688
+ blobId,
689
+ objectUrl: objectUrl ?? undefined,
690
+ blob: img.file,
691
+ } satisfies ChatMessageAttachment;
692
+ }),
693
+ );
694
+
695
+ return built.filter(Boolean);
696
+ },
697
+ [historyStorage],
698
+ );
699
+
700
+ const hydratePersistedMessages = useCallback(
701
+ async (stored: PersistedChatMessage[]): Promise<ChatMessage[]> => {
702
+ const hydrated: ChatMessage[] = [];
703
+
704
+ for (const msg of stored) {
705
+ let attachments: ChatMessageAttachment[] | undefined;
706
+ if (msg.attachments && msg.attachments.length > 0) {
707
+ attachments = [];
708
+ for (const att of msg.attachments) {
709
+ let blob: Blob | undefined;
710
+ if (att.blobId) {
711
+ try {
712
+ const loaded = await loadAttachmentBlob(att.blobId);
713
+ blob = loaded?.blob ?? undefined;
714
+ } catch (error) {
715
+ console.warn("[AiChatbot] Failed to hydrate attachment", error);
716
+ }
717
+ }
718
+
719
+ if (!blob) {
720
+ continue;
721
+ }
722
+
723
+ const objectUrl = blob ? createObjectUrl(blob) : null;
724
+
725
+ attachments.push({
726
+ ...att,
727
+ objectUrl: objectUrl ?? undefined,
728
+ blob: blob ?? undefined,
729
+ });
730
+ }
731
+ }
732
+
733
+ hydrated.push({
734
+ ...msg,
735
+ attachments,
736
+ });
737
+ }
738
+
739
+ return hydrated;
740
+ },
741
+ [],
742
+ );
743
+
744
+ const restoreAttachmentsToComposer = useCallback(
745
+ async (attachments?: ChatMessageAttachment[]) => {
746
+ if (!attachments || attachments.length === 0) {
747
+ clearComposerImages();
748
+ return;
749
+ }
750
+
751
+ const restored: ComposerImage[] = [];
752
+
753
+ for (const attachment of attachments) {
754
+ let blob: Blob | undefined = attachment.blob;
755
+ if (!blob && attachment.blobId) {
756
+ try {
757
+ const loaded = await loadAttachmentBlob(attachment.blobId);
758
+ blob = loaded?.blob ?? undefined;
759
+ } catch (error) {
760
+ console.warn("[AiChatbot] Failed to reload attachment", error);
761
+ }
762
+ }
763
+
764
+ if (!blob) continue;
765
+
766
+ const file =
767
+ blob instanceof File
768
+ ? blob
769
+ : new File([blob], attachment.name || "attachment", {
770
+ type:
771
+ attachment.type || blob.type || "application/octet-stream",
772
+ });
773
+
774
+ const objectUrl = createObjectUrl(file);
775
+ if (!objectUrl) continue;
776
+
777
+ restored.push({
778
+ id: createMessageId("composer-image"),
779
+ file,
780
+ objectUrl,
781
+ });
782
+
783
+ if (restored.length >= resolvedMaxImages) break;
784
+ }
785
+
786
+ if (restored.length === 0) {
787
+ clearComposerImages();
788
+ return;
789
+ }
790
+
791
+ setComposerImages((prev) => {
792
+ disposeComposerImageList(prev);
793
+ return restored;
794
+ });
795
+
796
+ if (fileInputRef.current) fileInputRef.current.value = "";
797
+ },
798
+ [clearComposerImages, disposeComposerImageList, resolvedMaxImages],
799
+ );
800
+
801
+ const openAttachmentPreview = useCallback(
802
+ (url?: string | null, title?: string) => {
803
+ if (!url) return;
804
+ setPreviewAttachment({ url, title });
805
+ },
806
+ [],
807
+ );
808
+
809
+ const closeAttachmentPreview = useCallback(() => {
810
+ setPreviewAttachment(null);
811
+ }, []);
812
+
554
813
  const resetConversation = useCallback(() => {
814
+ disposeMessagesAttachments(messagesRef.current);
815
+ clearComposerImages();
555
816
  setMessages([]);
556
817
  setStatusLineError(null);
557
818
  sessionRef.current = null;
@@ -559,7 +820,6 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
559
820
  setStickToBottom(true);
560
821
  setResetDialogOpen(false);
561
822
 
562
- // clear persisted history immediately
563
823
  const storage = getHistoryStorage(historyStorage);
564
824
  if (storage) {
565
825
  try {
@@ -568,7 +828,9 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
568
828
  // ignore
569
829
  }
570
830
  }
571
- }, [historyStorage]);
831
+
832
+ void clearAllAttachments();
833
+ }, [clearComposerImages, historyStorage]);
572
834
 
573
835
  const handleResetClick = useCallback(() => {
574
836
  // Open confirmation dialog
@@ -595,7 +857,8 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
595
857
  try {
596
858
  const activeSessionId =
597
859
  sessionRef.current &&
598
- Date.now() - sessionRef.current.storedAt < TWENTY_FOUR_HOURS_MS
860
+ Date.now() - sessionRef.current.storedAt <
861
+ emptyHistoryAfterDays * TWENTY_FOUR_HOURS_MS
599
862
  ? sessionRef.current.id
600
863
  : undefined;
601
864
  if (!activeSessionId) return;
@@ -655,8 +918,8 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
655
918
  cancelRequestedRef.current = false;
656
919
  setStatusLineError(null);
657
920
  setActiveOp("chat");
658
-
659
- const selectedImages = imagesRef.current;
921
+ const selectedImages = [...composerImagesRef.current];
922
+ const userAttachments = await buildUserAttachments(selectedImages);
660
923
 
661
924
  const userMessageId = createMessageId("user");
662
925
  const userMessageCreatedAt = Date.now();
@@ -666,12 +929,12 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
666
929
  content: trimmed,
667
930
  createdAt: userMessageCreatedAt,
668
931
  clientStatus: "pending",
932
+ attachments: userAttachments.length ? userAttachments : undefined,
669
933
  };
670
934
 
671
935
  // optimistic UI
672
936
  setQuestion("");
673
- setImages([]);
674
- if (fileInputRef.current) fileInputRef.current.value = "";
937
+ clearComposerImages();
675
938
  setMessages((prev) => [...prev, userMessage]);
676
939
 
677
940
  if (!opened) setOpened(true);
@@ -680,7 +943,8 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
680
943
  try {
681
944
  const activeSessionId =
682
945
  sessionRef.current &&
683
- Date.now() - sessionRef.current.storedAt < TWENTY_FOUR_HOURS_MS
946
+ Date.now() - sessionRef.current.storedAt <
947
+ emptyHistoryAfterDays * TWENTY_FOUR_HOURS_MS
684
948
  ? sessionRef.current.id
685
949
  : undefined;
686
950
 
@@ -689,7 +953,7 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
689
953
  {
690
954
  sessionId: activeSessionId,
691
955
  message: trimmed,
692
- images: selectedImages,
956
+ images: selectedImages.map((img) => img.file),
693
957
  },
694
958
  {
695
959
  signal,
@@ -705,7 +969,9 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
705
969
  return;
706
970
  }
707
971
 
708
- if (!res) throw new Error(I18n.get(labels.emptyResponseLabel));
972
+ if (!res) {
973
+ throw new Error(I18n.get(ai.error ?? labels.emptyResponseLabel));
974
+ }
709
975
 
710
976
  if (res.sessionId) {
711
977
  sessionRef.current = {
@@ -771,6 +1037,8 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
771
1037
  }
772
1038
  }, [
773
1039
  ai,
1040
+ buildUserAttachments,
1041
+ clearComposerImages,
774
1042
  opened,
775
1043
  scrollToBottom,
776
1044
  markLastPendingAs,
@@ -830,11 +1098,18 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
830
1098
  });
831
1099
  }, []);
832
1100
 
833
- const handleEditCanceled = useCallback((msg: ChatMessage) => {
834
- setQuestion(msg.content);
835
- setMessages((prev) => prev.filter((m) => m.id !== msg.id));
836
- queueMicrotask(() => questionInputRef.current?.focus());
837
- }, []);
1101
+ const handleEditCanceled = useCallback(
1102
+ (msg: ChatMessage) => {
1103
+ setQuestion(msg.content);
1104
+ void (async () => {
1105
+ await restoreAttachmentsToComposer(msg.attachments);
1106
+ setMessages((prev) => prev.filter((m) => m.id !== msg.id));
1107
+ disposeMessageAttachments(msg.attachments);
1108
+ queueMicrotask(() => questionInputRef.current?.focus());
1109
+ })();
1110
+ },
1111
+ [restoreAttachmentsToComposer],
1112
+ );
838
1113
 
839
1114
  const renderOpenButtonIcon = useMemo(() => {
840
1115
  if (!showOpenButtonIcon) return null;
@@ -1006,83 +1281,118 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
1006
1281
  // -----------------------------
1007
1282
 
1008
1283
  useEffect(() => {
1284
+ let canceled = false;
1009
1285
  const storage = getHistoryStorage(historyStorage);
1010
- if (!storage) return;
1286
+ if (!storage) {
1287
+ if (historyStorage === "nostorage") {
1288
+ void clearAllAttachments();
1289
+ }
1290
+ setHistoryReady(true);
1291
+ return;
1292
+ }
1011
1293
 
1012
- try {
1013
- const raw = storage.getItem(HISTORY_STORAGE_KEY);
1014
- if (!raw) return;
1294
+ (async () => {
1295
+ try {
1296
+ const raw = storage.getItem(HISTORY_STORAGE_KEY);
1297
+ if (!raw) {
1298
+ setHistoryReady(true);
1299
+ return;
1300
+ }
1015
1301
 
1016
- const parsed = JSON.parse(raw) as PersistedChat;
1017
- const last =
1018
- typeof parsed?.lastUserSentAt === "number"
1019
- ? parsed.lastUserSentAt
1020
- : null;
1302
+ const parsed = JSON.parse(raw) as PersistedChat;
1303
+ const last =
1304
+ typeof parsed?.lastUserSentAt === "number"
1305
+ ? parsed.lastUserSentAt
1306
+ : null;
1307
+
1308
+ if (
1309
+ !last ||
1310
+ Date.now() - last > emptyHistoryAfterDays * TWENTY_FOUR_HOURS_MS
1311
+ ) {
1312
+ storage.removeItem(HISTORY_STORAGE_KEY);
1313
+ await clearAllAttachments();
1314
+ setHistoryReady(true);
1315
+ return;
1316
+ }
1021
1317
 
1022
- // Only use storage within 24 hours
1023
- if (!last || Date.now() - last > TWENTY_FOUR_HOURS_MS) {
1024
- storage.removeItem(HISTORY_STORAGE_KEY);
1025
- return;
1026
- }
1318
+ const loadedMessages = Array.isArray(parsed.messages)
1319
+ ? parsed.messages
1320
+ : [];
1321
+
1322
+ const normalized = loadedMessages.map((m) => {
1323
+ if (m?.role === "user" && m.clientStatus === "pending") {
1324
+ return { ...m, clientStatus: "canceled" as const };
1325
+ }
1326
+ return m;
1327
+ });
1027
1328
 
1028
- const loadedMessages = Array.isArray(parsed.messages)
1029
- ? parsed.messages
1030
- : [];
1329
+ const hydrated = await hydratePersistedMessages(normalized);
1031
1330
 
1032
- // Normalize stale "pending" to "canceled" after reload
1033
- const normalized = loadedMessages.map((m) => {
1034
- if (m?.role === "user" && m.clientStatus === "pending") {
1035
- return { ...m, clientStatus: "canceled" as const };
1331
+ if (canceled) {
1332
+ disposeMessagesAttachments(hydrated);
1333
+ return;
1036
1334
  }
1037
- return m;
1038
- });
1039
1335
 
1040
- setMessages(normalized);
1041
- setLastUserSentAt(last);
1336
+ setMessages(hydrated);
1337
+ setLastUserSentAt(last);
1042
1338
 
1043
- if (parsed.session && parsed.session.id) {
1044
- sessionRef.current = parsed.session;
1045
- }
1046
- } catch {
1047
- try {
1048
- storage.removeItem(HISTORY_STORAGE_KEY);
1049
- } catch {
1050
- // ignore
1339
+ if (parsed.session && parsed.session.id) {
1340
+ sessionRef.current = parsed.session;
1341
+ }
1342
+ } catch (error) {
1343
+ console.warn("[AiChatbot] Failed to load history", error);
1344
+ try {
1345
+ storage.removeItem(HISTORY_STORAGE_KEY);
1346
+ } catch {
1347
+ // ignore
1348
+ }
1349
+ } finally {
1350
+ if (!canceled) setHistoryReady(true);
1051
1351
  }
1052
- }
1053
- }, [historyStorage]);
1352
+ })();
1353
+
1354
+ return () => {
1355
+ canceled = true;
1356
+ };
1357
+ }, [historyStorage, emptyHistoryAfterDays, hydratePersistedMessages]);
1054
1358
 
1055
1359
  useEffect(() => {
1360
+ if (!historyReady) return;
1361
+
1056
1362
  const storage = getHistoryStorage(historyStorage);
1057
1363
  if (!storage) return;
1058
1364
 
1059
- if (historyStorage === "nostorage") {
1060
- try {
1061
- storage.removeItem(HISTORY_STORAGE_KEY);
1062
- } catch {
1063
- // ignore
1064
- }
1065
- return;
1066
- }
1067
-
1068
1365
  const last = lastUserSentAtRef.current;
1069
1366
 
1070
- if (!last) return;
1367
+ if (!last) {
1368
+ return;
1369
+ }
1071
1370
 
1072
- if (Date.now() - last > TWENTY_FOUR_HOURS_MS) {
1371
+ if (Date.now() - last > emptyHistoryAfterDays * TWENTY_FOUR_HOURS_MS) {
1073
1372
  try {
1074
1373
  storage.removeItem(HISTORY_STORAGE_KEY);
1075
1374
  } catch {
1076
1375
  // ignore
1077
1376
  }
1377
+ void cleanupDanglingAttachments(new Set());
1078
1378
  return;
1079
1379
  }
1080
1380
 
1381
+ const persistableMessages: PersistedChatMessage[] = messages.map(
1382
+ ({ attachments, ...rest }) => ({
1383
+ ...rest,
1384
+ attachments: attachments?.map(
1385
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
1386
+ ({ objectUrl, blob, ...persisted }) => persisted,
1387
+ ),
1388
+ }),
1389
+ );
1390
+
1081
1391
  const payload: PersistedChat = {
1082
1392
  version: 1,
1083
1393
  lastUserSentAt: last,
1084
1394
  session: sessionRef.current,
1085
- messages,
1395
+ messages: persistableMessages,
1086
1396
  };
1087
1397
 
1088
1398
  try {
@@ -1090,7 +1400,22 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
1090
1400
  } catch {
1091
1401
  // ignore
1092
1402
  }
1093
- }, [messages, lastUserSentAt, historyStorage]);
1403
+
1404
+ const validIds = new Set<string>();
1405
+ persistableMessages.forEach((msg) => {
1406
+ msg.attachments?.forEach((att) => {
1407
+ if (att.blobId) validIds.add(att.blobId);
1408
+ });
1409
+ });
1410
+
1411
+ void cleanupDanglingAttachments(validIds);
1412
+ }, [
1413
+ historyReady,
1414
+ messages,
1415
+ lastUserSentAt,
1416
+ historyStorage,
1417
+ emptyHistoryAfterDays,
1418
+ ]);
1094
1419
 
1095
1420
  if (
1096
1421
  (previewMode && !showChatbotPreview) ||
@@ -1232,6 +1557,43 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
1232
1557
  )}
1233
1558
  </Stack>
1234
1559
 
1560
+ {msg.attachments && msg.attachments.length > 0 && (
1561
+ <Group className="ai-thumbs ai-message-thumbs" gap="xs">
1562
+ {msg.attachments.map((attachment) => (
1563
+ <button
1564
+ key={attachment.id}
1565
+ type="button"
1566
+ className="thumb"
1567
+ style={{
1568
+ backgroundImage: attachment.objectUrl
1569
+ ? `url(${attachment.objectUrl})`
1570
+ : undefined,
1571
+ backgroundSize: "cover",
1572
+ backgroundPosition: "center",
1573
+ backgroundRepeat: "no-repeat",
1574
+ }}
1575
+ onClick={() =>
1576
+ openAttachmentPreview(
1577
+ attachment.objectUrl,
1578
+ attachment.name,
1579
+ )
1580
+ }
1581
+ disabled={!attachment.objectUrl}
1582
+ title={attachment.name || I18n.get("View image")}
1583
+ aria-label={
1584
+ attachment.name || I18n.get("View image")
1585
+ }
1586
+ >
1587
+ {!attachment.objectUrl && (
1588
+ <Text size="xs" c="dimmed">
1589
+ {I18n.get("Loading image...")}
1590
+ </Text>
1591
+ )}
1592
+ </button>
1593
+ ))}
1594
+ </Group>
1595
+ )}
1596
+
1235
1597
  {isLastCanceled && (
1236
1598
  <Group justify="flex-end" gap="xs">
1237
1599
  <Text size="xs" c="dimmed">
@@ -1412,24 +1774,31 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
1412
1774
  </Group>
1413
1775
 
1414
1776
  <Group justify="flex-end">
1415
- <Button
1416
- variant="outline"
1417
- leftSection={<IconPaperclip size={18} />}
1418
- onClick={() => fileInputRef.current?.click()}
1419
- disabled={images.length >= resolvedMaxImages}
1420
- title={I18n.get(labels.addImageLabel)}
1421
- data-ai-kit-add-image-button
1422
- >
1423
- {I18n.get(labels.addLabel)}
1424
- </Button>
1425
- <Input
1426
- ref={fileInputRef}
1427
- type="file"
1428
- accept="image/png,image/jpeg,image/gif,image/webp"
1429
- style={{ display: "none" }}
1430
- multiple
1431
- onChange={onPickImages}
1432
- />
1777
+ {resolvedMaxImages > 0 && (
1778
+ <>
1779
+ <Button
1780
+ variant="outline"
1781
+ leftSection={<IconPaperclip size={18} />}
1782
+ onClick={() => fileInputRef.current?.click()}
1783
+ disabled={
1784
+ composerImages.length >= resolvedMaxImages ||
1785
+ isChatBusy
1786
+ }
1787
+ title={I18n.get(labels.addImageLabel)}
1788
+ data-ai-kit-add-image-button
1789
+ >
1790
+ {I18n.get(labels.addLabel)}
1791
+ </Button>
1792
+ <Input
1793
+ ref={fileInputRef}
1794
+ type="file"
1795
+ accept="image/png,image/jpeg,image/gif,image/webp"
1796
+ style={{ display: "none" }}
1797
+ multiple
1798
+ onChange={onPickImages}
1799
+ />
1800
+ </>
1801
+ )}
1433
1802
 
1434
1803
  {/* Send -> Cancel switch (ChatGPT-like) */}
1435
1804
  <Button
@@ -1444,11 +1813,13 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
1444
1813
  </Group>
1445
1814
  </Group>
1446
1815
 
1447
- {imagePreviews.length > 0 && (
1816
+ {composerPreviews.length > 0 && (
1448
1817
  <Group className="ai-thumbs" mt="xs" gap="xs">
1449
- {imagePreviews.map(({ url }, i) => (
1818
+ {composerPreviews.map(({ url, title }, i) => (
1450
1819
  <div
1451
- key={i}
1820
+ key={composerImages[i]?.id ?? i}
1821
+ role="button"
1822
+ tabIndex={0}
1452
1823
  className="thumb"
1453
1824
  style={{
1454
1825
  backgroundImage: url ? `url(${url})` : undefined,
@@ -1457,10 +1828,21 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
1457
1828
  backgroundRepeat: "no-repeat",
1458
1829
  overflow: "visible",
1459
1830
  }}
1831
+ aria-label={title || I18n.get("View image")}
1832
+ onClick={() => openAttachmentPreview(url, title)}
1833
+ onKeyDown={(evt) => {
1834
+ if (evt.key === "Enter" || evt.key === " ") {
1835
+ evt.preventDefault();
1836
+ openAttachmentPreview(url, title);
1837
+ }
1838
+ }}
1460
1839
  >
1461
1840
  <Button
1462
1841
  variant="white"
1463
- onClick={() => removeImage(i)}
1842
+ onClick={(evt) => {
1843
+ evt.stopPropagation();
1844
+ removeImage(i);
1845
+ }}
1464
1846
  aria-label={I18n.get(labels.removeImageLabel)}
1465
1847
  mt="-xs"
1466
1848
  mr="-xs"
@@ -1480,6 +1862,22 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
1480
1862
  </div>
1481
1863
  </Modal.Root>
1482
1864
  )}
1865
+
1866
+ <Modal
1867
+ opened={!!previewAttachment}
1868
+ onClose={closeAttachmentPreview}
1869
+ centered
1870
+ size="auto"
1871
+ title={previewAttachment?.title || I18n.get("Image preview")}
1872
+ >
1873
+ {previewAttachment && (
1874
+ <img
1875
+ src={previewAttachment.url}
1876
+ alt={previewAttachment.title || I18n.get("Image preview")}
1877
+ style={{ maxWidth: "100%", maxHeight: "70vh" }}
1878
+ />
1879
+ )}
1880
+ </Modal>
1483
1881
  </Group>
1484
1882
  );
1485
1883
  };