@smart-cloud/ai-kit-ui 1.0.3 → 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.
- package/dist/ai-kit-ui.css +693 -36
- package/dist/index.cjs +25 -8
- package/dist/index.d.cts +16 -5
- package/dist/index.d.ts +16 -5
- package/dist/index.js +25 -8
- package/package.json +2 -2
- package/src/ai-chatbot/AiChatbot.tsx +1398 -0
- package/src/ai-chatbot/index.tsx +1 -0
- package/src/ai-feature/AiFeature.tsx +61 -11
- package/src/i18n/ar.ts +45 -1
- package/src/i18n/de.ts +173 -128
- package/src/i18n/en.ts +46 -1
- package/src/i18n/es.ts +45 -1
- package/src/i18n/fr.ts +45 -1
- package/src/i18n/he.ts +45 -1
- package/src/i18n/hi.ts +45 -1
- package/src/i18n/hu.ts +52 -8
- package/src/i18n/id.ts +45 -1
- package/src/i18n/it.ts +45 -1
- package/src/i18n/ja.ts +45 -1
- package/src/i18n/ko.ts +45 -1
- package/src/i18n/nb.ts +46 -1
- package/src/i18n/nl.ts +46 -1
- package/src/i18n/pl.ts +45 -1
- package/src/i18n/pt.ts +45 -1
- package/src/i18n/ru.ts +45 -1
- package/src/i18n/sv.ts +45 -1
- package/src/i18n/th.ts +45 -1
- package/src/i18n/tr.ts +45 -1
- package/src/i18n/ua.ts +45 -1
- package/src/i18n/zh.ts +44 -1
- package/src/index.tsx +1 -0
- package/src/styles/ai-kit-ui.css +693 -36
|
@@ -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
|
+
});
|