chore: hapus fitur materi (learning materials + RAG chat)
Hapus fitur materi seluruhnya dari GMW monorepo: Backend: - Hapus module materi/ (index.ts, materi.repository.ts, materi.schema.ts, materi.service.ts, ragClient.ts) - Hapus materiRouter dari orpc/router.ts (imports, const, appRouter entry) - Hapus pgMateriDocumentsTable + types dari shared/database/schema.ts Frontend: - Hapus route pages app/(dashboard)/materi/ (page, [id], chat, new) - Hapus lib/api/materi.ts (oRPC client wrappers) - Hapus lib/types/materi.ts + export dari index.ts - Hapus nav item "Materi" dari lib/navigation.ts + unused BookOpen import Scripts: - Hapus scripts/add-materi-documents.sql - Tambah scripts/drop-materi-documents.sql (ops DB cleanup) Verification: - Backend: npx tsc --noEmit — clean (exit 0) - Frontend: npx tsc --noEmit — clean (exit 0) - Grep: zero materi code references remaining (hanya di drop-materi-documents.sql) RAG dependencies (messages/embed.ts, messages/qdrant.ts) tetap karena juga dipakai oleh messages.service.ts.
This commit is contained in:
@@ -1,29 +0,0 @@
|
|||||||
-- Migration: Add materi_documents table for learning materials + RAG
|
|
||||||
-- Run: PGPASSWORD=<pw> psql -h <host> -U <user> -d <db> -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;
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Migration: Drop materi_documents table (feature removed)
|
||||||
|
-- Run: PGPASSWORD=<pw> psql -h <host> -U <user> -d <db> -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;
|
||||||
@@ -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";
|
|
||||||
@@ -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<MateriDocument[]> {
|
|
||||||
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<MateriDocument | null> {
|
|
||||||
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<MateriDocument> {
|
|
||||||
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<MateriDocument | null> {
|
|
||||||
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<boolean> {
|
|
||||||
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<void> {
|
|
||||||
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();
|
|
||||||
@@ -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<typeof createMateriSchema>;
|
|
||||||
export type UpdateMateriInput = z.infer<typeof updateMateriSchema>;
|
|
||||||
export type MateriQueryInput = z.infer<typeof materiQuerySchema>;
|
|
||||||
export type MateriRagChatInput = z.infer<typeof materiRagChatSchema>;
|
|
||||||
@@ -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<MateriDocument[]> {
|
|
||||||
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<MateriDocument | null> {
|
|
||||||
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<MateriDocument> {
|
|
||||||
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<MateriDocument | null> {
|
|
||||||
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<boolean> {
|
|
||||||
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();
|
|
||||||
@@ -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<number[][] | null> {
|
|
||||||
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<MateriSearchHit[]> {
|
|
||||||
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<RAGChatResult> {
|
|
||||||
const hits = await searchMateri(query, documents);
|
|
||||||
|
|
||||||
const contextBlock =
|
|
||||||
hits
|
|
||||||
.map((h) => {
|
|
||||||
const scoreStr = h.score.toFixed(3);
|
|
||||||
return (
|
|
||||||
'<source id="' +
|
|
||||||
h.document.id +
|
|
||||||
'" title="' +
|
|
||||||
h.document.title +
|
|
||||||
'" score="' +
|
|
||||||
scoreStr +
|
|
||||||
'">\n' +
|
|
||||||
h.chunkText +
|
|
||||||
"\n</source>"
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.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<string, string> = {};
|
|
||||||
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),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,16 +3,8 @@ import { z } from "zod";
|
|||||||
import { analysisService } from "../modules/analysis/analysis.service";
|
import { analysisService } from "../modules/analysis/analysis.service";
|
||||||
import { chatRequestSchema } from "../modules/chatbot/chatbot.schema";
|
import { chatRequestSchema } from "../modules/chatbot/chatbot.schema";
|
||||||
import { chatbotService } from "../modules/chatbot/chatbot.service";
|
import { chatbotService } from "../modules/chatbot/chatbot.service";
|
||||||
// ── Service imports ──────────────────────────────────────────────
|
|
||||||
import { dashboardService } from "../modules/dashboard/dashboard.service";
|
import { dashboardService } from "../modules/dashboard/dashboard.service";
|
||||||
import { knowledgeService } from "../modules/knowledge/knowledge.service";
|
import { knowledgeService } from "../modules/knowledge/knowledge.service";
|
||||||
import {
|
|
||||||
createMateriSchema,
|
|
||||||
materiQuerySchema,
|
|
||||||
materiRagChatSchema,
|
|
||||||
materiService,
|
|
||||||
updateMateriSchema,
|
|
||||||
} from "../modules/materi/index.js";
|
|
||||||
import {
|
import {
|
||||||
mediaLoopSchema,
|
mediaLoopSchema,
|
||||||
mediaQueueSchema,
|
mediaQueueSchema,
|
||||||
@@ -434,28 +426,6 @@ const uiStateRouter = {
|
|||||||
.handler(({ input }) => uiStateService.updateState(input)),
|
.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 ───────────────────────────────────────────────────
|
// ── Root router ───────────────────────────────────────────────────
|
||||||
export const appRouter = {
|
export const appRouter = {
|
||||||
dashboard: dashboardRouter,
|
dashboard: dashboardRouter,
|
||||||
@@ -469,7 +439,6 @@ export const appRouter = {
|
|||||||
config: configRouter,
|
config: configRouter,
|
||||||
uiState: uiStateRouter,
|
uiState: uiStateRouter,
|
||||||
knowledge: knowledgeRouter,
|
knowledge: knowledgeRouter,
|
||||||
materi: materiRouter,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
@@ -596,44 +596,3 @@ export type DbRetentionPolicyInsert =
|
|||||||
// Chatbot Messages
|
// Chatbot Messages
|
||||||
export type ChatbotMessage = typeof chatbotMessagesTable.$inferSelect;
|
export type ChatbotMessage = typeof chatbotMessagesTable.$inferSelect;
|
||||||
export type ChatbotMessageInsert = typeof chatbotMessagesTable.$inferInsert;
|
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;
|
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<PageTransition>
|
|
||||||
<article className="prose dark:prose-invert max-w-none">
|
|
||||||
<div className="flex items-start justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h1>{doc.title}</h1>
|
|
||||||
{doc.description && (
|
|
||||||
<p className="text-muted-foreground">{doc.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="ghost" size="sm" asChild>
|
|
||||||
<a href={`/materi/${doc.id}/edit`}>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" asChild>
|
|
||||||
<a href={`/materi/new?duplicate=${doc.id}`}>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6 flex flex-wrap gap-2">
|
|
||||||
<Badge tone="neutral">{doc.category}</Badge>
|
|
||||||
{doc.tags.map((tag) => (
|
|
||||||
<Badge key={tag} tone="neutral">
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* MarkdownLite component renders content safely (no dangerouslySetInnerHTML) */}
|
|
||||||
<MarkdownLite content={doc.content} />
|
|
||||||
</article>
|
|
||||||
</PageTransition>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<MateriRagChatMessage[]>([]);
|
|
||||||
const [input, setInput] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [sources, setSources] = useState<MateriRagChatResult["sources"]>([]);
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(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<HTMLTextAreaElement>) => {
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSend();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageTransition>
|
|
||||||
<div className="flex flex-col h-[calc(100vh-200px)]">
|
|
||||||
<div className="mb-4">
|
|
||||||
<h1 className="text-3xl font-bold">AI Chat — Materi & RAG</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Tanya tentang materi komunitas. AI akan mencari referensi dari
|
|
||||||
dokumen materi dan arsip Discord.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-4">
|
|
||||||
{messages.length === 0 ? (
|
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
|
||||||
<Bot className="mx-auto h-12 w-12 mb-4 opacity-50" />
|
|
||||||
<p>Silakan tanyakan sesuatu tentang materi komunitas.</p>
|
|
||||||
<p className="text-xs mt-2">
|
|
||||||
Contoh: "Apa itu screenshare audio di GMW?" atau "Cara pakai
|
|
||||||
voice recording"
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
messages.map((msg, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={
|
|
||||||
"flex gap-3 " +
|
|
||||||
(msg.role === "user" ? "justify-end" : "justify-start")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
"max-w-[80%] rounded-lg p-4 " +
|
|
||||||
(msg.role === "user"
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-muted/50")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
{msg.role === "user" ? (
|
|
||||||
<User className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Bot className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span className="text-xs font-medium">
|
|
||||||
{msg.role === "user" ? "Anda" : "AI Agent"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-pre-wrap text-sm">
|
|
||||||
{msg.content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex gap-3 justify-start">
|
|
||||||
<div className="bg-muted/50 rounded-lg p-4 max-w-[80%]">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
<span className="text-sm">
|
|
||||||
AI sedang mencari di materi...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sources from last AI response */}
|
|
||||||
{sources.length > 0 && (
|
|
||||||
<GlassCard className="p-4 mb-4">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-2">
|
|
||||||
Sumber:
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{sources.map((src, i) => (
|
|
||||||
<div key={i} className="text-sm">
|
|
||||||
<span className="font-medium">{src.title}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{" "}
|
|
||||||
(skor: {src.score.toFixed(2)})
|
|
||||||
</span>
|
|
||||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-1">
|
|
||||||
{src.excerpt}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</GlassCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Input */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Textarea
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Tanya tentang materi..."
|
|
||||||
disabled={isLoading}
|
|
||||||
className="flex-1"
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleSend}
|
|
||||||
disabled={isLoading || !input.trim()}
|
|
||||||
size="icon"
|
|
||||||
>
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 text-xs text-muted-foreground">
|
|
||||||
<ExternalLink className="h-3 w-3 inline mr-1" />
|
|
||||||
AI mengacu pada materi dan arsip Discord. Jawaban mungkin tidak 100%
|
|
||||||
akurat.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageTransition>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ArrowLeft, Save } from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button, GlassCard, Input, Textarea } from "@/components/primitives";
|
|
||||||
import { PageTransition } from "@/components/shared";
|
|
||||||
import { createMateri } from "@/lib/api/materi";
|
|
||||||
import type { CreateMateriInput } from "@/lib/types/materi";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default function MateriNewPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [form, setForm] = useState<CreateMateriInput>({
|
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
content: "",
|
|
||||||
category: "general",
|
|
||||||
tags: [],
|
|
||||||
isPublic: true,
|
|
||||||
});
|
|
||||||
const [tagsInput, setTagsInput] = useState("");
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function update<K extends keyof CreateMateriInput>(
|
|
||||||
key: K,
|
|
||||||
value: CreateMateriInput[K],
|
|
||||||
) {
|
|
||||||
setForm((f) => ({ ...f, [key]: value }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
if (!form.title.trim() || !form.content.trim()) {
|
|
||||||
setError("Judul dan konten wajib diisi.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const tags = tagsInput
|
|
||||||
.split(",")
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const doc = await createMateri({ ...form, tags });
|
|
||||||
router.push(`/materi/${doc.id}`);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Gagal menyimpan materi.");
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageTransition>
|
|
||||||
<div className="max-w-3xl mx-auto space-y-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<h1 className="text-3xl font-bold">Buat Materi Baru</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<GlassCard className="p-4 border border-red-500/30 text-red-400 text-sm">
|
|
||||||
{error}
|
|
||||||
</GlassCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<GlassCard className="p-6 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="materi-title"
|
|
||||||
className="text-sm font-medium mb-1 block"
|
|
||||||
>
|
|
||||||
Judul *
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="materi-title"
|
|
||||||
value={form.title}
|
|
||||||
onChange={(e) => update("title", e.target.value)}
|
|
||||||
placeholder="Judul materi"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="materi-description"
|
|
||||||
className="text-sm font-medium mb-1 block"
|
|
||||||
>
|
|
||||||
Deskripsi
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="materi-description"
|
|
||||||
value={form.description ?? ""}
|
|
||||||
onChange={(e) => update("description", e.target.value)}
|
|
||||||
placeholder="Deskripsi singkat"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="materi-category"
|
|
||||||
className="text-sm font-medium mb-1 block"
|
|
||||||
>
|
|
||||||
Kategori
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="materi-category"
|
|
||||||
value={form.category}
|
|
||||||
onChange={(e) => update("category", e.target.value)}
|
|
||||||
placeholder="general"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="materi-tags"
|
|
||||||
className="text-sm font-medium mb-1 block"
|
|
||||||
>
|
|
||||||
Tags (pisahkan dengan koma)
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="materi-tags"
|
|
||||||
value={tagsInput}
|
|
||||||
onChange={(e) => setTagsInput(e.target.value)}
|
|
||||||
placeholder="wibu, discord, moderation"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="materi-content"
|
|
||||||
className="text-sm font-medium mb-1 block"
|
|
||||||
>
|
|
||||||
Konten *
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
id="materi-content"
|
|
||||||
value={form.content}
|
|
||||||
onChange={(e) => update("content", e.target.value)}
|
|
||||||
placeholder="Tulis materi di sini..."
|
|
||||||
rows={12}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.isPublic}
|
|
||||||
onChange={(e) => update("isPublic", e.target.checked)}
|
|
||||||
/>
|
|
||||||
Publik (terlihat semua orang)
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button onClick={handleSubmit} disabled={saving}>
|
|
||||||
<Save className="h-4 w-4 mr-2" />
|
|
||||||
{saving ? "Menyimpan..." : "Simpan"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</GlassCard>
|
|
||||||
</div>
|
|
||||||
</PageTransition>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { BookOpen, MessageSquare, Plus, Search } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Badge, Button, GlassCard, Input } from "@/components/primitives";
|
|
||||||
import { PageTransition } from "@/components/shared";
|
|
||||||
import { listMateriSSR } from "@/lib/api/materi";
|
|
||||||
import type { MateriDocument } from "@/lib/types/materi";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
async function loadMateri(search?: string): Promise<MateriDocument[]> {
|
|
||||||
try {
|
|
||||||
return await listMateriSSR(50, search);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function MateriGrid({ materi }: { materi: MateriDocument[] }) {
|
|
||||||
if (materi.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
|
||||||
<BookOpen className="mx-auto h-12 w-12 mb-4 opacity-50" />
|
|
||||||
<p>Belum ada materi. Jadilah yang pertama membuat materi!</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{materi.map((doc) => (
|
|
||||||
<Link key={doc.id} href={`/materi/${doc.id}`}>
|
|
||||||
<GlassCard className="h-full cursor-pointer hover:shadow-lg transition-shadow">
|
|
||||||
<div className="p-6">
|
|
||||||
<h3 className="font-bold text-lg mb-2 line-clamp-2">
|
|
||||||
{doc.title}
|
|
||||||
</h3>
|
|
||||||
{doc.description && (
|
|
||||||
<p className="text-sm text-muted-foreground mb-3 line-clamp-3">
|
|
||||||
{doc.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap gap-1 mb-3">
|
|
||||||
<Badge tone="neutral" className="text-xs">
|
|
||||||
{doc.category}
|
|
||||||
</Badge>
|
|
||||||
{doc.tags.slice(0, 3).map((tag) => (
|
|
||||||
<Badge key={tag} tone="neutral" className="text-xs">
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
||||||
<span>{doc.view_count} views</span>
|
|
||||||
<span>{new Date(doc.created_at).toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</GlassCard>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function MateriPage({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{ search?: string }>;
|
|
||||||
}) {
|
|
||||||
const params = await searchParams;
|
|
||||||
const materi = await loadMateri(params.search);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageTransition>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Materi & Bahan Belajar</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Dokumen, panduan, dan bahan belajar komunitas beserta AI agent RAG
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Link href="/materi/chat">
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<MessageSquare className="h-4 w-4 mr-2" />
|
|
||||||
AI Chat
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Button size="sm" asChild>
|
|
||||||
<Link href={"/materi/new"}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Buat Materi
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
type="search"
|
|
||||||
placeholder="Cari materi..."
|
|
||||||
className="pl-10"
|
|
||||||
name="search"
|
|
||||||
defaultValue={params.search}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MateriGrid materi={materi} />
|
|
||||||
</div>
|
|
||||||
</PageTransition>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
// Materi API client — talks to backend /trpc materi router
|
|
||||||
import { createORPCClient } from "@orpc/client";
|
|
||||||
import { RPCLink } from "@orpc/client/fetch";
|
|
||||||
import { orpc } from "@/lib/orpc/client";
|
|
||||||
import type { ORPCClient } from "@/lib/orpc/types";
|
|
||||||
import type {
|
|
||||||
CreateMateriInput,
|
|
||||||
MateriDocument,
|
|
||||||
MateriRagChatMessage,
|
|
||||||
MateriRagChatResult,
|
|
||||||
} from "@/lib/types/materi";
|
|
||||||
|
|
||||||
const BACKEND_URL =
|
|
||||||
process.env.GMW_BACKEND_URL?.replace(/\/+$/, "") || "http://127.0.0.1:4001";
|
|
||||||
|
|
||||||
let _serverClient: ORPCClient | null = null;
|
|
||||||
function serverOrpc(): ORPCClient {
|
|
||||||
if (!_serverClient) {
|
|
||||||
const link = new RPCLink({
|
|
||||||
url: `${BACKEND_URL}/trpc`,
|
|
||||||
fetch(url, init) {
|
|
||||||
return fetch(url, { ...init, cache: "no-store" });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
_serverClient = createORPCClient(link) as unknown as ORPCClient;
|
|
||||||
}
|
|
||||||
return _serverClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server-side (SSR seed) — uses the HTTP RPCLink via oRPC
|
|
||||||
export async function listMateriSSR(
|
|
||||||
limit = 50,
|
|
||||||
search?: string,
|
|
||||||
): Promise<MateriDocument[]> {
|
|
||||||
return (serverOrpc() as any).materi.list({
|
|
||||||
limit,
|
|
||||||
search,
|
|
||||||
}) as unknown as Promise<MateriDocument[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getMateriSSR(id: string): Promise<MateriDocument | null> {
|
|
||||||
return (serverOrpc() as any).materi.detail({
|
|
||||||
id,
|
|
||||||
}) as unknown as Promise<MateriDocument | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client-side — browser WebSocket RPCLink (orpc is "use client")
|
|
||||||
export async function createMateri(
|
|
||||||
input: CreateMateriInput,
|
|
||||||
): Promise<MateriDocument> {
|
|
||||||
return orpc.materi.create(input) as unknown as Promise<MateriDocument>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateMateri(
|
|
||||||
id: string,
|
|
||||||
input: Partial<CreateMateriInput>,
|
|
||||||
): Promise<MateriDocument | null> {
|
|
||||||
return orpc.materi.update({
|
|
||||||
id,
|
|
||||||
...input,
|
|
||||||
}) as unknown as Promise<MateriDocument | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteMateri(id: string): Promise<boolean> {
|
|
||||||
return orpc.materi.delete({ id }) as unknown as Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function searchMateri(
|
|
||||||
query: string,
|
|
||||||
history: MateriRagChatMessage[] = [],
|
|
||||||
materiId?: string,
|
|
||||||
): Promise<MateriRagChatResult> {
|
|
||||||
return orpc.materi.chat({
|
|
||||||
message: query,
|
|
||||||
history,
|
|
||||||
materiId,
|
|
||||||
}) as unknown as Promise<MateriRagChatResult>;
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
BookOpen,
|
|
||||||
Headphones,
|
Headphones,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
@@ -64,12 +63,6 @@ export const navItems: NavItem[] = [
|
|||||||
icon: Search,
|
icon: Search,
|
||||||
matchPrefix: "/analysis",
|
matchPrefix: "/analysis",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: "/materi",
|
|
||||||
label: "Materi",
|
|
||||||
icon: BookOpen,
|
|
||||||
matchPrefix: "/materi",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export * from "./dashboard";
|
export * from "./dashboard";
|
||||||
export * from "./guild";
|
export * from "./guild";
|
||||||
export * from "./knowledge";
|
export * from "./knowledge";
|
||||||
export * from "./materi";
|
|
||||||
export * from "./media";
|
export * from "./media";
|
||||||
export * from "./message";
|
export * from "./message";
|
||||||
export * from "./moderation";
|
export * from "./moderation";
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
export interface MateriDocument {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string | null;
|
|
||||||
content: string;
|
|
||||||
category: string;
|
|
||||||
tags: string[];
|
|
||||||
owner_user_id: string;
|
|
||||||
guild_id: string | null;
|
|
||||||
channel_id: string | null;
|
|
||||||
is_public: boolean;
|
|
||||||
view_count: number;
|
|
||||||
created_at: number;
|
|
||||||
updated_at: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateMateriInput {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
content: string;
|
|
||||||
category: string;
|
|
||||||
tags: string[];
|
|
||||||
guildId?: string;
|
|
||||||
channelId?: string;
|
|
||||||
isPublic: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MateriRagChatMessage {
|
|
||||||
role: "user" | "assistant";
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MateriRagChatResult {
|
|
||||||
answer: string;
|
|
||||||
sources: Array<{
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
score: number;
|
|
||||||
excerpt: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user