Spaces:
Sleeping
Sleeping
| import { Head } from '@inertiajs/react'; | |
| import { | |
| BookOpen, | |
| Bot, | |
| MessageSquare, | |
| Plus, | |
| Send, | |
| Sparkles, | |
| Trash2, | |
| UserRound, | |
| X, | |
| } from 'lucide-react'; | |
| import type { CSSProperties, FormEvent, KeyboardEvent } from 'react'; | |
| import { useEffect, useMemo, useRef, useState } from 'react'; | |
| import { LoadingIndicator } from '@/components/ui/loading-indicator'; | |
| import { Spinner } from '@/components/ui/spinner'; | |
| import type { | |
| ChatMessageResponse, | |
| ChatSessionResponse, | |
| CourseResponse, | |
| } from '@/lib/rag'; | |
| import { createLocalUserMessage } from '@/lib/rag'; | |
| import type { AuthUser } from '@/lib/rag-client'; | |
| import { | |
| createChatSessionEmbed, | |
| deleteChatSessionEmbed, | |
| getChatHistoryEmbed, | |
| listChatSessionsEmbed, | |
| listCoursesEmbed, | |
| loginWithEmbedApiKey, | |
| sendRestChatMessage, | |
| } from '@/lib/rag-client'; | |
| type EmbedConfig = { | |
| background: string; | |
| backgroundProfile: string; | |
| border: string; | |
| courseId?: string | null; | |
| danger: string; | |
| primary: string; | |
| primaryGradient: string; | |
| radius: string; | |
| subtitle: string; | |
| textPrimary: string; | |
| textSecondary: string; | |
| title: string; | |
| userName: string; | |
| }; | |
| const COLOR_DEFAULTS = { | |
| background: '#ffffff', | |
| backgroundProfile: '#ffffff', | |
| border: '#739af1', | |
| danger: '#ef4444', | |
| primary: '#2764eb', | |
| primaryGradient: '#ccdcff', | |
| textPrimary: '#0a0a0a', | |
| textSecondary: '#737373', | |
| } as const; | |
| const EMBED_TOKEN_KEY = 'sevima_raghub_embed_token'; | |
| const EMBED_USER_KEY = 'sevima_raghub_embed_user'; | |
| type EmbedTokenResult = { | |
| token: string; | |
| user?: AuthUser; | |
| }; | |
| function parseColor(value: string | null, fallback: string): string { | |
| if (!value) { | |
| return fallback; | |
| } | |
| const normalized = value.startsWith('#') ? value : `#${value}`; | |
| return /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(normalized) | |
| ? normalized | |
| : fallback; | |
| } | |
| function parseRadius(value: string | null): string { | |
| if (!value) { | |
| return '20px'; | |
| } | |
| return /^\d{1,2}(?:px|rem)$/.test(value) ? value : '20px'; | |
| } | |
| function parseEmbedConfig(params: URLSearchParams): EmbedConfig { | |
| return { | |
| background: parseColor(params.get('background'), COLOR_DEFAULTS.background), | |
| backgroundProfile: parseColor(params.get('background_profile'), COLOR_DEFAULTS.backgroundProfile), | |
| border: parseColor(params.get('border'), COLOR_DEFAULTS.border), | |
| courseId: params.get('course_id') ?? params.get('courseId') ?? undefined, | |
| danger: parseColor(params.get('danger'), COLOR_DEFAULTS.danger), | |
| primary: parseColor(params.get('primary'), COLOR_DEFAULTS.primary), | |
| primaryGradient: parseColor(params.get('primary_gradient'), COLOR_DEFAULTS.primaryGradient), | |
| radius: parseRadius(params.get('radius')), | |
| subtitle: params.get('subtitle') ?? 'Pilih mata kuliah, lalu kirim pertanyaan pertama untuk membuat sesi baru', | |
| textPrimary: parseColor(params.get('text_primary'), COLOR_DEFAULTS.textPrimary), | |
| textSecondary: parseColor(params.get('text_secondary'), COLOR_DEFAULTS.textSecondary), | |
| title: params.get('title') ?? 'RAG Hub', | |
| userName: params.get('user_name') ?? 'User', | |
| }; | |
| } | |
| const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days | |
| function setEmbedCookie(name: string, value: string): void { | |
| const secure = window.location.protocol === 'https:'; | |
| const parts = [ | |
| `${name}=${encodeURIComponent(value)}`, | |
| 'path=/', | |
| `max-age=${COOKIE_MAX_AGE}`, | |
| 'SameSite=None', | |
| ]; | |
| if (secure) { | |
| parts.push('Secure'); | |
| } | |
| document.cookie = parts.join('; '); | |
| } | |
| function getEmbedCookie(name: string): string | null { | |
| const match = document.cookie.match( | |
| new RegExp(`(?:^|;\\s*)${name}=([^;]*)`), | |
| ); | |
| return match ? decodeURIComponent(match[1]) : null; | |
| } | |
| function getStoredEmbedToken(): string | null { | |
| return getEmbedCookie(EMBED_TOKEN_KEY); | |
| } | |
| function getStoredEmbedUser(): AuthUser | null { | |
| try { | |
| const raw = getEmbedCookie(EMBED_USER_KEY); | |
| return raw ? (JSON.parse(raw) as AuthUser) : null; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| function storeEmbedSession(token: string, user?: AuthUser): void { | |
| setEmbedCookie(EMBED_TOKEN_KEY, token); | |
| if (user) { | |
| setEmbedCookie(EMBED_USER_KEY, JSON.stringify(user)); | |
| } | |
| } | |
| async function resolveEmbedToken( | |
| params: URLSearchParams, | |
| ): Promise<EmbedTokenResult | null> { | |
| const stored = getStoredEmbedToken(); | |
| if (stored) { | |
| return { token: stored, user: getStoredEmbedUser() ?? undefined }; | |
| } | |
| const apiKey = params.get('api_key'); | |
| if (apiKey) { | |
| try { | |
| const { token, user } = await loginWithEmbedApiKey(apiKey); | |
| storeEmbedSession(token, user); | |
| return { token, user }; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| return null; | |
| } | |
| function appendUniqueMessages( | |
| current: ChatMessageResponse[], | |
| next: ChatMessageResponse[], | |
| ): ChatMessageResponse[] { | |
| const ids = new Set(current.map((m) => m.uuid_id)); | |
| return [...current, ...next.filter((m) => !ids.has(m.uuid_id))]; | |
| } | |
| function isUserMessage(role: string): boolean { | |
| return ['human', 'student', 'user'].includes(role.toLowerCase()); | |
| } | |
| function formatMessageTime(value?: string): string { | |
| if (!value) { | |
| return ''; | |
| } | |
| const date = new Date(value); | |
| if (Number.isNaN(date.getTime())) { | |
| return value; | |
| } | |
| return new Intl.DateTimeFormat('en-US', { | |
| hour: 'numeric', | |
| minute: '2-digit', | |
| }).format(date); | |
| } | |
| function TypingText({ text }: { text: string }) { | |
| const [visibleLength, setVisibleLength] = useState(0); | |
| useEffect(() => { | |
| const intervalId = window.setInterval(() => { | |
| setVisibleLength((currentLength) => { | |
| if (currentLength >= text.length) { | |
| window.clearInterval(intervalId); | |
| return currentLength; | |
| } | |
| return currentLength + 1; | |
| }); | |
| }, 14); | |
| return () => window.clearInterval(intervalId); | |
| }, [text]); | |
| return ( | |
| <> | |
| {text.slice(0, visibleLength)} | |
| {visibleLength < text.length && ( | |
| <span className="embed-message-caret" aria-hidden /> | |
| )} | |
| </> | |
| ); | |
| } | |
| function EmbedMessage({ | |
| message, | |
| shouldAnimateTyping, | |
| }: { | |
| message: ChatMessageResponse; | |
| shouldAnimateTyping?: boolean; | |
| }) { | |
| const isUser = isUserMessage(message.role); | |
| return ( | |
| <article | |
| className={ | |
| isUser | |
| ? 'embed-message embed-message--user' | |
| : 'embed-message embed-message--assistant' | |
| } | |
| > | |
| {!isUser && ( | |
| <span className="embed-message-avatar embed-message-avatar--assistant"> | |
| <Bot className="size-4" /> | |
| </span> | |
| )} | |
| <div className="embed-message-bubble"> | |
| <p className="embed-message-content"> | |
| {!isUser && shouldAnimateTyping ? ( | |
| <TypingText | |
| key={message.uuid_id} | |
| text={message.content} | |
| /> | |
| ) : ( | |
| message.content | |
| )} | |
| </p> | |
| <span className="embed-message-time"> | |
| {formatMessageTime(message.created_at)} | |
| </span> | |
| </div> | |
| {isUser && ( | |
| <span className="embed-message-avatar embed-message-avatar--user"> | |
| <UserRound className="size-4" /> | |
| </span> | |
| )} | |
| </article> | |
| ); | |
| } | |
| function EmbedComposer({ | |
| courses, | |
| isProcessing, | |
| onCourseChange, | |
| onQuestionChange, | |
| onSubmit, | |
| question, | |
| selectedCourseId, | |
| }: { | |
| courses: CourseResponse[]; | |
| isProcessing: boolean; | |
| onCourseChange: (courseId: string) => void; | |
| onQuestionChange: (question: string) => void; | |
| onSubmit: (event: FormEvent<HTMLFormElement>) => void; | |
| question: string; | |
| selectedCourseId: string; | |
| }) { | |
| const canSubmit = | |
| !isProcessing && | |
| selectedCourseId.length > 0 && | |
| question.trim().length > 0; | |
| function handleQuestionKeyDown( | |
| event: KeyboardEvent<HTMLTextAreaElement>, | |
| ): void { | |
| if ( | |
| event.key !== 'Enter' || | |
| event.shiftKey || | |
| event.nativeEvent.isComposing | |
| ) { | |
| return; | |
| } | |
| event.preventDefault(); | |
| if (canSubmit) { | |
| event.currentTarget.form?.requestSubmit(); | |
| } | |
| } | |
| return ( | |
| <form className="embed-composer" onSubmit={onSubmit}> | |
| <div className="embed-course-row"> | |
| <BookOpen className="size-4" /> | |
| <span>Mata Kuliah:</span> | |
| <select | |
| aria-label="Mata kuliah" | |
| disabled={courses.length === 0} | |
| onChange={(event) => onCourseChange(event.target.value)} | |
| value={selectedCourseId} | |
| > | |
| <option value="" disabled> | |
| Pilih mata kuliah | |
| </option> | |
| {courses.map((course) => ( | |
| <option key={course.id} value={String(course.id)}> | |
| {course.title} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| <div className="embed-composer-divider" /> | |
| <div className="embed-question-row"> | |
| <Sparkles className="size-4" /> | |
| <textarea | |
| aria-label="Pertanyaan" | |
| onChange={(event) => onQuestionChange(event.target.value)} | |
| onKeyDown={handleQuestionKeyDown} | |
| placeholder="Tulis pertanyaanmu disini..." | |
| value={question} | |
| /> | |
| </div> | |
| <button | |
| aria-label="Kirim pertanyaan" | |
| className="embed-send-button" | |
| disabled={!canSubmit} | |
| type="submit" | |
| > | |
| {isProcessing ? ( | |
| <Spinner className="size-4" /> | |
| ) : ( | |
| <Send className="size-4" /> | |
| )} | |
| </button> | |
| </form> | |
| ); | |
| } | |
| function EmbedConfirmModal({ | |
| message, | |
| onCancel, | |
| onConfirm, | |
| }: { | |
| message: string; | |
| onCancel: () => void; | |
| onConfirm: () => void; | |
| }) { | |
| return ( | |
| <div className="embed-modal-backdrop" onClick={onCancel} role="dialog"> | |
| <div | |
| className="embed-modal" | |
| onClick={(e) => e.stopPropagation()} | |
| > | |
| <p className="embed-modal-title">Konfirmasi</p> | |
| <p className="embed-modal-body">{message}</p> | |
| <div className="embed-modal-actions"> | |
| <button | |
| className="embed-modal-btn embed-modal-btn--cancel" | |
| onClick={onCancel} | |
| type="button" | |
| > | |
| Batal | |
| </button> | |
| <button | |
| className="embed-modal-btn embed-modal-btn--danger" | |
| onClick={onConfirm} | |
| type="button" | |
| > | |
| Hapus | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function EmbedHistory({ | |
| activeSessionId, | |
| chatSessions, | |
| isProcessing, | |
| onDeleteSession, | |
| onOpenSession, | |
| }: { | |
| activeSessionId?: string | null; | |
| chatSessions: ChatSessionResponse[]; | |
| isProcessing: boolean; | |
| onDeleteSession: (sessionId: string) => void; | |
| onOpenSession: (sessionId: string) => void; | |
| }) { | |
| return ( | |
| <section className="embed-history"> | |
| {chatSessions.length === 0 ? ( | |
| <p className="embed-empty-copy">Belum ada riwayat chat.</p> | |
| ) : ( | |
| chatSessions.map((session) => ( | |
| <button | |
| className="embed-history-item" | |
| data-active={session.uuid_id === activeSessionId} | |
| key={session.uuid_id} | |
| onClick={() => onOpenSession(session.uuid_id)} | |
| type="button" | |
| > | |
| <span> | |
| <strong>{session.title}</strong> | |
| <small> | |
| {formatMessageTime( | |
| session.last_message_at ?? | |
| session.updated_at ?? | |
| session.created_at, | |
| )} | |
| </small> | |
| </span> | |
| <span | |
| aria-disabled={isProcessing} | |
| aria-label="Hapus sesi" | |
| className="embed-history-delete" | |
| onClick={(event) => { | |
| event.stopPropagation(); | |
| onDeleteSession(session.uuid_id); | |
| }} | |
| role="button" | |
| tabIndex={0} | |
| > | |
| <Trash2 className="size-4" /> | |
| </span> | |
| </button> | |
| )) | |
| )} | |
| </section> | |
| ); | |
| } | |
| export default function Embed() { | |
| const [config, setConfig] = useState<EmbedConfig>(() => | |
| parseEmbedConfig( | |
| typeof window !== 'undefined' | |
| ? new URLSearchParams(window.location.search) | |
| : new URLSearchParams(), | |
| ), | |
| ); | |
| const [token, setToken] = useState<string | null>(null); | |
| const [isAuthorized, setIsAuthorized] = useState(false); | |
| const [courses, setCourses] = useState<CourseResponse[]>([]); | |
| const [chatSessions, setChatSessions] = useState<ChatSessionResponse[]>([]); | |
| const [sessionId, setSessionId] = useState<string | null>(null); | |
| const [messages, setMessages] = useState<ChatMessageResponse[]>([]); | |
| const [optimisticMessages, setOptimisticMessages] = useState< | |
| ChatMessageResponse[] | |
| >([]); | |
| const [selectedCourseId, setSelectedCourseId] = useState( | |
| config.courseId ?? '', | |
| ); | |
| const [question, setQuestion] = useState(''); | |
| const [isProcessing, setIsProcessing] = useState(false); | |
| const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const [isLoadingHistory, setIsLoadingHistory] = useState(false); | |
| const [backendMessage, setBackendMessage] = useState<string | undefined>(); | |
| const [activeTab, setActiveTab] = useState<'chat' | 'history'>('chat'); | |
| const [animatedAssistantMessageId, setAnimatedAssistantMessageId] = | |
| useState<string | undefined>(); | |
| const messagesEndRef = useRef<HTMLDivElement>(null); | |
| const allMessages = useMemo( | |
| () => appendUniqueMessages(messages, optimisticMessages), | |
| [messages, optimisticMessages], | |
| ); | |
| const rootStyle = { | |
| '--embed-bg': config.background, | |
| '--embed-border': config.border, | |
| '--embed-danger': config.danger, | |
| '--embed-muted': config.textSecondary, | |
| '--embed-primary': config.primary, | |
| '--embed-primary-soft': config.primaryGradient, | |
| '--embed-radius': config.radius, | |
| '--embed-surface': config.backgroundProfile, | |
| '--embed-text': config.textPrimary, | |
| } as CSSProperties; | |
| useEffect(() => { | |
| const params = new URLSearchParams(window.location.search); | |
| const parsedConfig = parseEmbedConfig(params); | |
| const parsedSessionId = params.get('session_id'); | |
| setConfig(parsedConfig); | |
| setSelectedCourseId(parsedConfig.courseId ?? ''); | |
| void (async () => { | |
| try { | |
| const resolvedToken = await resolveEmbedToken(params); | |
| if (!resolvedToken) { | |
| setBackendMessage('Token iframe tidak tersedia.'); | |
| return; | |
| } | |
| const { token, user } = resolvedToken; | |
| setToken(token); | |
| setIsAuthorized(true); | |
| if (user?.name && !params.get('user_name')) { | |
| setConfig((prev: EmbedConfig) => ({ ...prev, userName: user.name as string })); | |
| } | |
| const [coursesResult, sessionsResult] = await Promise.all([ | |
| listCoursesEmbed(token), | |
| listChatSessionsEmbed( | |
| token, | |
| parsedConfig.courseId ?? undefined, | |
| ), | |
| ]); | |
| const loadedCourses = coursesResult.data; | |
| setCourses(loadedCourses); | |
| setChatSessions(sessionsResult.data); | |
| if (!parsedConfig.courseId && loadedCourses[0] !== undefined) { | |
| setSelectedCourseId(String(loadedCourses[0].id)); | |
| } | |
| if (parsedSessionId) { | |
| setSessionId(parsedSessionId); | |
| const historyResult = await getChatHistoryEmbed( | |
| parsedSessionId, | |
| token, | |
| ); | |
| setMessages(historyResult.data); | |
| } | |
| } catch (err) { | |
| setBackendMessage( | |
| err instanceof Error ? err.message : 'Gagal memuat data.', | |
| ); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| })(); | |
| }, []); | |
| useEffect(() => { | |
| if (typeof window === 'undefined') { | |
| return; | |
| } | |
| const params = new URLSearchParams(window.location.search); | |
| if (sessionId) { | |
| params.set('session_id', sessionId); | |
| } else { | |
| params.delete('session_id'); | |
| } | |
| history.replaceState(null, '', `${window.location.pathname}?${params.toString()}`); | |
| }, [sessionId]); | |
| useEffect(() => { | |
| const frame = window.requestAnimationFrame(() => { | |
| messagesEndRef.current?.scrollIntoView({ block: 'end' }); | |
| }); | |
| return () => window.cancelAnimationFrame(frame); | |
| }, [allMessages, isProcessing]); | |
| async function handleQuestionSubmit( | |
| event: FormEvent<HTMLFormElement>, | |
| ): Promise<void> { | |
| event.preventDefault(); | |
| if (!token) { | |
| return; | |
| } | |
| const trimmedQuestion = question.trim(); | |
| if (!selectedCourseId || !trimmedQuestion) { | |
| return; | |
| } | |
| setBackendMessage(undefined); | |
| setIsProcessing(true); | |
| const userMessage = createLocalUserMessage(trimmedQuestion); | |
| setOptimisticMessages((prev) => [...prev, userMessage]); | |
| setQuestion(''); | |
| try { | |
| let currentSessionId = sessionId; | |
| if (!currentSessionId) { | |
| const session = await createChatSessionEmbed( | |
| { course_id: selectedCourseId, title: trimmedQuestion }, | |
| token, | |
| ); | |
| currentSessionId = session.uuid_id; | |
| setSessionId(currentSessionId); | |
| setChatSessions((prev) => [session, ...prev]); | |
| } | |
| const assistantMessage = await sendRestChatMessage( | |
| currentSessionId, | |
| trimmedQuestion, | |
| token, | |
| ); | |
| setAnimatedAssistantMessageId(assistantMessage.uuid_id); | |
| setMessages((prev) => [...prev, userMessage, assistantMessage]); | |
| setOptimisticMessages([]); | |
| } catch (err) { | |
| setOptimisticMessages([]); | |
| setQuestion(trimmedQuestion); | |
| setBackendMessage( | |
| err instanceof Error ? err.message : 'Gagal mengirim pesan.', | |
| ); | |
| } finally { | |
| setIsProcessing(false); | |
| } | |
| } | |
| function handleNewSession(): void { | |
| setActiveTab('chat'); | |
| setSessionId(null); | |
| setMessages([]); | |
| setOptimisticMessages([]); | |
| setQuestion(''); | |
| } | |
| async function handleOpenSession(targetSessionId: string): Promise<void> { | |
| if (!token) { | |
| return; | |
| } | |
| setActiveTab('chat'); | |
| setSessionId(targetSessionId); | |
| setMessages([]); | |
| setOptimisticMessages([]); | |
| setIsLoadingHistory(true); | |
| try { | |
| const result = await getChatHistoryEmbed(targetSessionId, token); | |
| setMessages(result.data); | |
| } catch (err) { | |
| setBackendMessage( | |
| err instanceof Error ? err.message : 'Gagal memuat riwayat.', | |
| ); | |
| } finally { | |
| setIsLoadingHistory(false); | |
| } | |
| } | |
| function handleDeleteSession(targetSessionId: string): void { | |
| if (!token) { | |
| return; | |
| } | |
| setPendingDeleteId(targetSessionId); | |
| } | |
| async function handleConfirmDelete(): Promise<void> { | |
| if (!token || !pendingDeleteId) { | |
| return; | |
| } | |
| const targetId = pendingDeleteId; | |
| setPendingDeleteId(null); | |
| try { | |
| await deleteChatSessionEmbed(targetId, token); | |
| setChatSessions((prev) => | |
| prev.filter((s) => s.uuid_id !== targetId), | |
| ); | |
| if (targetId === sessionId) { | |
| handleNewSession(); | |
| } | |
| } catch (err) { | |
| setBackendMessage( | |
| err instanceof Error ? err.message : 'Gagal menghapus sesi.', | |
| ); | |
| } | |
| } | |
| function handleClose(): void { | |
| window.parent?.postMessage({ type: 'sevima-raghub:close' }, '*'); | |
| } | |
| return ( | |
| <> | |
| <Head title={config.title} /> | |
| <main className="embed-shell" style={rootStyle}> | |
| {pendingDeleteId && ( | |
| <EmbedConfirmModal | |
| message="Sesi chat ini akan dihapus permanen." | |
| onCancel={() => setPendingDeleteId(null)} | |
| onConfirm={() => { void handleConfirmDelete(); }} | |
| /> | |
| )} | |
| <header className="embed-toolbar"> | |
| <div className="embed-tabs" role="tablist"> | |
| <button | |
| aria-selected={activeTab === 'chat'} | |
| onClick={() => setActiveTab('chat')} | |
| role="tab" | |
| type="button" | |
| > | |
| Chat | |
| </button> | |
| <button | |
| aria-selected={activeTab === 'history'} | |
| onClick={() => setActiveTab('history')} | |
| role="tab" | |
| type="button" | |
| > | |
| Riwayat | |
| </button> | |
| </div> | |
| <div className="embed-actions"> | |
| <button | |
| aria-label="Sesi baru" | |
| className="embed-icon-button embed-icon-button--primary" | |
| onClick={handleNewSession} | |
| type="button" | |
| > | |
| <Plus className="size-6" /> | |
| </button> | |
| <button | |
| aria-label="Tutup iframe" | |
| className="embed-icon-button" | |
| onClick={handleClose} | |
| type="button" | |
| > | |
| <X className="size-6" /> | |
| </button> | |
| </div> | |
| </header> | |
| {isLoading ? ( | |
| <section className="embed-chat"> | |
| <div className="embed-empty-state"> | |
| <Spinner className="size-6" /> | |
| </div> | |
| </section> | |
| ) : activeTab === 'history' ? ( | |
| <EmbedHistory | |
| activeSessionId={sessionId} | |
| chatSessions={chatSessions} | |
| isProcessing={isProcessing} | |
| onDeleteSession={(id) => { | |
| void handleDeleteSession(id); | |
| }} | |
| onOpenSession={(id) => { | |
| void handleOpenSession(id); | |
| }} | |
| /> | |
| ) : ( | |
| <section | |
| className={ | |
| sessionId | |
| ? 'embed-chat embed-chat--session' | |
| : 'embed-chat' | |
| } | |
| > | |
| {!isAuthorized ? ( | |
| <div className="embed-empty-state"> | |
| <span className="embed-empty-icon"> | |
| <MessageSquare className="size-7" /> | |
| </span> | |
| <h1>{config.title}</h1> | |
| <p>{backendMessage}</p> | |
| </div> | |
| ) : sessionId ? ( | |
| <div className="embed-thread"> | |
| {backendMessage && ( | |
| <p className="embed-status"> | |
| {backendMessage} | |
| </p> | |
| )} | |
| {isLoadingHistory ? ( | |
| <div className="embed-empty-state"> | |
| <Spinner className="size-6" /> | |
| </div> | |
| ) : ( | |
| <> | |
| {allMessages.map((message) => ( | |
| <EmbedMessage | |
| key={message.uuid_id} | |
| message={message} | |
| shouldAnimateTyping={ | |
| message.uuid_id === | |
| animatedAssistantMessageId | |
| } | |
| /> | |
| ))} | |
| {isProcessing && ( | |
| <article className="embed-message embed-message--assistant"> | |
| <span className="embed-message-avatar embed-message-avatar--assistant"> | |
| <Bot className="size-4" /> | |
| </span> | |
| <div className="embed-message-bubble embed-message-bubble--typing"> | |
| <LoadingIndicator | |
| label="Thinking" | |
| showSpinner={false} | |
| /> | |
| </div> | |
| </article> | |
| )} | |
| <div ref={messagesEndRef} /> | |
| </> | |
| )} | |
| </div> | |
| ) : ( | |
| <div className="embed-empty-state"> | |
| <span className="embed-empty-icon"> | |
| <MessageSquare className="size-7" /> | |
| </span> | |
| <h1>Good Morning, {config.userName}</h1> | |
| <p>{config.subtitle}</p> | |
| {backendMessage && ( | |
| <p className="embed-status"> | |
| {backendMessage} | |
| </p> | |
| )} | |
| </div> | |
| )} | |
| {isAuthorized && ( | |
| <div className="embed-composer-dock"> | |
| <EmbedComposer | |
| courses={courses} | |
| isProcessing={isProcessing} | |
| onCourseChange={setSelectedCourseId} | |
| onQuestionChange={setQuestion} | |
| onSubmit={(e) => { | |
| void handleQuestionSubmit(e); | |
| }} | |
| question={question} | |
| selectedCourseId={selectedCourseId} | |
| /> | |
| </div> | |
| )} | |
| </section> | |
| )} | |
| </main> | |
| </> | |
| ); | |
| } | |