From 0aa893ab7d35bb731af45949c758a4f2a59657d1 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Thu, 20 Aug 2026 15:57:04 +0700 Subject: [PATCH] feat: add Materi section + AI agent RAG to GMW business flow Backend: - New materi module: schema (materi_documents table), repository, service - ragClient: semantic + keyword search over materi docs, plus Discord archive via Qdrant, then LLM answer generation (RAG) - Wire materiRouter into appRouter (list/detail/create/update/delete/chat) Frontend: - New types (MateriDocument, CreateMateriInput, RAG chat shapes) - API client (SSR HTTP RPCLink + browser WS RPCLink) - Routes: /materi list, /materi/[id] detail, /materi/new form, /materi/chat RAG chat UI - Sidebar nav item 'Materi' Migration: scripts/add-materi-documents.sql (CREATE TABLE IF NOT EXISTS) --- scripts/add-materi-documents.sql | 27 ++ services/backend/src/modules/materi/index.ts | 14 ++ .../src/modules/materi/materi.repository.ts | 148 +++++++++++ .../src/modules/materi/materi.schema.ts | 43 ++++ .../src/modules/materi/materi.service.ts | 100 ++++++++ .../backend/src/modules/materi/ragClient.ts | 238 ++++++++++++++++++ services/backend/src/orpc/router.ts | 30 +++ .../backend/src/shared/database/schema.ts | 41 +++ .../src/app/(dashboard)/materi/[id]/page.tsx | 59 +++++ .../src/app/(dashboard)/materi/chat/page.tsx | 170 +++++++++++++ .../src/app/(dashboard)/materi/new/page.tsx | 133 ++++++++++ .../src/app/(dashboard)/materi/page.tsx | 111 ++++++++ services/frontend/src/lib/api/materi.ts | 65 +++++ services/frontend/src/lib/navigation.ts | 7 + services/frontend/src/lib/types/index.ts | 1 + services/frontend/src/lib/types/materi.ts | 41 +++ 16 files changed, 1228 insertions(+) create mode 100644 scripts/add-materi-documents.sql create mode 100644 services/backend/src/modules/materi/index.ts create mode 100644 services/backend/src/modules/materi/materi.repository.ts create mode 100644 services/backend/src/modules/materi/materi.schema.ts create mode 100644 services/backend/src/modules/materi/materi.service.ts create mode 100644 services/backend/src/modules/materi/ragClient.ts create mode 100644 services/frontend/src/app/(dashboard)/materi/[id]/page.tsx create mode 100644 services/frontend/src/app/(dashboard)/materi/chat/page.tsx create mode 100644 services/frontend/src/app/(dashboard)/materi/new/page.tsx create mode 100644 services/frontend/src/app/(dashboard)/materi/page.tsx create mode 100644 services/frontend/src/lib/api/materi.ts create mode 100644 services/frontend/src/lib/types/materi.ts diff --git a/scripts/add-materi-documents.sql b/scripts/add-materi-documents.sql new file mode 100644 index 0000000..5361a6a --- /dev/null +++ b/scripts/add-materi-documents.sql @@ -0,0 +1,27 @@ +-- Migration: Add materi_documents table for learning materials + RAG +-- Run: PGPASSWORD= psql -h -U -d -f scripts/add-materi-documents.sql + +BEGIN; + +CREATE TABLE IF NOT EXISTS "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, + "updated_at" bigint NOT NULL DEFAULT extract(epoch from now())::bigint +); + +CREATE INDEX IF NOT EXISTS "idx_materi_category" ON "materi_documents" ("category"); +CREATE INDEX IF NOT EXISTS "idx_materi_is_public" ON "materi_documents" ("is_public"); +CREATE INDEX IF NOT EXISTS "idx_materi_owner" ON "materi_documents" ("owner_user_id"); +CREATE INDEX IF NOT EXISTS "idx_materi_created" ON "materi_documents" ("created_at"); + +COMMIT; diff --git a/services/backend/src/modules/materi/index.ts b/services/backend/src/modules/materi/index.ts new file mode 100644 index 0000000..7cf5b3a --- /dev/null +++ b/services/backend/src/modules/materi/index.ts @@ -0,0 +1,14 @@ +export { MateriService } from "./materi.service.js"; +export { materiService } from "./materi.service.js"; +export { materiRepository } from "./materi.repository.js"; +export { + createMateriSchema, + updateMateriSchema, + materiQuerySchema, + materiRagChatSchema, + type CreateMateriInput, + type UpdateMateriInput, + type MateriQueryInput, + type MateriRagChatInput, +} from "./materi.schema.js"; +export { ragChat, searchMateri, type MateriSearchHit, type RAGChatResult } from "./ragClient.js"; diff --git a/services/backend/src/modules/materi/materi.repository.ts b/services/backend/src/modules/materi/materi.repository.ts new file mode 100644 index 0000000..d41bbab --- /dev/null +++ b/services/backend/src/modules/materi/materi.repository.ts @@ -0,0 +1,148 @@ +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; +import { + type MateriDocument, + materiDocumentsTable, +} from "@/shared/database/schema.js"; +import { getDatabase } from "@/shared/database/index.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 new file mode 100644 index 0000000..51915b0 --- /dev/null +++ b/services/backend/src/modules/materi/materi.schema.ts @@ -0,0 +1,43 @@ +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 new file mode 100644 index 0000000..eec86c2 --- /dev/null +++ b/services/backend/src/modules/materi/materi.service.ts @@ -0,0 +1,100 @@ +import type { MateriDocument } from "@/shared/database/schema.js"; +import { createChildLogger } from "@/shared/logger/index.js"; +import { materiRepository } from "./materi.repository.js"; +import { + type MateriQueryInput, + type CreateMateriInput, + type UpdateMateriInput, + type MateriRagChatInput, +} 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 new file mode 100644 index 0000000..9153143 --- /dev/null +++ b/services/backend/src/modules/materi/ragClient.ts @@ -0,0 +1,238 @@ +import { config } from "@/shared/config/index.js"; +import { createChildLogger } from "@/shared/logger/index.js"; +import type { MateriDocument } from "@/shared/database/schema.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 ?? []).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 65fa7dc..114b4a6 100644 --- a/services/backend/src/orpc/router.ts +++ b/services/backend/src/orpc/router.ts @@ -6,6 +6,13 @@ import { chatbotService } from "../modules/chatbot/chatbot.service"; // ── Service imports ────────────────────────────────────────────── import { dashboardService } from "../modules/dashboard/dashboard.service"; import { knowledgeService } from "../modules/knowledge/knowledge.service"; +import { + materiQuerySchema, + materiRagChatSchema, + createMateriSchema, + updateMateriSchema, + materiService, +} from "../modules/materi/index.js"; import { mediaLoopSchema, mediaQueueSchema, @@ -427,6 +434,28 @@ 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, @@ -440,6 +469,7 @@ 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 5855443..473e7a8 100644 --- a/services/backend/src/shared/database/schema.ts +++ b/services/backend/src/shared/database/schema.ts @@ -596,3 +596,44 @@ 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: pgText("tags").array(), + 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 new file mode 100644 index 0000000..a7a0d33 --- /dev/null +++ b/services/frontend/src/app/(dashboard)/materi/[id]/page.tsx @@ -0,0 +1,59 @@ +import { notFound } from "next/navigation"; +import { PageTransition, MarkdownLite } from "@/components/shared"; +import { Button, Badge } from "@/components/primitives"; +import { Trash2, Pencil } from "lucide-react"; +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 new file mode 100644 index 0000000..5c42585 --- /dev/null +++ b/services/frontend/src/app/(dashboard)/materi/chat/page.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { PageTransition } from "@/components/shared"; +import { Button, Input, Textarea, GlassCard } from "@/components/primitives"; +import { Send, Bot, User, Loader2, ExternalLink } from "lucide-react"; +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" }); + }, [messages]); + + 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 */} +
+