wepee's picture
change: integration docs
bac7ea5
Raw
History Blame Contribute Delete
29.9 kB
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>
</>
);
}