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:
@@ -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>“Apa itu ownership di Rust?”</i>{' '}
|
||||
atau <i>“Gimana cara buat route di Axum?”</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 < 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, '<')}</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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user