feat: multimodal video detection + reply/forward/crosspost + batch optimization

- Video frame extraction via ffmpeg (4 key frames per video → vision LLM)
- Video display in FE MessageCard with HTML5 <video> player
- Reply/forward/crosspost indicator in FE + pipeline in DG/BE
- Fix: missing sanitizeAiContent + escapeXml in media path (prompt injection)
- Optimize: text-only batch results saved to DB immediately, no longer wait for media analysis
- BE mapper/schema/repo: add reference fields (is_reply, is_forward, etc.)
This commit is contained in:
MythEclipse
2026-06-22 09:46:06 +07:00
parent dc62b283c0
commit f502918b27
7 changed files with 261 additions and 48 deletions
@@ -134,6 +134,12 @@ export class MessagesRepository {
type: data.type ?? "text",
metadata: null,
ai_status: "pending",
is_reply: data.isReply ?? false,
is_forward: data.isForward ?? false,
is_crosspost: data.isCrosspost ?? false,
reference_message_id: data.referenceMessageId ?? null,
reference_channel_id: data.referenceChannelId ?? null,
reference_guild_id: data.referenceGuildId ?? null,
})
.returning();
@@ -260,6 +266,12 @@ export class MessagesRepository {
ai_severity: pgMessagesTable.ai_severity,
ai_confidence: pgMessagesTable.ai_confidence,
ai_analysis: pgMessagesTable.ai_analysis,
is_reply: pgMessagesTable.is_reply,
is_forward: pgMessagesTable.is_forward,
is_crosspost: pgMessagesTable.is_crosspost,
reference_message_id: pgMessagesTable.reference_message_id,
reference_channel_id: pgMessagesTable.reference_channel_id,
reference_guild_id: pgMessagesTable.reference_guild_id,
})
.from(pgMessagesTable)
.where(and(...conditions))
@@ -19,6 +19,12 @@ export const messageCreateSchema = z.object({
avatarUrl: z.string().optional(),
content: z.string(),
type: z.enum(["text", "edited", "deleted"]).default("text"),
isReply: z.boolean().optional(),
isForward: z.boolean().optional(),
isCrosspost: z.boolean().optional(),
referenceMessageId: z.string().optional(),
referenceChannelId: z.string().optional(),
referenceGuildId: z.string().optional(),
});
export const messageUpdateSchema = z.object({
@@ -25,6 +25,12 @@ export interface MappedMessage {
ai_recommended_action: string | null;
ai_analyzed_at: number | null;
ai_error: string | null;
is_reply: boolean | null;
is_forward: boolean | null;
is_crosspost: boolean | null;
reference_message_id: string | null;
reference_channel_id: string | null;
reference_guild_id: string | null;
}
export function mapMessageRow(row: Record<string, unknown>): MappedMessage {
@@ -53,5 +59,11 @@ export function mapMessageRow(row: Record<string, unknown>): MappedMessage {
ai_recommended_action: (row.ai_recommended_action as string | null) ?? null,
ai_analyzed_at: (row.ai_analyzed_at as number | null) ?? null,
ai_error: (row.ai_error as string | null) ?? null,
is_reply: row.is_reply === null ? null : Boolean(row.is_reply),
is_forward: row.is_forward === null ? null : Boolean(row.is_forward),
is_crosspost: row.is_crosspost === null ? null : Boolean(row.is_crosspost),
reference_message_id: (row.reference_message_id as string | null) ?? null,
reference_channel_id: (row.reference_channel_id as string | null) ?? null,
reference_guild_id: (row.reference_guild_id as string | null) ?? null,
};
}
@@ -10,6 +10,7 @@ import type {
AnalysisResult,
MessageRecord,
} from "../message-capture/types.js";
import { extractMessageMediaEvidence } from "../message-capture/messageMetadata.js";
import { buildConversationContext } from "./conversationContext.js";
import {
runModerationAnalysis,
@@ -159,36 +160,92 @@ async function processBatch(job: {
const allMessageIds = [...targetIds, ...contextIds];
const attachments = await getAttachmentsForMessages(allMessageIds);
const result = await runModerationAnalysis({
targets: messages,
contextText: contextLines.join("\n"),
attachments,
});
// ── Split: text-only vs media ──────────────────────────────────────
// Text-only analysis runs fast (single LLM call, no vision).
// Media analysis is slow (download + vision → LLM).
// By splitting here, text results are saved to DB immediately
// instead of waiting for media downloads to finish.
// ────────────────────────────────────────────────────────────────────
const textOnly: MessageRecord[] = [];
const media: MessageRecord[] = [];
const updates = result.results.map((analysisResult) => ({
messageId: analysisResult.messageId,
result: {
status: analysisResult.status,
flags: JSON.stringify(analysisResult.flags),
score: analysisResult.score,
analysis: analysisResult.analysis,
categories: analysisResult.categories,
severity: analysisResult.severity,
confidence: analysisResult.confidence,
recommendedAction: analysisResult.recommendedAction,
analyzedAt: Date.now(),
error: null,
},
}));
try {
const rows = await updateMessagesAIAnalysisBulk(updates);
return { ok: true, conversationKey, rows };
} catch (dbErr) {
throw new Error(
`Failed to update DB: ${dbErr instanceof Error ? dbErr.message : String(dbErr)}`,
);
for (const msg of messages) {
const meta = msg.metadata ? extractMessageMediaEvidence(msg.metadata) : null;
if (meta && (meta.attachments.length > 0 || meta.stickers.length > 0 || meta.embeds.length > 0)) {
media.push(msg);
} else {
textOnly.push(msg);
}
}
const allRows: MessageRecord[] = [];
// Phase 1: Text-only → save immediately (fast)
if (textOnly.length > 0) {
const textResult = await runModerationAnalysis({
targets: textOnly,
contextText: contextLines.join("\n"),
attachments,
});
const textUpdates = textResult.results.map((analysisResult) => ({
messageId: analysisResult.messageId,
result: {
status: analysisResult.status,
flags: JSON.stringify(analysisResult.flags),
score: analysisResult.score,
analysis: analysisResult.analysis,
categories: analysisResult.categories,
severity: analysisResult.severity,
confidence: analysisResult.confidence,
recommendedAction: analysisResult.recommendedAction,
analyzedAt: Date.now(),
error: null,
},
}));
if (textUpdates.length > 0) {
const rows = await updateMessagesAIAnalysisBulk(textUpdates);
allRows.push(...rows);
logger.info(
{ count: textUpdates.length, conversationKey },
"Text-only batch saved — media analysis still in progress",
);
}
}
// Phase 2: Media → save when done (slow: download + vision)
if (media.length > 0) {
const mediaResult = await runModerationAnalysis({
targets: media,
contextText: contextLines.join("\n"),
attachments,
});
const mediaUpdates = mediaResult.results.map((analysisResult) => ({
messageId: analysisResult.messageId,
result: {
status: analysisResult.status,
flags: JSON.stringify(analysisResult.flags),
score: analysisResult.score,
analysis: analysisResult.analysis,
categories: analysisResult.categories,
severity: analysisResult.severity,
confidence: analysisResult.confidence,
recommendedAction: analysisResult.recommendedAction,
analyzedAt: Date.now(),
error: null,
},
}));
if (mediaUpdates.length > 0) {
const rows = await updateMessagesAIAnalysisBulk(mediaUpdates);
allRows.push(...rows);
}
}
logger.info(
{ total: messages.length, textOnly: textOnly.length, media: media.length, saved: allRows.length },
"Batch analysis complete",
);
return { ok: true, conversationKey, rows: allRows };
}
// ---------------------------------------------------------------------------
@@ -1,4 +1,9 @@
import { execFile } from "node:child_process";
import { createChildLogger } from "@bete/shared/logger";
import { readFile, writeFile, unlink, rm, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { delay, retryWithBackoff } from "@bete/shared/utils";
import { LRUCache } from "lru-cache";
import type { ChatCompletion } from "openai/resources/chat/completions";
@@ -864,7 +869,7 @@ async function runTextOnlyBatch(
.map((url) => {
const fetchedText = urlFetchMap.get(url);
if (!fetchedText) return null;
return `<web_content url="${url}">${fetchedText}</web_content>`;
return `<web_content url="${escapeXml(url)}">${escapeXml(fetchedText)}</web_content>`;
})
.filter(Boolean)
.join("\n");
@@ -876,7 +881,7 @@ async function runTextOnlyBatch(
const profileLine = userProfileCtx ? `\n ${userProfileCtx}` : "";
const refXml = await buildReferenceXml(msg);
const refLine = refXml ? `\n ${refXml}` : "";
return `<message id="${msg.id}" user="${msg.username}">\n ${userCtx}${profileLine}${refLine}\n <content>${content}</content>${webContext}\n</message>`;
return `<message id="${msg.id}" user="${msg.username}">\n ${userCtx}${profileLine}${refLine}\n <content>${escapeXml(content)}</content>${webContext}\n</message>`;
}),
).then((blocks) => blocks.join("\n"));
@@ -1008,7 +1013,7 @@ async function prepareMediaMessage(
(att) =>
att.message_id === targetId &&
(att.uploaded_url ?? att.discord_url ?? null) &&
att.type.startsWith("image/"),
(att.type.startsWith("image/") || att.type.startsWith("video/")),
)
.slice(0, 8);
@@ -1092,12 +1097,12 @@ async function prepareMediaMessage(
const userCtx = `<user_reputation trust_score="${rep.trust_score}" />`;
const profile = await getUserProfile(target.user_id);
const userProfileCtx = profile
? `\n <user_profile>${profile.profile_summary}</user_profile>`
? `\n <user_profile>${sanitizeAiContent(profile.profile_summary)}</user_profile>`
: "";
const refXml = await buildReferenceXml(target);
const refLine = refXml ? `\n ${refXml}` : "";
const messageBlock = `<message id="${target.id}" user="${target.username}">\n ${userCtx}${userProfileCtx}${refLine}\n <content>${content}</content>${mediaContext ? ` ${mediaContext}` : ""}${webContext}${mediaAnalysisContext}\n</message>`;
const messageBlock = `<message id="${escapeXml(target.id)}" user="${escapeXml(target.username)}">\n ${userCtx}${userProfileCtx}${refLine}\n <content>${escapeXml(content)}</content>${mediaContext ? ` ${escapeXml(mediaContext)}` : ""}${webContext}${mediaAnalysisContext}\n</message>`;
return { targetId, messageBlock };
}
@@ -1745,6 +1750,68 @@ async function downloadSingleAttachment(
const imageBytes = Buffer.concat(chunks);
const sniffedMime = sniffImageMimeType(imageBytes);
if (!sniffedMime && att.type.startsWith("video/")) {
// ── Video frame extraction via ffmpeg ──
const execFileAsync = promisify(execFile);
const tmpDir = await mkdtemp(path.join(tmpdir(), "bete-video-"));
const inputPath = path.join(tmpDir, att.filename || "video.mp4");
const outputPattern = path.join(tmpDir, "frame-%03d.jpg");
try {
await writeFile(inputPath, imageBytes);
// Get video duration via ffprobe, then extract 4 evenly-spaced frames
const { stdout: durationStr } = await execFileAsync("/usr/bin/ffprobe", [
"-v", "error",
"-show_entries", "format=duration",
"-of", "csv=p=0",
inputPath,
], { timeout: 10000 });
const duration = parseFloat(durationStr.trim()) || 1;
// fps = 3/duration gives exactly 4 frames at 0, dur/3, 2*dur/3, dur
const fps = (3 / duration).toFixed(6);
await execFileAsync("/usr/bin/ffmpeg", [
"-i", inputPath,
"-vf", `fps=${fps}`,
"-frames:v", "4",
"-vsync", "vfr",
"-q:v", "2",
outputPattern,
], { timeout: 30000 });
// Read extracted frames and add to imageMap
for (let i = 1; i <= 4; i++) {
const framePath = path.join(tmpDir, `frame-${String(i).padStart(3, "0")}.jpg`);
try {
const frameBytes = await readFile(framePath);
const { data: resizedBuffer, mimeType: resizedMime } =
await resizeImageForVision(frameBytes, maxDimension);
const dataUrl = `data:${resizedMime};base64,${resizedBuffer.toString("base64")}`;
const part: MessageImagePart = {
type: "image_url",
image_url: { url: dataUrl },
sourceLabel: `[frame ${i}/4 dari video ${att.filename} (attachment), pesan id=${att.message_id}]`,
};
addImageToMap(imageMap, targetId, part);
} catch {
// Frame may not exist if video is short; skip silently
}
}
log.info({ attachmentId: att.id, frameCount: 4 }, "Extracted video frames for vision analysis");
} catch (ffmpegErr) {
log.warn(
{ attachmentId: att.id, error: ffmpegErr instanceof Error ? ffmpegErr.message : String(ffmpegErr) },
"Failed to extract video frames with ffmpeg — skipping video",
);
} finally {
// Cleanup temp files
try { await unlink(inputPath); } catch { /* ignore */ }
for (let i = 1; i <= 4; i++) {
try { await unlink(path.join(tmpDir, `frame-${String(i).padStart(3, "0")}.jpg`)); } catch { /* ignore */ }
}
try { await rm(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
}
return;
}
if (!sniffedMime) {
log.warn(
{ attachmentId: att.id },
@@ -1752,7 +1819,6 @@ async function downloadSingleAttachment(
);
return;
}
const { data: resizedBuffer, mimeType: resizedMime } =
await resizeImageForVision(imageBytes, maxDimension);
@@ -55,7 +55,10 @@ Pedoman ini mencerminkan nilai-nilai yang dijunjung server. Terapkan dengan bija
- Pelecehan, rasisme, seksisme, dan segala bentuk diskriminasi lainnya tidak ditoleransi.
- Perbedaan pendapat itu wajar. Serangan personal, penghinaan, dan merendahkan orang lain tidak.
- **Toxicity pada Entitas Eksternal:** Makian/trash-talk emosional yang ditujukan pada benda mati, game, karakter fiksi, perusahaan, atau konsep (mis. "game ini ampas", "dev bodoh") adalah AMAN. Harassment dan hate_speech HANYA berlaku jika ditujukan pada anggota/kelompok server secara personal.
### Deteksi SARA Agama / Penistaan / Parodi PRIORITAS TINGGI (WAJIB FLAG)
### LARANGAN BERAT LGBT, ISRAEL/PALESTINA, YAHUDI
- **LGBT ZERO TOLERANCE:** Komunitas ini secara eksplisit TIDAK memberikan ruang dan toleransi terhadap segala bentuk perilaku menyimpang. Ini termasuk, namun tidak terbatas pada, promosi, diskusi, dan penggambaran aktivitas atau ideologi LGBT. WAJIB DI-FLAG sebagai "sexual_deviation". Tidak ada pengecualian untuk "pengakuan orientasi", "coming out", atau "curhat personal".
- **Topik Israel/Palestina/Yahudi:** Segala bentuk diskusi, pembahasan, opini, berita, konten pro/kontra, atau komentar apapun tentang Israel, Palestina, atau Yahudi WAJIB DI-HAPUS. Tidak ada diskusi, tidak ada debat, tidak ada berita ZERO TOLERANCE. Flag sebagai "sara" atau "conflict_instigation" dengan severity high/critical. JANGAN PERNAH anggap sebagai "diskusi politik normal" atau "berita" topik ini dilarang total di server.
### Deteksi SARA Agama
SARA (Suku, Agama, Ras, Antargolongan) adalah konsep hukum dan budaya Indonesia yang sangat serius. Pelanggaran SARA agama adalah salah satu pelanggaran paling berat di server ini. Berikut adalah kategori deteksi WAJIB:
**1. PARODI AYAT / KITAB SUCI PALSU WAJIB DI-FLAG**
@@ -142,7 +145,7 @@ Prioritas menengah (PERILAKU MERUSAK):
Prioritas rendah (PELANGGARAN RINGAN):
- harassment (targeted insult), vulgar_language (profanity terarah)
- sexual_deviation: HANYA flag jika pesan secara EKSPLISIT mempromosikan/mengajak/membahas aktivitas seksual menyimpang atau fetish (mis. "DM aja kalo mau konten 18+", "link bokep", "jual video seks"). Karakter hewan fiksi antropomorfik normal (seperti Sonic, Pokemon, Lucario, maskot anime) adalah BUKAN referensi furry fetish dalam konteks apapun tanpa bukti seksual eksplisit.
- sexual_deviation: DUAL MODE. (A) LGBT/Penyimpangan orientasi seksual WAJIB FLAG server zero tolerance terhadap segala diskusi/pengakuan/promosi LGBT. (B) Fetish/aktivitas seksual eksplisit flag jika secara EKSPLISIT mempromosikan/mengajak (mis. "DM aja kalo mau konten 18+", "link bokep", "jual video seks"). Karakter hewan fiksi antropomorfik normal (seperti Sonic, Pokemon, Lucario, maskot anime) adalah BUKAN referensi furry fetish dalam konteks apapun tanpa bukti seksual eksplisit.
- **ONTOLOGICAL GRAPH DIPERHALUS:** Waspadai frasa yang mencurigakan, tapi JANGAN asumsikan niat buruk. Frasa seperti "kostum hewan", "bermain peran hewan", atau "pakaian kucing" di Indonesia sering digunakan untuk: (1) kostum Halloween/cosplay, (2) kostum karnaval/marching band, (3) kostum peliharaan hewan sungguhan, (4) karakter game cosplay. **JANGAN FLAG** hanya karena mengandung kata "hewan" + "kostum". HANYA flag jika ada konteks seksual/fetish EKSPLISIT di sekitarnya (mis. "DM buat foto pake kostum hewan, khusus dewasa 18+"). Jika tidak yakin CLEAN.
- Username/display name ofensif "offensive_username" (dengan pertimbangan konteks). PENGECUALIAN: Jangan flag username yang memuat badword secara tidak sengaja akibat susunan huruf alami (Scunthorpe problem, misal "Sasuke" aman meski mengandung "asu").
@@ -158,14 +161,16 @@ Prioritas rendah (PELANGGARAN RINGAN):
## Pohon Keputusan (Decision Tree)
1. Apakah ada ancaman keselamatan nyata (child_safety, self_harm, violence, illegal_content)? flagged, critical
2. Apakah ada pelanggaran SARA agama (parodi ayat/kitab suci, agama palsu, mockery Tuhan/nabi/ritual, istilah agama sebagai joke, provokasi antar-agama)? flagged, high/critical. **JANGAN PERNAH menganggap parodi agama sebagai "clean" atau hanya "warn".**
3. Apakah ada konten ilegal/explicit (NSFW, drugs, gambling, scam, nsfw_image)? flagged, high
4. Apakah ada harassment terarah/hate speech/sara lainnya/diskriminasi (seksisme, ageisme, rasisme)? flagged, medium-high
5. Apakah ada sexual_deviation (ajakan/foto/video seksual eksplisit, link bokep, jual konten 18+, fetish)? flagged, medium
6. Apakah ada conflict_instigation (memancing drama/keributan)? warn, low-medium
7. Apakah ada username ofensif? warn, low (kecuali diperkuat isi pesan)
8. Apakah ada spam/promosi borderline? warn, low-medium
9. Jika tidak ada pelanggaran jelas atau bukti ambigu karena murni kurang konteks historis clean
10. **ENTROPY-TRIGGERED ROUTING (DIPERHALUS):** Jika teks terasa "acak", terfragmentasi, atau sulit dipahami, JANGAN LANGSUNG ANGGAP sebagai teknik evasi. Situasi berikut AMAN:
3. Apakah konten membahas LGBT (orientasi, coming out, promosi, diskusi, aktivitas)? flagged sebagai "sexual_deviation", high/critical. ZERO TOLERANCE.
4. Apakah konten membahas Israel, Palestina, atau Yahudi dalam bentuk apapun? flagged sebagai "sara" dan/atau "conflict_instigation", critical. ZERO TOLERANCE.
5. Apakah ada konten ilegal/explicit (NSFW, drugs, gambling, scam, nsfw_image)? flagged, high
6. Apakah ada harassment terarah/hate speech/sara lainnya/diskriminasi (seksisme, ageisme, rasisme)? flagged, medium-high
7. Apakah ada sexual_deviation fetish (ajakan/foto/video seksual eksplisit, link bokep, jual konten 18+, fetish)? flagged, medium
8. Apakah ada conflict_instigation (memancing drama/keributan)? warn, low-medium
9. Apakah ada username ofensif? warn, low (kecuali diperkuat isi pesan)
10. Apakah ada spam/promosi borderline? warn, low-medium
11. Jika tidak ada pelanggaran jelas atau bukti ambigu karena murni kurang konteks historis clean
12. **ENTROPY-TRIGGERED ROUTING (DIPERHALUS):** Jika teks terasa "acak", terfragmentasi, atau sulit dipahami, JANGAN LANGSUNG ANGGAP sebagai teknik evasi. Situasi berikut AMAN:
- **Kode/programming:** Campuran kode dan bahasa alami, log error, stack trace, output console, query SQL, JSON, regex, path file AMAN.
- **Percakapan multilingual alami:** Campuran bahasa Indonesia, Inggris, dan daerah adalah hal umum di komunitas ini AMAN.
- **Pesan terpotong/terpecah:** Pesan yang terpotong karena karakter limit Discord atau koneksi tidak stabil AMAN.
@@ -245,7 +250,12 @@ Vision model TIDAK memutuskan apakah gambar melanggar atau tidak — ia hanya me
- Sticker sering bersifat humor, satir, atau ekspresi emosi yang dilebih-lebihkan.
- Gambar sticker bisa menampilkan adegan kartun yang terlihat "keras" itu SENI KARTUN, bukan dokumentasi kekerasan nyata.
- Nama sticker yang terdengar provokatif (mis. "Singa injek pejabat") adalah konteks satir/humor. JANGAN flag berdasarkan nama sticker saja.
- Terapkan standar yang lebih longgar untuk konten kartun/meme dibanding foto/video nyata.`;
- Terapkan standar yang lebih longgar untuk konten kartun/meme dibanding foto/video nyata.
## Panduan Khusus Video
- Video attachments: WAJIB di-analisis frame-by-frame oleh vision model. Jika ada frame yang menunjukkan konten melanggar (NSFW, SARA, kekerasan, judi), flag sesuai kategori. Video durasi pendek (30 detik) dapat dideteksi dari beberapa frame kunci.
- Deskripsi video dari vision model mungkin berisi rincian frame. Gunakan itu sebagai bukti utama, sama seperti deskripsi gambar.
- Video tanpa deskripsi dari vision model tetap harus dinilai berdasarkan konteks teks pesan.`;
// ---------------------------------------------------------------------------
// Section: Few-Shot Examples — single-source array, derived per-mode strings
@@ -332,11 +342,11 @@ const ALL_EXAMPLES: ExampleDef[] = [
},
{
id: "19",
title: "Diskusi orientasi seksual (dilarang sesuai kebijakan server)",
title: "Diskusi orientasi seksual LGBT (dilarang — zero tolerance)",
input:
'[target] id=17174 user=alex: btw gw gay, semoga ga masalah ya. Gw cmn mau jujur aja',
output:
'{"results":[{"message_id":"17174","status":"flagged","flags":["sexual_deviation"],"score":0.7,"categories":["sexual_deviation"],"severity":"medium","confidence":0.85,"recommended_action":"warn","policy_version":"default-2026-05-30","evidence":["btw gw gay, semoga ga masalah ya"],"analysis":"Pengirim menyampaikan orientasi seksual LGBT. Sesuai kebijakan server, diskusi tentang LGBT tidak diperbolehkan dalam bentuk apapun. Diberi peringatan."}]}',
'{"results":[{"message_id":"17174","status":"flagged","flags":["sexual_deviation"],"score":0.85,"categories":["sexual_deviation"],"severity":"high","confidence":0.95,"recommended_action":"delete","policy_version":"default-2026-05-30","evidence":["btw gw gay, semoga ga masalah ya"],"analysis":"Pengirim menyebutkan orientasi LGBT. Berdasarkan kebijakan server, segala bentuk diskusi tentang LGBT dilarang — tidak ada toleransi untuk pengakuan orientasi, coming out, atau curhat personal. Dihapus."}]}',
modes: ["text", "mixed"],
},
@@ -552,6 +562,28 @@ const ALL_EXAMPLES: ExampleDef[] = [
'{"results":[{"message_id":"29292","status":"warn","flags":["spam"],"score":0.55,"categories":["spam"],"severity":"low","confidence":0.7,"recommended_action":"warn","policy_version":"default-2026-05-30","evidence":["https://discord.gg/xyzk123"],"analysis":"Pengirim mempromosikan server Discord lain melalui invite link di channel. Meskipun topik coding relevan, promosi server tanpa izin di channel publik berpotensi spam. Diberi peringatan."}]}',
modes: ["text", "media", "mixed"],
},
// ── New: LGBT zero tolerance ──
{
id: "30",
title: "LGBT coming out (dilarang — zero tolerance)",
input:
"[target] id=30303 user=rainbow: jujur aja gua lesbian, terima apa adanya",
output:
'{"results":[{"message_id":"30303","status":"flagged","flags":["sexual_deviation"],"score":0.9,"categories":["sexual_deviation"],"severity":"critical","confidence":0.95,"recommended_action":"delete","policy_version":"default-2026-05-30","evidence":["jujur aja gua lesbian, terima apa adanya"],"analysis":"Pengirim mengungkapkan orientasi lesbian. LGBT zero tolerance — segala bentuk diskusi/pengakuan orientasi LGBT dilarang di server ini. Dihapus."}]}',
modes: ["text", "media", "mixed"],
},
// ── New: Topik Israel/Palestina/Yahudi ──
{
id: "31",
title: "Diskusi Israel-Palestina (dilarang total)",
input:
"[target] id=31313 user=hot_takes: gw sih dukung palestina, israel biadab banget",
output:
'{"results":[{"message_id":"31313","status":"flagged","flags":["conflict_instigation","sara"],"score":0.95,"categories":["conflict_instigation","sara"],"severity":"critical","confidence":0.95,"recommended_action":"delete","policy_version":"default-2026-05-30","evidence":["gw sih dukung palestina, israel biadab banget"],"analysis":"Segala bentuk diskusi tentang Israel, Palestina, dan Yahudi dilarang total di server ini — tidak ada debat, dukungan, atau berita. Dihapus."}]}',
modes: ["text", "media", "mixed"],
},
];
// Derive per-mode strings from the single ALL_EXAMPLES array (zero duplication)
@@ -825,7 +857,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
parts.push(OUTPUT_INSTRUCTIONS);
// XML-delimited context — prevents prompt injection
const delimitedContext = `<conversation_context>\n${contextText}\n</conversation_context>`;
const delimitedContext = `<conversation_context>\n${sanitizeAiContent(contextText, 8000)}\n</conversation_context>`;
parts.push(delimitedContext);
let base = parts.join("\n\n");
@@ -10,6 +10,7 @@ import {
RotateCw,
Smile,
Trash2,
Video,
} from "lucide-react";
import { Fragment, useEffect, useMemo, useState } from "react";
import type { MessageRecord } from "../../../entities/message/types.js";
@@ -183,7 +184,13 @@ function MessageRow({
a.contentType?.startsWith("image/") ||
/\.(png|jpe?g|gif|webp)$/i.test(a.name),
);
const videoAttachments = attachments.filter(
(a) =>
a.contentType?.startsWith("video/") ||
/\.(mp4|webm|mov|mkv|avi)$/i.test(a.name),
);
const hasImages = imageAttachments.length > 0;
const hasVideos = videoAttachments.length > 0;
/** Hide the fallback text ("[Attachment: ...]", "[Sticker: ...]", "[Embed]") when the actual media IS already shown visually. */
const isFallbackText =
@@ -365,6 +372,27 @@ function MessageRow({
</div>
)}
{/* Attached videos */}
{hasVideos && (
<div className="flex gap-2 overflow-x-auto">
{videoAttachments.slice(0, 4).map((vid) => (
<video
key={vid.url}
src={vid.url}
controls
className="h-28 w-48 shrink-0 rounded-lg border border-border object-cover bg-black"
preload="metadata"
/>
))}
{videoAttachments.length > 4 && (
<div className="flex h-28 w-16 items-center justify-center rounded-lg border border-border bg-muted text-[11px] text-muted-foreground">
+{videoAttachments.length - 4}
<Video className="ml-0.5 h-3 w-3" />
</div>
)}
</div>
)}
{/* Categories */}
{categories.length > 0 && (
<div className="flex flex-wrap gap-1">