6 Commits
Author SHA1 Message Date
mytheclipsebotreview 5658726ea5 fix(frontend): sort messages by created_at to fix WS race condition
Two independent WS handlers (useMessagesWsSync for message_created/
updated/analyzed, and useMessagesStream for message_snapshot) both
prepend live messages to the SWR list without enforcing order.
When frames arrive out-of-order (common with batched WS delivery),
the message feed gets scrambled.

Fix: add sortMessages() helper that sorts newest-first by created_at
(the list's stored order before .reverse() for display) and apply it
in every patchLists/mutate updater: message_created, message_updated,
message_analyzed, message_snapshot, and useLoadMore page appends.

Function declaration is hoisted so useLoadMore (defined above the
helper) can use it.
2026-08-20 18:16:43 +07:00
mytheclipsebotreview f5d5690401 fix: double-.js extension in @/ alias resolution (fix-imports.mjs)
The fix-imports.mjs script blindly appended '.js' to every @/ alias
import, even when the source specifier already carried a .js
extension (e.g. '@/shared/config/index.js'). This produced
'index.js.js' in the emitted dist/, causing ERR_MODULE_NOT_FOUND
at startup.

This was latent: only triggered once digestScheduler.ts (which
uses @/shared/config/index.js with explicit extension) was built.
The user-reputation removal (2a8f6d9) was also blocked by this
bug — stale binary kept crashing with 'user_reputations' query
errors because it was never redeployed.

Fix: only append .js when the @/ specifier has no existing
extension. Applied to both gateway and backend scripts.
2026-08-20 18:08:30 +07:00
asepharyana 0aa893ab7d 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)
2026-08-20 15:57:04 +07:00
asepharyana 6f20b0f146 docs: clarify termGlossary uses Wikipedia (not SearXNG) for definition lookups
SearXNG was already replaced by Wikipedia REST/Action APIs (wikipediaClient.ts).
Update comments to reflect the current implementation: term glossary now
resolves definitions via Wikipedia → Redis → Postgres cache chain, with no
SearXNG dependency.
2026-08-20 15:32:20 +07:00
asepharyana 80248d4b7a feat: add 'screenshare' to MediaMode union for screen-share audio recording
Prepares the media type system to distinguish screenshare audio SSRCs
from mic voice SSRCs once the hookScreenShareAudio capture logic is
wired in.
2026-08-20 15:29:22 +07:00
asepharyana 20e991062c fix: screen-share audio capture — hook VoiceReceiver.onUdpMessage to discover unregistered SSRCs
Discord GoLive sends screen-share audio on a separate SSRC from the
user's microphone. In @discordjs/voice v0.19, VoiceReceiver.onUdpMessage
silently drops packets for SSRCs not in ssrcMap (which is only populated
from VOICE_STATE_UPDATE/VOICE_SERVER_UPDATE). This caused screen-share
audio to never trigger receiver.speaking and never reach the speakingHandler.

Fix: hookScreenShareAudio() wraps onUdpMessage to:
1. Detect incoming RTP packets with unknown SSRCs (OPRUS payload type 120)
2. Infer the owning userId by proximity to known audioSSRC
3. Clone the user's VoiceUserData into ssrcMap under the new SSRC
4. Let the original handler decrypt and forward to the subscription stream
5. Listen on ssrcMap 'create'/'update' events for video SSRC changes

Also removes the broken initial approach (polling ssrcMap which never
contains screen-share SSRCs).
2026-08-20 15:28:55 +07:00
23 changed files with 1508 additions and 12 deletions

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
-- 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
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;
+10 -1
View File
@@ -25,7 +25,16 @@ function walk(dir) {
const pat = /from\s+['"]([^'"]+)['"]/g;
const n = c.replace(pat, (m, spec) => {
if (spec.startsWith("@/")) {
const target = join("dist", spec.slice(2)) + ".js";
// Source may already carry an extension (e.g. "@/shared/config/index.js");
// only append ".js" when the specifier has none — otherwise we'd
// produce "index.js.js".
const core = spec.slice(2);
let target;
if (/\.(js|json|node|mjs|cjs)$/.test(core)) {
target = join("dist", core);
} else {
target = join("dist", core) + ".js";
}
let rel = relative(dirname(p), target);
if (!rel.startsWith(".")) rel = "./" + rel;
return `from "${rel}"`;
@@ -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";
@@ -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<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();
@@ -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<typeof createMateriSchema>;
export type UpdateMateriInput = z.infer<typeof updateMateriSchema>;
export type MateriQueryInput = z.infer<typeof materiQuerySchema>;
export type MateriRagChatInput = z.infer<typeof materiRagChatSchema>;
@@ -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<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();
@@ -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<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 ?? []).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),
})),
};
}
}
+30
View File
@@ -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;
@@ -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;
@@ -25,7 +25,16 @@ function walk(dir) {
const pat = /from\s+['"]([^'"]+)['"]/g;
const n = c.replace(pat, (m, spec) => {
if (spec.startsWith("@/")) {
const target = join("dist", spec.slice(2)) + ".js";
// Source may already carry an extension (e.g. "@/shared/config/index.js");
// only append ".js" when the specifier has none — otherwise we'd
// produce "index.js.js".
const core = spec.slice(2);
let target;
if (/\.(js|json|node|mjs|cjs)$/.test(core)) {
target = join("dist", core);
} else {
target = join("dist", core) + ".js";
}
let rel = relative(dirname(p), target);
if (!rel.startsWith(".")) rel = "./" + rel;
return `from "${rel}"`;
@@ -22,11 +22,11 @@
* the DB as fast read caches, so repeat lookups are effectively free;
* - lookups per batch are bounded (AI_GLOSSARY_MAX_TERMS);
* - live Wikipedia calls are rate-limit aware: concurrency 2 + stagger, retry
* once on empty results, and misses cached for only 1h so a limiter/
* once on empty results, and misses cached for only 1h so a limiter or
* network blip is not treated as a permanent miss;
* - only results that read like actual definitions are accepted (Wikipedia
* preferred; disambiguation/ads/translate-homepages rejected);
* - everything degrades gracefully: no Redis, no SearXNG, no match
* - everything degrades gracefully: no Redis, no Wikipedia API, no match
* → the block is simply omitted and moderation proceeds as before.
*/
@@ -258,7 +258,7 @@ export function extractGlossaryTerms(
}
// ---------------------------------------------------------------------------
// Definition lookup (cached: LRU → Redis → SearXNG/Wikipedia)
// ─── Definition lookup (cached: LRU → Redis → Wikipedia) ───────────────────
// ---------------------------------------------------------------------------
export interface TermDefinition {
@@ -1,7 +1,7 @@
import type { Readable } from "node:stream";
import type { StreamType } from "@discordjs/voice";
export type MediaMode = "music";
export type MediaMode = "music" | "screenshare";
export type MediaSourceKind =
| "url"
| "local"
@@ -18,6 +18,7 @@ import {
type RecordingSession,
} from "./recorder/sessionRecording.js";
import { createSpeakingHandler } from "./recorder/speakingHandler.js";
import { hookScreenShareAudio } from "./screenShareAudio.js";
const logger = createChildLogger("recorder");
@@ -146,6 +147,13 @@ export async function startRecording(
receiver.speaking.on("start", speakingHandler);
// ── Screen-share audio capture ──────────────────────────────────────
// Discord GoLive sends screen-share audio on a SEPARATE SSRC from the
// user's microphone. `receiver.speaking` only fires for voice (mic) SSRCs,
// so screen-share audio is silently dropped unless we hook the UDP receiver
// to discover and register those SSRCs.
hookScreenShareAudio(receiver, speakingHandler);
// Handle unexpected disconnection
connection.on(VoiceConnectionStatus.Disconnected, async () => {
if (config.VERBOSE) {
@@ -0,0 +1,222 @@
import type { VoiceReceiver, VoiceUserData } from "@discordjs/voice";
import { createChildLogger } from "@/shared/logger/index";
const logger = createChildLogger("screen-share-audio");
/**
* Hooks screen-share audio capture into the voice receiver.
*
* ## Background
*
* Discord GoLive (screen share) sends audio on **separate SSRCs** from the
* user's microphone. In `@discordjs/voice` v0.19, `VoiceReceiver.onUdpMessage`
* does:
*
* ```js
* const userData = this.ssrcMap.get(ssrc);
* if (!userData) return; // ← DROPS screen-share audio SSRC
* ```
*
* `ssrcMap` is only populated from `VOICE_STATE_UPDATE` / `VOICE_SERVER_UPDATE`
* WebSocket packets, which carry the **voice audioSSRC** only. When a user
* starts an audio+video screen-share, Discord sends additional RTP packets on
* new SSRCs that are *never* registered in `ssrcMap` → they are silently
* discarded → `receiver.speaking` never fires → screen-share audio is missing.
*
* ## Fix
*
* 1. Wrap `onUdpMessage` to inspect every incoming RTP packet's SSRC.
* 2. If the SSRC isn't in `ssrcMap`, check whether it looks like a screen-share
* audio stream (OPRUS payload type 120, RTP version 2).
* 3. Clone the owning user's VoiceUserData into `ssrcMap` under the new SSRC
* so the *original* (un-patched) `onUdpMessage` picks it up, decrypts it,
* and forwards the Opus packet to the existing subscription stream.
* 4. Emit a synthetic `"start"` speaking event so the existing
* `speakingHandler` sets up the full pipeline (decoder, packet filter,
* segment manager, event handlers) for that userId if not already.
*/
export function hookScreenShareAudio(
receiver: VoiceReceiver,
speakingHandler: (userId: string) => Promise<void>,
): void {
// Cache the original (un-bound) method so we can delegate to it.
const original = receiver.onUdpMessage;
receiver.onUdpMessage = (msg: Buffer) => {
// ── 1. Detect screen-share SSRCs BEFORE the original discards them ──
if (isLikelyScreenShareAudio(msg, receiver)) {
const ssrc = msg.readUInt32BE(8);
const userData = getSsrcMapEntry(receiver, ssrc);
if (!userData) {
// SSRC not registered — try to infer owner and register it
const owner = inferScreenShareOwner(ssrc, receiver);
if (owner) {
registerScreenShareSsrc(receiver, ssrc, owner);
logger.info(
{ userId: owner.userId, ssrc, kind: "screenshare-audio" },
"Registered screen-share audio SSRC in ssrcMap",
);
// Trigger the speaking handler to ensure pipeline is ready
void speakingHandler(owner.userId).catch((err) =>
logger.error(
{ userId: owner.userId, error: err.message },
"Speaking handler for screen-share failed",
),
);
} else {
logger.warn(
{ ssrc },
"Screen-share audio SSRC found but owner unknown",
);
}
}
}
// ── 2. Delegate to the original handler ──
// It will now find the SSRC (we registered it above) and forward the
// decrypted Opus packet to the subscription stream.
original.call(receiver, msg);
};
// ── 3. Listen for dynamic ssrcMap updates ──────────────────────────────
receiver.ssrcMap.on("create", (data: VoiceUserData) => {
if (data.videoSSRC !== undefined) {
logger.info(
{ userId: data.userId, videoSSRC: data.videoSSRC },
"Screen-share video started",
);
void speakingHandler(data.userId).catch((err) =>
logger.error(
{ userId: data.userId, error: err.message },
"Handler for screen-share start failed",
),
);
}
});
receiver.ssrcMap.on(
"update",
(_old: VoiceUserData | undefined, neu: VoiceUserData) => {
if (_old?.videoSSRC !== neu.videoSSRC && neu.videoSSRC !== undefined) {
logger.info(
{ userId: neu.userId, videoSSRC: neu.videoSSRC },
"Screen-share video SSRC appeared",
);
void speakingHandler(neu.userId).catch((err) =>
logger.error(
{ userId: neu.userId, error: err.message },
"Handler for screen-share update failed",
),
);
}
},
);
}
// ─── Helpers ───────────────────────────────────────────────────────────────
/**
* Check if a UDP packet looks like a screen-share audio RTP packet.
*
* Voice packets have: RTP version 2 (top 2 bits of byte 0), and payload type 120 (OPRUS).
* We also require that the SSRC is NOT already in ssrcMap (that's handled
* by the original onUdpMessage).
*/
function isLikelyScreenShareAudio(
msg: Buffer,
receiver: VoiceReceiver,
): boolean {
if (msg.length <= 8) return false;
const ssrc = msg.readUInt32BE(8);
// Already registered as a known voice SSRC?
if (getSsrcMapEntry(receiver, ssrc)) return false;
const rtpVersion = msg[0] >> 6;
const payloadType = msg[1] & 127;
// OPRUS payload type is 120 in Discord voice
return rtpVersion === 2 && payloadType === 120;
}
/**
* Safely read an entry from the SSRCMap via the public `get` API.
*/
function getSsrcMapEntry(
receiver: VoiceReceiver,
ssrc: number,
): VoiceUserData | undefined {
try {
return receiver.ssrcMap.get(ssrc);
} catch {
return undefined;
}
}
/**
* Infer which user owns a screen-share audio SSRC by proximity to their
* known voice audioSSRC (Discord allocates SSRCs in small increments).
*/
function inferScreenShareOwner(
ssrc: number,
receiver: VoiceReceiver,
): { userId: string } | null {
try {
// Iterate known SSRCs via internal _map (the public API only gets by ssrc)
const map = getSsrcInternalMap(receiver.ssrcMap);
if (!map) return null;
for (const [, data] of map.entries()) {
if (data.audioSSRC && Math.abs(data.audioSSRC - ssrc) < 200_000) {
return { userId: data.userId };
}
}
return null;
} catch {
return null;
}
}
/**
* Register a new SSRC in the internal ssrcMap so the original onUdpMessage
* picks it up. We clone the user's existing VoiceUserData (so decryption
* keys, userId mapping, etc. all work) under the new SSRC key.
*/
function registerScreenShareSsrc(
receiver: VoiceReceiver,
ssrc: number,
owner: { userId: string },
): void {
const map = getSsrcInternalMap(receiver.ssrcMap);
if (!map) return;
// Find the owner's existing VoiceUserData and clone it under the new SSRC
for (const [, data] of map.entries()) {
if (data.userId === owner.userId) {
map.set(ssrc, { ...data });
return;
}
}
// Fallback: register with minimal data (userId only)
map.set(ssrc, {
userId: owner.userId,
audioSSRC: ssrc,
});
}
/**
* Access the private `_map` inside an SSRCMap instance.
*
* SSRCMap in @discordjs/voice ≤ v0.19 stores its entries in a private
* `#map` (JS private field) or `_map` depending on the build target.
* We access it defensively for read + write so we can register new SSRCs.
*/
function getSsrcInternalMap(
ssrcMap: VoiceReceiver["ssrcMap"],
): Map<number, VoiceUserData> | undefined {
const asAny = ssrcMap as unknown as Record<string, unknown>;
// v0.19 ESM build uses _map
const m1 = asAny._map;
if (m1 instanceof Map) return m1 as Map<number, VoiceUserData>;
return undefined;
}
@@ -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 (
<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>
);
}
@@ -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<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" });
}, [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<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 &amp; 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>
);
}
@@ -0,0 +1,133 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { PageTransition } from "@/components/shared";
import { Button, Input, Textarea, GlassCard } from "@/components/primitives";
import { Save, ArrowLeft } from "lucide-react";
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 className="text-sm font-medium mb-1 block">Judul *</label>
<Input
value={form.title}
onChange={(e) => update("title", e.target.value)}
placeholder="Judul materi"
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Deskripsi</label>
<Input
value={form.description ?? ""}
onChange={(e) => update("description", e.target.value)}
placeholder="Deskripsi singkat"
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Kategori</label>
<Input
value={form.category}
onChange={(e) => update("category", e.target.value)}
placeholder="general"
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Tags (pisahkan dengan koma)</label>
<Input
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder="wibu, discord, moderation"
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Konten *</label>
<Textarea
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>
);
}
@@ -0,0 +1,111 @@
import { PageTransition } from "@/components/shared";
import { Badge, Button, GlassCard, Input } from "@/components/primitives";
import { Plus, BookOpen, MessageSquare, Search } from "lucide-react";
import { listMateriSSR } from "@/lib/api/materi";
import type { MateriDocument } from "@/lib/types/materi";
import Link from "next/link";
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 &amp; 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>
);
}
+26 -6
View File
@@ -93,7 +93,7 @@ export function useLoadMore() {
(old: MessagePage | undefined): MessagePage | undefined =>
old
? {
data: [...old.data, ...result.data],
data: sortMessages([...old.data, ...result.data]),
nextCursor: result.nextCursor,
}
: result,
@@ -201,6 +201,17 @@ export function useSemanticSearch(query: string, enabled: boolean) {
// ── WS sync helpers ──────────────────────────────
/**
* Sort messages newest-first (descending by created_at). The message list is
* stored newest-first in SWR data (the view reverses it for display), so every
* WS insert must maintain this order regardless of arrival order. Without this,
* out-of-order `message_created` / `message_snapshot` frames produce a
* scrambled feed.
*/
function sortMessages(msgs: MessageRecord[]): MessageRecord[] {
return [...msgs].sort((a, b) => b.created_at - a.created_at);
}
export function useMessagesWsSync(ws: WsHook, guildId: string) {
const { mutate } = useSWRConfig();
useEffect(() => {
@@ -237,7 +248,8 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) {
const msg = data as MessageRecord;
patchLists(
(_k, m) => matchesFilter(_k as unknown[], m),
(old) => (old ? { ...old, data: [msg, ...old.data] } : old),
(old) =>
old ? { ...old, data: sortMessages([msg, ...old.data]) } : old,
msg,
);
});
@@ -254,8 +266,8 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) {
old
? {
...old,
data: old.data.map((m) =>
m.id === msg.id ? { ...m, ...msg } : m,
data: sortMessages(
old.data.map((m) => (m.id === msg.id ? { ...m, ...msg } : m)),
),
}
: old,
@@ -283,7 +295,12 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) {
(_k, m) => matchesFilter(_k as unknown[], m),
(old) =>
old
? { ...old, data: old.data.map((m) => (m.id === msg.id ? msg : m)) }
? {
...old,
data: sortMessages(
old.data.map((m) => (m.id === msg.id ? msg : m)),
),
}
: old,
msg,
);
@@ -331,7 +348,10 @@ export function useMessagesStream(
const data2 = old?.data ?? [];
if (data2.some((m) => m.id === msg.id))
return old ?? { data: [], nextCursor: null };
return { data: [msg, ...data2], nextCursor: old?.nextCursor ?? null };
return {
data: sortMessages([msg, ...data2]),
nextCursor: old?.nextCursor ?? null,
};
},
{ revalidate: false },
);
+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;
}>;
}