// Persistent floating multimodal assistant. Mounted once outside the booking stage container. const EHTClerkAuthControl = window.ClerkAuthControl || (() => null); const FloatingTravelAssistant = () => { const [open, setOpen] = useState(false); const [draft, setDraft] = useState(""); const [messages, setMessages] = useState([{ id: "welcome", role: "assistant", text: "Hi — I can help with travel questions, analyze a document or image, and securely check your flight orders after you sign in." }]); const [attachments, setAttachments] = useState([]); const [sending, setSending] = useState(false); const [error, setError] = useState(""); const [conversationId, setConversationId] = useState(null); const [recording, setRecording] = useState(null); // audio | video const [recordingSeconds, setRecordingSeconds] = useState(0); const launcherRef = useRef(null); const textareaRef = useRef(null); const fileRef = useRef(null); const transcriptRef = useRef(null); const previewRef = useRef(null); const streamRef = useRef(null); const recorderRef = useRef(null); const chunksRef = useRef([]); const timerRef = useRef(null); const recordingModeRef = useRef(null); const sendAbortRef = useRef(null); const attachmentsRef = useRef([]); useEffect(() => { attachmentsRef.current = attachments; }, [attachments]); useEffect(() => { if (recording !== "video" || !previewRef.current || !streamRef.current) return; previewRef.current.srcObject = streamRef.current; previewRef.current.play().catch(() => { setError("The camera started, but its preview could not be displayed. You can stop and attach the recording or cancel it."); }); }, [recording]); const stopTracks = useCallback(() => { if (previewRef.current) previewRef.current.srcObject = null; if (streamRef.current) streamRef.current.getTracks().forEach(track => track.stop()); streamRef.current = null; if (timerRef.current) clearInterval(timerRef.current); timerRef.current = null; recorderRef.current = null; recordingModeRef.current = null; chunksRef.current = []; setRecording(null); setRecordingSeconds(0); }, []); useEffect(() => () => { sendAbortRef.current?.abort(); try { if (recorderRef.current?.state === "recording") recorderRef.current.stop(); } catch {} stopTracks(); attachmentsRef.current.forEach(item => item.url && URL.revokeObjectURL(item.url)); }, [stopTracks]); useEffect(() => { if (open) { setTimeout(() => textareaRef.current?.focus(), 60); } }, [open]); useEffect(() => { if (transcriptRef.current) transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight; }, [messages, sending, open]); useEffect(() => { function onKeyDown(event) { if (event.key === "Escape" && open) { if (recording) cancelRecording(); setOpen(false); setTimeout(() => launcherRef.current?.focus(), 0); } } document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); }, [open, recording]); function attachmentKind(file) { if (file.type.startsWith("image/")) return "image"; if (file.type.startsWith("audio/")) return "audio"; if (file.type.startsWith("video/")) return "video"; return "document"; } function addFiles(fileList) { const incoming = Array.from(fileList || []); if (!incoming.length) return; const currentAttachments = attachmentsRef.current; const room = Math.max(0, 5 - currentAttachments.length); const selected = incoming.slice(0, room); const tooLarge = selected.find(file => file.size > 10 * 1024 * 1024); const total = [...currentAttachments.map(item => item.file), ...selected].reduce((sum, file) => sum + file.size, 0); if (incoming.length > room) setError("You can attach up to five files per message."); else if (tooLarge) setError(`${tooLarge.name} is larger than 10 MiB.`); else if (total > 25 * 1024 * 1024) setError("Combined attachments cannot exceed 25 MiB."); else { setError(""); setAttachments(current => [...current, ...selected.map(file => ({ id: crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}_${Math.random()}`, file, kind: attachmentKind(file), url: URL.createObjectURL(file), }))]); } if (fileRef.current) fileRef.current.value = ""; } function removeAttachment(id) { setAttachments(current => current.filter(item => { if (item.id === id && item.url) URL.revokeObjectURL(item.url); return item.id !== id; })); } function preferredMime(mode) { const candidates = mode === "video" ? ["video/webm;codecs=vp9,opus", "video/webm;codecs=vp8,opus", "video/mp4", "video/webm"] : ["audio/webm;codecs=opus", "audio/mp4", "audio/ogg;codecs=opus", "audio/webm"]; return candidates.find(type => MediaRecorder.isTypeSupported?.(type)) || ""; } function extensionFor(type, mode) { if (type.includes("mp4")) return mode === "video" ? "mp4" : "m4a"; if (type.includes("ogg")) return "ogg"; return "webm"; } function captureVideoFrame() { const video = previewRef.current; if (!video?.videoWidth || !video?.videoHeight) return Promise.resolve(null); const maxWidth = 1280; const scale = Math.min(1, maxWidth / video.videoWidth); const canvas = document.createElement("canvas"); canvas.width = Math.round(video.videoWidth * scale); canvas.height = Math.round(video.videoHeight * scale); canvas.getContext("2d").drawImage(video, 0, 0, canvas.width, canvas.height); return new Promise(resolve => canvas.toBlob(blob => resolve(blob ? new File([blob], `video-frame-${Date.now()}.png`, { type: "image/png" }) : null), "image/png", .9)); } async function startRecording(mode) { setError(""); if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") { setError(`${mode === "video" ? "Camera" : "Voice"} recording is not supported here. You can attach a prerecorded file instead.`); return; } if (!window.isSecureContext) { setError("Camera and microphone recording require HTTPS (localhost is allowed for development)."); return; } try { const stream = await navigator.mediaDevices.getUserMedia(mode === "video" ? { video: { facingMode: "user" }, audio: true } : { audio: true }); streamRef.current = stream; recordingModeRef.current = mode; const mimeType = preferredMime(mode); const recorder = new MediaRecorder(stream, { ...(mimeType ? { mimeType } : {}), ...(mode === "video" ? { videoBitsPerSecond: 700000, audioBitsPerSecond: 64000 } : { audioBitsPerSecond: 64000 }), }); recorderRef.current = recorder; chunksRef.current = []; recorder.ondataavailable = event => { if (event.data?.size) chunksRef.current.push(event.data); }; recorder.onerror = () => { setError("Recording failed. Please try a file upload instead."); stopTracks(); }; recorder.start(500); setRecording(mode); setRecordingSeconds(0); timerRef.current = setInterval(() => { setRecordingSeconds(seconds => { if (seconds >= 59) setTimeout(() => finishRecording(), 0); return seconds + 1; }); }, 1000); } catch (reason) { stopTracks(); setError(reason?.name === "NotAllowedError" ? "Camera or microphone permission was denied. Text and file upload are still available." : "This camera or microphone could not be started."); } } async function finishRecording() { const recorder = recorderRef.current; const mode = recordingModeRef.current; if (timerRef.current) clearInterval(timerRef.current); timerRef.current = null; if (!recorder || recorder.state === "inactive") { stopTracks(); return; } const frame = mode === "video" ? await captureVideoFrame() : null; recorder.onstop = () => { const type = recorder.mimeType || preferredMime(mode) || (mode === "video" ? "video/webm" : "audio/webm"); const blob = new Blob(chunksRef.current, { type }); stopTracks(); if (!blob.size) { setError("No media was captured. Please record again or attach a prerecorded file."); return; } const media = new File([blob], `${mode}-message-${Date.now()}.${extensionFor(type, mode)}`, { type }); addFiles(frame ? [media, frame] : [media]); }; recorder.stop(); } function cancelRecording() { const recorder = recorderRef.current; if (recorder) recorder.onstop = () => stopTracks(); try { if (recorder?.state === "recording") recorder.stop(); else stopTracks(); } catch { stopTracks(); } } async function sendMessage() { const text = draft.trim(); if ((!text && !attachments.length) || sending) return; setError(""); const localAttachments = [...attachments]; const localId = crypto.randomUUID ? crypto.randomUUID() : String(Date.now()); const history = messages.filter(message => message.id !== "welcome" && message.status !== "error") .slice(-10).map(message => ({ role: message.role === "assistant" ? "ASSISTANT" : "USER", text: message.text })); setMessages(current => [...current, { id: localId, role: "user", text: text || "Sent attachments", attachments: localAttachments.map(item => ({ name: item.file.name, kind: item.kind })), status: "sending" }]); setSending(true); const controller = new AbortController(); sendAbortRef.current = controller; try { const uploaded = localAttachments.length ? await window.EHT_API.uploadAssistantAttachments(localAttachments.map(item => item.file), { signal: controller.signal }) : []; const reply = await window.EHT_API.sendAssistantMessage({ conversationId, message: text, history, attachmentIds: uploaded.map(item => item.id), bookingReference: null, }); setConversationId(reply.conversationId); setMessages(current => [ ...current.map(message => message.id === localId ? { ...message, status: "sent" } : message), { id: `${localId}_reply`, role: "assistant", text: reply.message, orders: reply.orders || [] } ]); setDraft(""); localAttachments.forEach(item => item.url && URL.revokeObjectURL(item.url)); setAttachments([]); } catch (reason) { if (reason?.name !== "AbortError") { setError(reason?.message || "The assistant could not send this message."); setMessages(current => current.map(message => message.id === localId ? { ...message, status: "error" } : message)); } } finally { setSending(false); sendAbortRef.current = null; } } function closePanel() { if (recording) cancelRecording(); setOpen(false); setTimeout(() => launcherRef.current?.focus(), 0); } function formatTimer(seconds) { return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; } function formatBytes(size) { return size < 1024 * 1024 ? `${Math.max(1, Math.round(size / 1024))} KB` : `${(size / 1024 / 1024).toFixed(1)} MB`; } return (