@smart-cloud/ai-kit-ui 1.1.26 → 1.1.28
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/ai-kit-ui.css +9 -0
- package/dist/index.cjs +9 -9
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +9 -9
- package/package.json +2 -2
- package/src/ai-chatbot/AiChatbot.tsx +518 -120
- package/src/ai-chatbot/attachmentStorage.ts +172 -0
- package/src/styles/ai-kit-ui.css +9 -0
- package/src/useAiRun.ts +15 -4
|
@@ -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:
|
|
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 [
|
|
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
|
-
|
|
289
|
-
}, [
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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 =
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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 ===
|
|
518
|
-
x.size ===
|
|
519
|
-
x.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 <
|
|
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
|
|
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
|
-
|
|
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 <
|
|
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)
|
|
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(
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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)
|
|
1286
|
+
if (!storage) {
|
|
1287
|
+
if (historyStorage === "nostorage") {
|
|
1288
|
+
void clearAllAttachments();
|
|
1289
|
+
}
|
|
1290
|
+
setHistoryReady(true);
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1011
1293
|
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1294
|
+
(async () => {
|
|
1295
|
+
try {
|
|
1296
|
+
const raw = storage.getItem(HISTORY_STORAGE_KEY);
|
|
1297
|
+
if (!raw) {
|
|
1298
|
+
setHistoryReady(true);
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1015
1301
|
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
-
|
|
1029
|
-
? parsed.messages
|
|
1030
|
-
: [];
|
|
1329
|
+
const hydrated = await hydratePersistedMessages(normalized);
|
|
1031
1330
|
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
return { ...m, clientStatus: "canceled" as const };
|
|
1331
|
+
if (canceled) {
|
|
1332
|
+
disposeMessagesAttachments(hydrated);
|
|
1333
|
+
return;
|
|
1036
1334
|
}
|
|
1037
|
-
return m;
|
|
1038
|
-
});
|
|
1039
1335
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1336
|
+
setMessages(hydrated);
|
|
1337
|
+
setLastUserSentAt(last);
|
|
1042
1338
|
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
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
|
-
{
|
|
1816
|
+
{composerPreviews.length > 0 && (
|
|
1448
1817
|
<Group className="ai-thumbs" mt="xs" gap="xs">
|
|
1449
|
-
{
|
|
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={() =>
|
|
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
|
};
|