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)
This commit is contained in:
asepharyana
2026-08-20 15:57:04 +07:00
parent 6f20b0f146
commit 0aa893ab7d
16 changed files with 1228 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
// 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 { MateriDocument, CreateMateriInput, MateriRagChatResult, MateriRagChatMessage } from "@/lib/types/materi";
import type { ORPCClient } from "@/lib/orpc/types";
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>;
}
+7
View File
@@ -1,4 +1,5 @@
import {
BookOpen,
Headphones,
LayoutDashboard,
type LucideIcon,
@@ -63,6 +64,12 @@ export const navItems: NavItem[] = [
icon: Search,
matchPrefix: "/analysis",
},
{
href: "/materi",
label: "Materi",
icon: BookOpen,
matchPrefix: "/materi",
},
];
/**
+1
View File
@@ -1,6 +1,7 @@
export * from "./dashboard";
export * from "./guild";
export * from "./knowledge";
export * from "./materi";
export * from "./media";
export * from "./message";
export * from "./moderation";
+41
View File
@@ -0,0 +1,41 @@
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;
}>;
}