feat(dimentorin): halaman Materi + AI Agent RAG

- /mentoring/materi: list materi (kategori chips, cards) + detail konten per slug
- /mentoring/ai-agent: chatbox RAG — tanya materi, jawaban dari Qdrant context + sumber relevansi %
- service: getMaterialsList/getMaterialBySlug/getMaterialById/getMaterialCategories + postChat + hooks
- header: menu Materi & AI Agent
- e2e verified: list 3 materi, detail tampil, chat 'ownership' jawab dari materi Rust + 3 sources (84/64/61%)
This commit is contained in:
asepharyana
2026-08-05 17:26:27 +07:00
parent 49a448fee1
commit 7294040fd1
7 changed files with 459 additions and 1 deletions
@@ -8,6 +8,8 @@ import { Link, NavLink, useLocation } from "react-router-dom";
const MENUS: { label: string; href: string }[] = [
{ label: "Home", href: "/" },
{ label: 'Mentoring', href: '/mentoring' },
{ label: 'Materi', href: '/mentoring/materi' },
{ label: 'AI Agent', href: '/mentoring/ai-agent' },
{ label: 'Sesi Saya', href: '/mentoring/my-sessions' },
{ label: 'Resources', href: '/resources' },
{ label: 'Articles', href: '/articles' },
@@ -0,0 +1,154 @@
import { FC, ReactElement, useRef, useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { SendOutlined } from '@ant-design/icons';
import { usePostChat } from '@imphnen-frontend-service/service';
import type { TChatResponse } from '@imphnen-frontend-service/service';
type Message = {
role: 'user' | 'assistant';
content: string;
sources?: TChatResponse['sources'];
};
export const Components: FC = (): ReactElement => {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const chatMutation = usePostChat();
const inputRef = useRef<HTMLInputElement>(null);
const handleSend = async () => {
const question = input.trim();
if (!question || loading) return;
setInput('');
setMessages((m) => [...m, { role: 'user', content: question }]);
setLoading(true);
try {
const res = await chatMutation.mutateAsync({ question });
setMessages((m) => [
...m,
{ role: 'assistant', content: res.answer, sources: res.sources },
]);
} catch {
setMessages((m) => [
...m,
{
role: 'assistant',
content: 'Maaf, terjadi kesalahan saat menjawab. Coba lagi ya.',
},
]);
} finally {
setLoading(false);
inputRef.current?.focus();
}
};
return (
<main className="px-4 md:px-20 py-10">
<div className="mb-6">
<h1 className="text-3xl font-semibold text-primary-500 mb-2">
AI Agent Mentoring
</h1>
<p className="text-neutral-500">
Tanya apa saja tentang materi mentoring jawaban diambil langsung
dari konten materi yang tersedia (RAG).
</p>
</div>
<div className="bg-white rounded-2xl border border-neutral-100 shadow-sm max-w-3xl mx-auto">
{/* Chat body */}
<div className="h-[420px] overflow-y-auto p-5 space-y-4">
{messages.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center">
<div className="size-14 rounded-full bg-primary-50 flex items-center justify-center mb-3">
<SendOutlined className="text-primary-500 text-xl" />
</div>
<p className="text-sm text-neutral-400 max-w-sm">
Coba tanya: <i>&ldquo;Apa itu ownership di Rust?&rdquo;</i>{' '}
atau <i>&ldquo;Gimana cara buat route di Axum?&rdquo;</i>
</p>
</div>
) : (
messages.map((msg, idx) => (
<div
key={idx}
className={
msg.role === 'user'
? 'flex justify-end'
: 'flex justify-start'
}
>
<div
className={
msg.role === 'user'
? 'bg-primary-500 text-white rounded-2xl rounded-br-sm px-4 py-2.5 max-w-[80%] text-sm whitespace-pre-wrap'
: 'bg-neutral-50 border border-neutral-100 rounded-2xl rounded-bl-sm px-4 py-2.5 max-w-[85%] text-sm text-neutral-700 whitespace-pre-wrap'
}
>
{msg.content}
{msg.sources && msg.sources.length > 0 && (
<div className="mt-3 pt-2 border-t border-neutral-200">
<p className="text-[10px] uppercase tracking-wide text-neutral-400 mb-1">
Sumber materi
</p>
{msg.sources.map((s, i) => (
<div
key={i}
className="flex items-center justify-between gap-2 mb-1"
>
<span className="text-[11px] text-neutral-600 truncate">
📄 {s.title}
</span>
<span className="text-[10px] text-neutral-400 shrink-0">
{Math.round(s.score * 100)}%
</span>
</div>
))}
</div>
)}
</div>
</div>
))
)}
{loading && (
<div className="flex justify-start">
<div className="bg-neutral-50 border border-neutral-100 rounded-2xl rounded-bl-sm px-4 py-2.5 text-sm text-neutral-400">
<span className="inline-flex gap-1">
<span className="animate-bounce"></span>
<span className="animate-bounce [animation-delay:0.1s]">
</span>
<span className="animate-bounce [animation-delay:0.2s]">
</span>
</span>
</div>
</div>
)}
</div>
{/* Input */}
<div className="border-t border-neutral-100 p-4 flex gap-2">
<input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="Tanya tentang materi mentoring..."
className="flex-1 px-4 py-2.5 rounded-xl border border-neutral-200 focus:border-primary-400 focus:outline-none text-sm"
/>
<Button
variant="primary"
className="shrink-0"
onClick={handleSend}
disabled={loading || !input.trim()}
>
<SendOutlined />
</Button>
</div>
</div>
</main>
);
};
export default Components;
@@ -0,0 +1,53 @@
import { FC, ReactElement } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { useGetMaterialBySlug } from '@imphnen-frontend-service/service';
export const Components: FC = (): ReactElement => {
const { slug } = useParams<{ slug: string }>();
const { data: material, isLoading } = useGetMaterialBySlug(slug ?? '');
return (
<main className="px-4 md:px-20 py-10">
<Link to="/mentoring/materi" className="inline-block mb-6">
<Button size="sm" variant="text">
<ArrowLeftOutlined /> Kembali ke Materi
</Button>
</Link>
{isLoading || !material ? (
<div className="animate-pulse bg-neutral-100 h-72 rounded-2xl" />
) : (
<article className="max-w-3xl">
<span className="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium mb-3 bg-neutral-100 text-neutral-600">
{material.category}
</span>
<h1 className="text-3xl font-semibold text-neutral-800 mb-3">
{material.title}
</h1>
<p className="text-neutral-500 mb-6">{material.description}</p>
<div className="bg-white rounded-2xl border border-neutral-100 p-8 shadow-sm">
{/* SAFE: all '<' escaped to &lt; below, no raw HTML passes through */}
<div
className="prose prose-sm max-w-none prose-neutral"
dangerouslySetInnerHTML={{
__html: material.content
.split('\n')
.map((p) => p.trim())
.filter(Boolean)
.map(
(p) =>
`<p class="mb-3 text-neutral-700 leading-relaxed">${p.replace(/</g, '&lt;')}</p>`
)
.join(''),
}}
/>
</div>
</article>
)}
</main>
);
};
export default Components;
@@ -0,0 +1,92 @@
import { FC, ReactElement } from 'react';
import { Link } from 'react-router-dom';
import { Show } from '@imphnen-frontend-service/utils';
import {
useGetMaterialCategories,
useGetMaterialsList,
} from '@imphnen-frontend-service/service';
const CATEGORY_COLORS: Record<string, string> = {
Backend: 'bg-primary-100 text-primary-700',
Frontend: 'bg-info-100 text-info-800',
Data: 'bg-success-100 text-success-700',
Mobile: 'bg-warning-100 text-warning-800',
Cybersecurity: 'bg-danger-100 text-danger-700',
Default: 'bg-neutral-100 text-neutral-600',
};
export const Components: FC = (): ReactElement => {
const { data: materials, isLoading } = useGetMaterialsList();
const { data: categories } = useGetMaterialCategories();
return (
<main className="px-4 md:px-20 py-10">
<div className="mb-8">
<h1 className="text-3xl font-semibold text-primary-500 mb-2">
Materi Mentoring
</h1>
<p className="text-neutral-500">
Kumpulan materi dari para mentor IMPHNEN untuk memperdalam
keterampilan kamu.
</p>
</div>
{/* Category filter chips */}
<Show condition={!!categories?.length}>
<div className="flex flex-wrap gap-2 mb-6">
{categories?.map((cat) => (
<span
key={cat}
className={`px-3 py-1 rounded-full text-xs font-medium ${
CATEGORY_COLORS[cat] ?? CATEGORY_COLORS.Default
}`}
>
{cat}
</span>
))}
</div>
</Show>
{isLoading ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[0, 1, 2].map((i) => (
<div
key={i}
className="animate-pulse bg-neutral-100 h-44 rounded-2xl"
/>
))}
</div>
) : !materials?.data?.length ? (
<p className="text-neutral-400">Belum ada materi tersedia.</p>
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{materials.data.map((m) => {
const color =
CATEGORY_COLORS[m.category] ?? CATEGORY_COLORS.Default;
return (
<Link
key={m.id}
to={`/mentoring/materi/${m.slug}`}
className="group bg-white rounded-2xl border border-neutral-100 p-6 shadow-sm hover:shadow-md transition-shadow"
>
<span
className={`inline-block px-2 py-0.5 rounded-full text-[11px] font-medium mb-3 ${color}`}
>
{m.category}
</span>
<h3 className="font-semibold text-neutral-800 group-hover:text-primary-500 transition-colors leading-snug mb-2">
{m.title}
</h3>
<p className="text-xs text-neutral-500 leading-relaxed line-clamp-2">
{m.description}
</p>
</Link>
);
})}
</div>
)}
</main>
);
};
export default Components;
+49
View File
@@ -3,6 +3,11 @@ import {
TArticleDetail,
TArticleListItem,
TArticleListParams,
TChatRequest,
TChatResponse,
TMaterialDetail,
TMaterialItem,
TMaterialListResponse,
TBookSessionRequest,
TCreatePaymentRequest,
TMentorAvailability,
@@ -214,3 +219,47 @@ export const postRefreshPayment = async (
});
return data.data;
};
// ---- Materials ----
export const getMaterialsList = async (): Promise<TMaterialListResponse> => {
const { data } = await api({ method: 'GET', url: '/dimentorin/materials' });
return data.data;
};
export const getMaterialBySlug = async (
slug: string
): Promise<TMaterialDetail> => {
const { data } = await api({
method: 'GET',
url: `/dimentorin/materials/slug/${slug}`,
});
return data.data;
};
export const getMaterialById = async (
id: string
): Promise<TMaterialDetail> => {
const { data } = await api({ method: 'GET', url: `/dimentorin/materials/${id}` });
return data.data;
};
export const getMaterialCategories = async (): Promise<string[]> => {
const { data } = await api({
method: 'GET',
url: '/dimentorin/materials/categories',
});
return data.data;
};
// ---- AI Agent (RAG) ----
export const postChat = async (
request: TChatRequest
): Promise<TChatResponse> => {
const { data } = await api({
method: 'POST',
url: '/dimentorin/ai/chat',
data: request,
});
return data.data;
};
+62 -1
View File
@@ -12,7 +12,12 @@ import {
getMentorStats,
getMentors,
getMyPayments,
getMaterialById,
getMaterialBySlug,
getMaterialCategories,
getMaterialsList,
getMySessions,
postChat,
getPaymentById,
getSessionPayments,
postBookSession,
@@ -26,7 +31,12 @@ import {
TArticleDetail,
TArticleListItem,
TArticleListParams,
TBookSessionRequest,
TBookSessionRequest,
TChatRequest,
TChatResponse,
TMaterialDetail,
TMaterialItem,
TMaterialListResponse,
TCreatePaymentRequest,
TMentorAvailability,
TMentorDetail,
@@ -245,4 +255,55 @@ export const useGetArticleById = (
});
};
// ---- Materials ----
export const useGetMaterialsList = (): UseQueryResult<
TMaterialListResponse,
TResponseError
> => {
return useQuery({
queryKey: ['get-materials-list'],
queryFn: async () => await getMaterialsList(),
});
};
export const useGetMaterialBySlug = (
slug: string
): UseQueryResult<TMaterialDetail, TResponseError> => {
return useQuery({
queryKey: ['get-material-by-slug', slug],
queryFn: async () => await getMaterialBySlug(slug),
enabled: !!slug,
});
};
export const useGetMaterialById = (
id: string
): UseQueryResult<TMaterialDetail, TResponseError> => {
return useQuery({
queryKey: ['get-material-by-id', id],
queryFn: async () => await getMaterialById(id),
enabled: !!id,
});
};
export const useGetMaterialCategories = (): UseQueryResult<
string[],
TResponseError
> => {
return useQuery({
queryKey: ['get-material-categories'],
queryFn: async () => await getMaterialCategories(),
});
};
// ---- AI Agent ----
export const usePostChat = () => {
return useMutation({
mutationKey: ['post-chat'],
mutationFn: async (request: TChatRequest) => await postChat(request),
});
};
export type { TResponseMessage };
@@ -182,3 +182,50 @@ export type TPayment = {
export type TCreatePaymentRequest = {
method: string; // 'va' | 'qris' | 'manual'
};
export type TMaterialItem = {
id: string;
mentorId: string;
title: string;
slug: string;
category: string;
description: string;
coverUrl?: string | null;
isPublished: boolean;
createdAt: string;
updatedAt: string;
};
export type TMaterialDetail = TMaterialItem & {
content: string;
};
export type TMaterialListResponse = {
data: TMaterialItem[];
meta: {
page: number;
per_page: number;
total?: number;
total_pages?: number;
has_next: boolean;
has_prev: boolean;
};
};
export type TChatRequest = {
question: string;
materialId?: string;
};
export type TChatSource = {
materialId: string;
title: string;
score: number;
snippet: string;
};
export type TChatResponse = {
answer: string;
sources: TChatSource[];
};