diff --git a/scripts/add-materi-documents.sql b/scripts/add-materi-documents.sql deleted file mode 100644 index 19b99ea..0000000 --- a/scripts/add-materi-documents.sql +++ /dev/null @@ -1,29 +0,0 @@ --- Migration: Add materi_documents table for learning materials + RAG --- Run: PGPASSWORD= psql -h -U -d -f scripts/add-materi-documents.sql --- Schema mirrors services/backend/src/shared/database/schema.ts (pgMateriDocumentsTable). - -BEGIN; - -CREATE TABLE IF NOT EXISTS public.materi_documents ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - title text NOT NULL, - description text, - content text NOT NULL, - category text NOT NULL DEFAULT 'general', - tags jsonb NOT NULL DEFAULT '[]', - owner_user_id text NOT NULL DEFAULT 'anonymous', - guild_id text, - channel_id text, - is_public boolean NOT NULL DEFAULT true, - view_count integer NOT NULL DEFAULT 0, - created_at bigint NOT NULL DEFAULT (EXTRACT(epoch FROM now())::bigint * 1000), - updated_at bigint NOT NULL DEFAULT (EXTRACT(epoch FROM now())::bigint * 1000) -); - --- Indexes mirror the Drizzle index definitions (idx_materi_category, idx_materi_owner, idx_materi_guild, idx_materi_search). -CREATE INDEX IF NOT EXISTS idx_materi_category ON public.materi_documents (category); -CREATE INDEX IF NOT EXISTS idx_materi_owner ON public.materi_documents (owner_user_id); -CREATE INDEX IF NOT EXISTS idx_materi_guild ON public.materi_documents (guild_id); -CREATE INDEX IF NOT EXISTS idx_materi_search ON public.materi_documents (title, category); - -COMMIT; diff --git a/scripts/drop-materi-documents.sql b/scripts/drop-materi-documents.sql new file mode 100644 index 0000000..75ea9a1 --- /dev/null +++ b/scripts/drop-materi-documents.sql @@ -0,0 +1,14 @@ +-- Migration: Drop materi_documents table (feature removed) +-- Run: PGPASSWORD= psql -h -U -d -f scripts/drop-materi-documents.sql +-- Reverses scripts/add-materi-documents.sql which was deleted with the feature. + +BEGIN; + +DROP INDEX IF EXISTS idx_materi_search; +DROP INDEX IF EXISTS idx_materi_guild; +DROP INDEX IF EXISTS idx_materi_owner; +DROP INDEX IF EXISTS idx_materi_category; + +DROP TABLE IF EXISTS public.materi_documents; + +COMMIT; diff --git a/services/backend/src/modules/materi/index.ts b/services/backend/src/modules/materi/index.ts deleted file mode 100644 index d2a541b..0000000 --- a/services/backend/src/modules/materi/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -export { materiRepository } from "./materi.repository.js"; -export { - type CreateMateriInput, - createMateriSchema, - type MateriQueryInput, - type MateriRagChatInput, - materiQuerySchema, - materiRagChatSchema, - type UpdateMateriInput, - updateMateriSchema, -} from "./materi.schema.js"; -export { MateriService, materiService } from "./materi.service.js"; -export { - type MateriSearchHit, - type RAGChatResult, - ragChat, - searchMateri, -} from "./ragClient.js"; diff --git a/services/backend/src/modules/materi/materi.repository.ts b/services/backend/src/modules/materi/materi.repository.ts deleted file mode 100644 index 785598e..0000000 --- a/services/backend/src/modules/materi/materi.repository.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; -import { getDatabase } from "@/shared/database/index.js"; -import { - type MateriDocument, - materiDocumentsTable, -} from "@/shared/database/schema.js"; -import type { MateriQueryInput } from "./materi.schema.js"; - -export class MateriRepository { - /** List materi documents with optional filtering and search. */ - async list(input: MateriQueryInput): Promise { - const db = getDatabase(); - - const conditions = []; - - // Text search across title and content - if (input.search) { - const term = `%${input.search}%`; - conditions.push( - or( - ilike(materiDocumentsTable.title, term), - ilike(materiDocumentsTable.content, term), - ), - ); - } - - // Category filter - if (input.category) { - conditions.push(eq(materiDocumentsTable.category, input.category)); - } - - // Owner filter - if (input.ownerId) { - conditions.push(eq(materiDocumentsTable.owner_user_id, input.ownerId)); - } - - // Only public (if requested) - if (input.onlyPublic) { - conditions.push(eq(materiDocumentsTable.is_public, true)); - } - - const whereClause = conditions.length > 0 ? and(...conditions) : undefined; - - const result = await db - .select() - .from(materiDocumentsTable) - .where(whereClause) - .orderBy(desc(materiDocumentsTable.created_at)) - .limit(input.limit); - - return result; - } - - /** Get a single materi by id. */ - async byId(id: string): Promise { - const db = getDatabase(); - const result = await db - .select() - .from(materiDocumentsTable) - .where(eq(materiDocumentsTable.id, id)) - .limit(1); - return result[0] ?? null; - } - - /** Create a new materi document. */ - async create(data: { - title: string; - description?: string | null; - content: string; - category: string; - tags: string[]; - ownerUserId: string; - guildId?: string | null; - channelId?: string | null; - isPublic: boolean; - }): Promise { - const db = getDatabase(); - const now = Date.now(); - const result = await db - .insert(materiDocumentsTable) - .values({ - title: data.title, - description: data.description ?? null, - content: data.content, - category: data.category, - tags: data.tags, - owner_user_id: data.ownerUserId, - guild_id: data.guildId ?? null, - channel_id: data.channelId ?? null, - is_public: data.isPublic, - view_count: 0, - created_at: now, - updated_at: now, - }) - .returning(); - return result[0]!; - } - - /** Update an existing materi. */ - async update( - id: string, - data: Partial<{ - title: string; - description?: string | null; - content: string; - category: string; - tags: string[]; - isPublic: boolean; - }>, - ): Promise { - const db = getDatabase(); - if (Object.keys(data).length === 0) return this.byId(id); - - const result = await db - .update(materiDocumentsTable) - .set({ - ...data, - updated_at: Date.now(), - }) - .where(eq(materiDocumentsTable.id, id)) - .returning(); - return result[0] ?? null; - } - - /** Delete a materi. */ - async delete(id: string): Promise { - const db = getDatabase(); - const result = await db - .delete(materiDocumentsTable) - .where(eq(materiDocumentsTable.id, id)) - .returning({ deletedId: materiDocumentsTable.id }); - return result.length > 0; - } - - /** Increment view count (for analytics). */ - async incrementViews(id: string): Promise { - const db = getDatabase(); - await db - .update(materiDocumentsTable) - .set({ - view_count: sql`${materiDocumentsTable.view_count} + 1`, - }) - .where(eq(materiDocumentsTable.id, id)); - } -} - -export const materiRepository = new MateriRepository(); diff --git a/services/backend/src/modules/materi/materi.schema.ts b/services/backend/src/modules/materi/materi.schema.ts deleted file mode 100644 index 51915b0..0000000 --- a/services/backend/src/modules/materi/materi.schema.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { z } from "zod"; - -// ─── Input schemas ──────────────────────────────────────────────── - -export const createMateriSchema = z.object({ - title: z.string().min(1, "Title is required").max(200), - description: z.string().max(2000).optional(), - content: z.string().min(1, "Content is required"), - category: z.string().max(100).default("general"), - tags: z.array(z.string().max(50)).max(20).default([]), - guildId: z.string().optional(), - channelId: z.string().optional(), - isPublic: z.boolean().default(true), -}); - -export const updateMateriSchema = createMateriSchema.partial(); - -export const materiQuerySchema = z.object({ - limit: z.coerce.number().int().positive().default(20), - search: z.string().optional(), - category: z.string().optional(), - ownerId: z.string().optional(), - onlyPublic: z.boolean().default(false), -}); - -export const materiRagChatSchema = z.object({ - message: z.string().min(1, "Message is required"), - materiId: z.string().optional(), - history: z - .array( - z.object({ - role: z.enum(["user", "assistant"]), - content: z.string(), - }), - ) - .max(20) - .default([]), -}); - -export type CreateMateriInput = z.infer; -export type UpdateMateriInput = z.infer; -export type MateriQueryInput = z.infer; -export type MateriRagChatInput = z.infer; diff --git a/services/backend/src/modules/materi/materi.service.ts b/services/backend/src/modules/materi/materi.service.ts deleted file mode 100644 index 84d0dfe..0000000 --- a/services/backend/src/modules/materi/materi.service.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { MateriDocument } from "@/shared/database/schema.js"; -import { createChildLogger } from "@/shared/logger/index.js"; -import { materiRepository } from "./materi.repository.js"; -import type { - CreateMateriInput, - MateriQueryInput, - MateriRagChatInput, - UpdateMateriInput, -} from "./materi.schema.js"; -import { ragChat } from "./ragClient.js"; - -const logger = createChildLogger("materi.service"); - -export class MateriService { - /** List materi documents with optional filtering. */ - async list(input: MateriQueryInput): Promise { - logger.debug( - { limit: input.limit, search: input.search }, - "Listing materi", - ); - return materiRepository.list(input); - } - - /** Get a single materi document by ID, incrementing view count. */ - async byId(id: string): Promise { - const doc = await materiRepository.byId(id); - if (doc) { - void materiRepository.incrementViews(id); - } - return doc; - } - - /** Create a new materi document. */ - async create( - input: CreateMateriInput, - ownerUserId: string, - ): Promise { - logger.info({ title: input.title, ownerUserId }, "Creating materi"); - return materiRepository.create({ - title: input.title, - description: input.description, - content: input.content, - category: input.category, - tags: input.tags, - ownerUserId, - guildId: input.guildId ?? null, - channelId: input.channelId ?? null, - isPublic: input.isPublic, - }); - } - - /** Update an existing materi document. */ - async update( - id: string, - input: UpdateMateriInput, - ): Promise { - logger.info({ id, keys: Object.keys(input) }, "Updating materi"); - return materiRepository.update(id, { - title: input.title, - description: input.description, - content: input.content, - category: input.category, - tags: input.tags, - isPublic: input.isPublic, - }); - } - - /** Delete a materi document. */ - async delete(id: string): Promise { - logger.info({ id }, "Deleting materi"); - return materiRepository.delete(id); - } - - /** RAG chat: answer a question using materi documents as context. */ - async ragChat( - input: MateriRagChatInput, - ownerUserId: string, - ): Promise<{ - answer: string; - sources: Array<{ - id: string; - title: string; - score: number; - excerpt: string; - }>; - }> { - logger.info({ ownerUserId, hasMateriId: !!input.materiId }, "RAG chat"); - - // Fetch relevant materi documents - let documents: MateriDocument[]; - if (input.materiId) { - const doc = await materiRepository.byId(input.materiId); - documents = doc ? [doc] : []; - } else { - // Fetch all public + user's own materi - documents = await materiRepository.list({ - limit: 100, - onlyPublic: true, - ownerId: ownerUserId, - }); - } - - const result = await ragChat(input.message, documents, input.history); - return { - answer: result.answer, - sources: result.sources, - }; - } -} - -export const materiService = new MateriService(); diff --git a/services/backend/src/modules/materi/ragClient.ts b/services/backend/src/modules/materi/ragClient.ts deleted file mode 100644 index d3ac6ce..0000000 --- a/services/backend/src/modules/materi/ragClient.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { config } from "@/shared/config/index.js"; -import type { MateriDocument } from "@/shared/database/schema.js"; -import { createChildLogger } from "@/shared/logger/index.js"; -import { embedQuery } from "../messages/embed.js"; -import { searchArchive } from "../messages/qdrant.js"; - -const logger = createChildLogger("materi-rag"); - -export interface MateriSearchHit { - document: MateriDocument; - score: number; - chunkText: string; - chunkIndex: number; -} - -export interface RAGChatResult { - answer: string; - sources: Array<{ - id: string; - title: string; - score: number; - excerpt: string; - }>; -} - -/** Chunk size for splitting materi content for embedding search. */ -const CHUNK_SIZE = 500; -const SEARCH_TOP_K = 5; -const SIMILARITY_THRESHOLD = 0.6; - -/** - * Split text into overlapping chunks for embedding search. - */ -function chunkText(text: string): string[] { - const chunks: string[] = []; - let pos = 0; - while (pos < text.length) { - const end = Math.min(pos + CHUNK_SIZE, text.length); - chunks.push(text.slice(pos, end)); - pos = end - CHUNK_SIZE / 4; // 25% overlap - if (pos <= 0) break; - } - return chunks; -} - -/** - * Generate embeddings for chunks. Returns null if embeddings not configured. - */ -async function embedChunks(chunks: string[]): Promise { - const vectors: number[][] = []; - for (const chunk of chunks) { - const vec = await embedQuery(chunk); - if (vec) vectors.push(vec); - } - return vectors.length > 0 ? vectors : null; -} - -/** - * Simple cosine similarity between two embedding vectors. - */ -function cosineSim(a: number[], b: number[]): number { - let dot = 0, - na = 0, - nb = 0; - for (let i = 0; i < a.length && i < b.length; i++) { - dot += a[i] * b[i]; - na += a[i] * a[i]; - nb += b[i] * b[i]; - } - const denom = Math.sqrt(na) * Math.sqrt(nb); - return denom > 0 ? dot / denom : 0; -} - -/** Search materi documents for relevant content via semantic + keyword search. */ -export async function searchMateri( - query: string, - documents: MateriDocument[], - topK: number = SEARCH_TOP_K, -): Promise { - if (documents.length === 0) return []; - - const queryVec = await embedQuery(query); - const results: MateriSearchHit[] = []; - - for (const doc of documents) { - const chunks = chunkText(doc.content); - const chunkVecs = await embedChunks(chunks); - - if (queryVec && chunkVecs) { - for (let i = 0; i < chunks.length && i < chunkVecs.length; i++) { - const score = cosineSim(queryVec, chunkVecs[i]); - if (score > SIMILARITY_THRESHOLD) { - results.push({ - document: doc, - score, - chunkText: chunks[i], - chunkIndex: i, - }); - } - } - } else { - // Fallback: keyword match scoring - const titleMatch = doc.title.toLowerCase().includes(query.toLowerCase()); - const contentMatch = doc.content - .toLowerCase() - .includes(query.toLowerCase()); - const tagMatch = ((doc.tags as string[]) ?? []).some((t) => - t.toLowerCase().includes(query.toLowerCase()), - ); - if (titleMatch || contentMatch || tagMatch) { - results.push({ - document: doc, - score: titleMatch ? 0.8 : contentMatch ? 0.5 : 0.3, - chunkText: chunks[0] ?? doc.content.slice(0, CHUNK_SIZE), - chunkIndex: 0, - }); - } - } - } - - // Also search Discord message archive via Qdrant for conversation context - const archiveHits = await searchArchive( - queryVec ?? [], - topK, - SIMILARITY_THRESHOLD, - ); - for (const hit of archiveHits) { - results.push({ - document: { - id: `archive-${Date.now()}`, - title: "Discord Archive", - description: null, - content: hit.payload.text, - category: "archive", - tags: [], - owner_user_id: "", - guild_id: null, - channel_id: null, - is_public: true, - view_count: 0, - created_at: hit.payload.analyzed_at, - updated_at: hit.payload.analyzed_at, - } as MateriDocument, - score: hit.score, - chunkText: hit.payload.text.slice(0, 500), - chunkIndex: 0, - }); - } - - results.sort((a, b) => b.score - a.score); - return results.slice(0, Math.min(topK, results.length)); -} - -/** RAG chat: search materi docs for context, then generate answer via LLM. */ -export async function ragChat( - query: string, - documents: MateriDocument[], - history: Array<{ role: "user" | "assistant"; content: string }> = [], -): Promise { - const hits = await searchMateri(query, documents); - - const contextBlock = - hits - .map((h) => { - const scoreStr = h.score.toFixed(3); - return ( - '\n' + - h.chunkText + - "\n" - ); - }) - .join("\n\n") || "(tidak ada konteks relevan ditemukan)"; - - const systemPrompt = - "Anda adalah asisten AI untuk komunitas GMW (Glow Mushroom Wibu). " + - "Jawab pertanyaan pengguna berdasarkan konteks berikut. Jika tidak tahu, katakan tidak tahu.\n\n" + - "Konteks materi dan arsip Discord:\n" + - contextBlock + - "\n\n" + - "Instruksi: jawab singkat, akurat, dan berguna. Kutip sumber jika perlu."; - - const baseUrL = config.AI_LLM_BASE_URL; - const authToken = config.AI_LLM_API_KEY; - const model = config.AI_LLM_MODEL ?? "text"; - // Build auth header without triggering secret redaction in tooling - const bearerPrefix = "Bearer "; - const authHeader = bearerPrefix + String(authToken); - - try { - const messages = [ - { role: "system", content: systemPrompt }, - ...history, - { role: "user", content: query }, - ].filter((m) => m.content) as Array<{ role: string; content: string }>; - - const authHeaders: Record = {}; - authHeaders.Authorization = authHeader; - const res = await fetch(`${baseUrL}/chat/completions`, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...authHeaders, - }, - body: JSON.stringify({ - model, - messages, - max_tokens: 2000, - temperature: 0.7, - stream: false, - }), - }); - - if (!res.ok) { - throw new Error(`LLM request failed: ${res.status}`); - } - - const data = (await res.json()) as { - choices?: Array<{ message?: { content?: string } }>; - }; - - const answer = - data.choices?.[0]?.message?.content ?? - "Maaf, tidak bisa menjawab saat ini."; - - return { - answer, - sources: hits.slice(0, 3).map((h) => ({ - id: h.document.id, - title: h.document.title, - score: h.score, - excerpt: h.chunkText.slice(0, 200), - })), - }; - } catch (error) { - logger.error( - { error: error instanceof Error ? error.message : String(error) }, - "RAG chat failed", - ); - return { - answer: "Maaf, ada kesalahan saat memproses pertanyaan Anda.", - sources: hits.slice(0, 3).map((h) => ({ - id: h.document.id, - title: h.document.title, - score: h.score, - excerpt: h.chunkText.slice(0, 200), - })), - }; - } -} diff --git a/services/backend/src/orpc/router.ts b/services/backend/src/orpc/router.ts index 1eef09e..b22602c 100644 --- a/services/backend/src/orpc/router.ts +++ b/services/backend/src/orpc/router.ts @@ -3,16 +3,8 @@ import { z } from "zod"; import { analysisService } from "../modules/analysis/analysis.service"; import { chatRequestSchema } from "../modules/chatbot/chatbot.schema"; import { chatbotService } from "../modules/chatbot/chatbot.service"; -// ── Service imports ────────────────────────────────────────────── import { dashboardService } from "../modules/dashboard/dashboard.service"; import { knowledgeService } from "../modules/knowledge/knowledge.service"; -import { - createMateriSchema, - materiQuerySchema, - materiRagChatSchema, - materiService, - updateMateriSchema, -} from "../modules/materi/index.js"; import { mediaLoopSchema, mediaQueueSchema, @@ -434,28 +426,6 @@ const uiStateRouter = { .handler(({ input }) => uiStateService.updateState(input)), }; -// ── Materi (learning materials + RAG chat) ────────────────────── -const materiRouter = { - list: os - .input(materiQuerySchema) - .handler(({ input }) => materiService.list(input)), - detail: os - .input(z.object({ id: z.string() })) - .handler(({ input }) => materiService.byId(input.id)), - create: os - .input(createMateriSchema) - .handler(({ input }) => materiService.create(input, "anonymous")), - update: os - .input(z.object({ id: z.string() }).merge(updateMateriSchema)) - .handler(({ input }) => materiService.update(input.id, input)), - delete: os - .input(z.object({ id: z.string() })) - .handler(({ input }) => materiService.delete(input.id)), - chat: os - .input(materiRagChatSchema) - .handler(({ input }) => materiService.ragChat(input, "anonymous")), -}; - // ── Root router ─────────────────────────────────────────────────── export const appRouter = { dashboard: dashboardRouter, @@ -469,7 +439,6 @@ export const appRouter = { config: configRouter, uiState: uiStateRouter, knowledge: knowledgeRouter, - materi: materiRouter, }; export type AppRouter = typeof appRouter; diff --git a/services/backend/src/shared/database/schema.ts b/services/backend/src/shared/database/schema.ts index d20c63a..5855443 100644 --- a/services/backend/src/shared/database/schema.ts +++ b/services/backend/src/shared/database/schema.ts @@ -596,44 +596,3 @@ export type DbRetentionPolicyInsert = // Chatbot Messages export type ChatbotMessage = typeof chatbotMessagesTable.$inferSelect; export type ChatbotMessageInsert = typeof chatbotMessagesTable.$inferInsert; - -// ============================================================================= -// Materi (learning materials for business flow + RAG) -// ============================================================================= - -/** - * Materi Documents Table (PostgreSQL) - * - * Stores learning materials (articles, guides, transcripts) that users - * create or that are auto-generated (e.g. AI conversation summaries). - * Used by the RAG chat agent to ground answers in authoritative content. - */ -export const pgMateriDocumentsTable = pgTable( - "materi_documents", - { - id: pgUuid("id").primaryKey().defaultRandom(), - title: pgText("title").notNull(), - description: pgText("description"), - content: pgText("content").notNull(), - category: pgText("category").notNull().default("general"), - tags: pgJsonb("tags").notNull().default("[]"), - owner_user_id: pgText("owner_user_id").notNull(), - guild_id: pgText("guild_id"), - channel_id: pgText("channel_id"), - is_public: pgBoolean("is_public").notNull().default(true), - view_count: pgInteger("view_count").notNull().default(0), - created_at: pgBigint("created_at", { mode: "number" }).notNull(), - updated_at: pgBigint("updated_at", { mode: "number" }).notNull(), - }, - (table) => ({ - categoryIdx: pgIndex("idx_materi_category").on(table.category), - ownerIdx: pgIndex("idx_materi_owner").on(table.owner_user_id), - guildIdx: pgIndex("idx_materi_guild").on(table.guild_id), - searchIdx: pgIndex("idx_materi_search").on(table.title, table.category), - }), -); - -export const materiDocumentsTable = pgMateriDocumentsTable; - -export type MateriDocument = typeof materiDocumentsTable.$inferSelect; -export type MateriDocumentInsert = typeof materiDocumentsTable.$inferInsert; diff --git a/services/frontend/src/app/(dashboard)/materi/[id]/page.tsx b/services/frontend/src/app/(dashboard)/materi/[id]/page.tsx deleted file mode 100644 index 63adca8..0000000 --- a/services/frontend/src/app/(dashboard)/materi/[id]/page.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Pencil, Trash2 } from "lucide-react"; -import { notFound } from "next/navigation"; -import { Badge, Button } from "@/components/primitives"; -import { MarkdownLite, PageTransition } from "@/components/shared"; -import { getMateriSSR } from "@/lib/api/materi"; - -export const dynamic = "force-dynamic"; - -export default async function MateriDetailPage({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { id } = await params; - const doc = await getMateriSSR(id); - - if (!doc) { - notFound(); - } - - return ( - -
-
-
-

{doc.title}

- {doc.description && ( -

{doc.description}

- )} -
-
- - -
-
- -
- {doc.category} - {doc.tags.map((tag) => ( - - {tag} - - ))} -
- - {/* MarkdownLite component renders content safely (no dangerouslySetInnerHTML) */} - -
-
- ); -} diff --git a/services/frontend/src/app/(dashboard)/materi/chat/page.tsx b/services/frontend/src/app/(dashboard)/materi/chat/page.tsx deleted file mode 100644 index 1947d57..0000000 --- a/services/frontend/src/app/(dashboard)/materi/chat/page.tsx +++ /dev/null @@ -1,192 +0,0 @@ -"use client"; - -import { Bot, ExternalLink, Loader2, Send, User } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { Button, GlassCard, Textarea } from "@/components/primitives"; -import { PageTransition } from "@/components/shared"; -import { searchMateri } from "@/lib/api/materi"; -import type { - MateriRagChatMessage, - MateriRagChatResult, -} from "@/lib/types/materi"; - -export const dynamic = "force-dynamic"; - -export default function MateriChatPage() { - const [messages, setMessages] = useState([]); - const [input, setInput] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const [sources, setSources] = useState([]); - const messagesEndRef = useRef(null); - - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, []); - - async function handleSend() { - if (!input.trim() || isLoading) return; - - const userMsg: MateriRagChatMessage = { - role: "user", - content: input.trim(), - }; - const newMessages = [...messages, userMsg]; - setMessages(newMessages); - setInput(""); - setIsLoading(true); - setSources([]); - - try { - const result = await searchMateri( - userMsg.content, - newMessages, - undefined, - ); - const assistantMsg: MateriRagChatMessage = { - role: "assistant", - content: result.answer, - }; - setMessages([...newMessages, assistantMsg]); - setSources(result.sources); - } catch { - const errorMsg: MateriRagChatMessage = { - role: "assistant", - content: "Maaf, ada kesalahan. Silakan coba lagi.", - }; - setMessages([...newMessages, errorMsg]); - } finally { - setIsLoading(false); - } - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - }; - - return ( - -
-
-

AI Chat — Materi & RAG

-

- Tanya tentang materi komunitas. AI akan mencari referensi dari - dokumen materi dan arsip Discord. -

-
- -
- {messages.length === 0 ? ( -
- -

Silakan tanyakan sesuatu tentang materi komunitas.

-

- Contoh: "Apa itu screenshare audio di GMW?" atau "Cara pakai - voice recording" -

-
- ) : ( - messages.map((msg, i) => ( -
-
-
- {msg.role === "user" ? ( - - ) : ( - - )} - - {msg.role === "user" ? "Anda" : "AI Agent"} - -
-
- {msg.content} -
-
-
- )) - )} - - {isLoading && ( -
-
-
- - - AI sedang mencari di materi... - -
-
-
- )} - -
-
- - {/* Sources from last AI response */} - {sources.length > 0 && ( - -

- Sumber: -

-
- {sources.map((src, i) => ( -
- {src.title} - - {" "} - (skor: {src.score.toFixed(2)}) - -

- {src.excerpt} -

-
- ))} -
-
- )} - - {/* Input */} -
-