diff --git a/.claude/worktrees/agent-a3d811850036f998a b/.claude/worktrees/agent-a3d811850036f998a new file mode 160000 index 0000000..49d4bbf --- /dev/null +++ b/.claude/worktrees/agent-a3d811850036f998a @@ -0,0 +1 @@ +Subproject commit 49d4bbf7812498cd7a0d04592db46e8708c79758 diff --git a/.claude/worktrees/agent-aa8b5c864c11af9a4 b/.claude/worktrees/agent-aa8b5c864c11af9a4 new file mode 160000 index 0000000..49d4bbf --- /dev/null +++ b/.claude/worktrees/agent-aa8b5c864c11af9a4 @@ -0,0 +1 @@ +Subproject commit 49d4bbf7812498cd7a0d04592db46e8708c79758 diff --git a/.claude/worktrees/agent-aaadb24c021c240fb b/.claude/worktrees/agent-aaadb24c021c240fb new file mode 160000 index 0000000..49d4bbf --- /dev/null +++ b/.claude/worktrees/agent-aaadb24c021c240fb @@ -0,0 +1 @@ +Subproject commit 49d4bbf7812498cd7a0d04592db46e8708c79758 diff --git a/.claude/worktrees/agent-abdbb74fab15cf706 b/.claude/worktrees/agent-abdbb74fab15cf706 new file mode 160000 index 0000000..49d4bbf --- /dev/null +++ b/.claude/worktrees/agent-abdbb74fab15cf706 @@ -0,0 +1 @@ +Subproject commit 49d4bbf7812498cd7a0d04592db46e8708c79758 diff --git a/.claude/worktrees/agent-ace5d9f61d75c84e6 b/.claude/worktrees/agent-ace5d9f61d75c84e6 new file mode 160000 index 0000000..49d4bbf --- /dev/null +++ b/.claude/worktrees/agent-ace5d9f61d75c84e6 @@ -0,0 +1 @@ +Subproject commit 49d4bbf7812498cd7a0d04592db46e8708c79758 diff --git a/.claude/worktrees/agent-ad5e79034c2a9c199 b/.claude/worktrees/agent-ad5e79034c2a9c199 new file mode 160000 index 0000000..e3c4836 --- /dev/null +++ b/.claude/worktrees/agent-ad5e79034c2a9c199 @@ -0,0 +1 @@ +Subproject commit e3c48365c73efa7e7eec7b0ec78f7d12a218aa96 diff --git a/.claude/worktrees/agent-af0a2804fbe1b727c b/.claude/worktrees/agent-af0a2804fbe1b727c new file mode 160000 index 0000000..49d4bbf --- /dev/null +++ b/.claude/worktrees/agent-af0a2804fbe1b727c @@ -0,0 +1 @@ +Subproject commit 49d4bbf7812498cd7a0d04592db46e8708c79758 diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..6dabe2d --- /dev/null +++ b/.env.test @@ -0,0 +1,30 @@ +DISCORD_TOKEN=test_token_for_testing +RECORDINGS_DIR=./recordings +RECORDING_SEGMENT_MS=5000 +VERBOSE=false +DECODER_ROTATE_MS=5000 +DECODER_COOLDOWN_MS=30000 +AUDIO_STREAM_SILENCE_DURATION_MS=3000 +PACKET_FILTER_MIN_SIZE=8 +OPUS_FRAME_SIZE=960 +AUDIO_SAMPLE_RATE=48000 +AUDIO_CHANNELS=2 +AVATAR_SIZE=64 +WEBSERVER_PORT=3000 +VOICE_CONNECTION_TIMEOUT_MS=15000 +RECONNECT_TIMEOUT_MS=5000 +LOG_LEVEL=info +NODE_ENV=test +MONITOR_GUILD_ID=test_guild_id +PICSER_UPLOAD_URL=https://picser.asepharyana.tech/api/upload +ATTACHMENT_UPLOAD_TIMEOUT_MS=30000 +ATTACHMENT_MAX_SIZE_MB=100 +ATTACHMENT_RETRY_ATTEMPTS=3 +BACKLOG_SYNC_HOURS=24 +BACKLOG_SYNC_BATCH_SIZE=100 +AI_ANALYSIS_ENABLED=false +AI_LLM_API_KEY=test_key +AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1 +AI_LLM_MODEL=free +AI_ANALYSIS_TIMEOUT_MS=30000 +DATABASE_TYPE=sqlite diff --git a/docs/superpowers/plans/2026-05-14-ai-message-flow-react-dashboard.md b/docs/superpowers/plans/2026-05-14-ai-message-flow-react-dashboard.md new file mode 100644 index 0000000..cd24a9b --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-ai-message-flow-react-dashboard.md @@ -0,0 +1,1854 @@ +# AI Message Flow + React Dashboard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild message capture, AI moderation analysis, split APIs, and dashboard UI into a fast async backend plus React/Vite frontend. + +**Architecture:** Keep Discord capture fast by writing messages and enqueueing async analysis. Split backend modules into store, typed broadcaster, conversation context, queue, LLM client, and focused route files. Replace the static inline dashboard with a React/Vite app served by Express. + +**Tech Stack:** TypeScript, Node.js, Express, Drizzle ORM, Vitest, React, Vite, WebSocket, pnpm. + +--- + +## File Structure + +### Backend + +- Modify `src/moderation/types.ts` — shared message, query, analysis, and WebSocket event types. +- Create `src/moderation/broadcaster.ts` — typed WebSocket broadcaster and event fanout. +- Modify `src/webserver.ts` — server bootstrap only; register route modules and broadcaster. +- Create `src/routes/messageRoutes.ts` — message/attachment/review read APIs. +- Create `src/routes/analysisRoutes.ts` — reanalysis and queue status APIs. +- Create `src/routes/syncRoutes.ts` — backlog sync API. +- Create `src/routes/uiStateRoutes.ts` — UI state API. +- Create `src/routes/voiceRoutes.ts` — guild/channel/connect voice APIs. +- Modify `src/moderation/messageStore.ts` — repository functions, cursor pagination, review queries, pending claims. +- Create `src/moderation/conversationContext.ts` — context selection for AI prompts. +- Create `src/moderation/llmModerationClient.ts` — LLM request and strict response validation. +- Replace `src/moderation/aiAnalyzer.ts` internals — debounce queue that uses context/client/store. +- Modify `src/moderation/messageCapture.ts` — narrow ingestion and queue by conversation. +- Modify `src/moderation/backlogSync.ts` — sync feeds same capture/queue path. +- Modify `src/database/schema.ts` — add indexes and optional `ai_analysis_runs` table. + +### Tests + +- Create `tests/moderation/conversationContext.test.ts`. +- Create `tests/moderation/llmModerationClient.test.ts`. +- Create `tests/moderation/analysisQueue.test.ts`. +- Create `tests/moderation/messageStoreQueries.test.ts`. +- Update `tests/smoke.test.ts` if package scripts/build paths change. + +### Frontend + +- Create `frontend/package.json` only if workspace split is chosen; otherwise add Vite deps/scripts to root `package.json`. +- Create `frontend/index.html`. +- Create `frontend/src/main.tsx`. +- Create `frontend/src/App.tsx`. +- Create `frontend/src/api/client.ts`. +- Create `frontend/src/ws/client.ts`. +- Create `frontend/src/state/useMessages.ts`. +- Create `frontend/src/state/useReviewQueue.ts`. +- Create `frontend/src/state/useVoiceState.ts`. +- Create `frontend/src/components/layout/Shell.tsx`. +- Create `frontend/src/components/messages/MessageFeed.tsx`. +- Create `frontend/src/components/messages/MessageCard.tsx`. +- Create `frontend/src/components/review/ReviewPanel.tsx`. +- Create `frontend/src/components/voice/VoiceControls.tsx`. +- Create `frontend/src/styles.css`. +- Modify `package.json` — add React/Vite deps and scripts. +- Modify `tsconfig.json` or add `frontend/tsconfig.json`. + +--- + +## Task 1: Typed moderation contracts and broadcaster + +**Files:** +- Modify: `src/moderation/types.ts` +- Create: `src/moderation/broadcaster.ts` +- Test: `tests/moderation/broadcaster.test.ts` + +- [ ] **Step 1: Write broadcaster tests** + +Create `tests/moderation/broadcaster.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import { createBroadcaster } from "../../src/moderation/broadcaster"; + +function client() { + return { readyState: 1, send: vi.fn() }; +} + +describe("createBroadcaster", () => { + it("sends JSON events to open clients", () => { + const ws = client(); + const broadcaster = createBroadcaster(); + + broadcaster.addClient(ws as any); + broadcaster.messageAnalyzed({ id: "m1", ai_status: "clean" } as any); + + expect(ws.send).toHaveBeenCalledTimes(1); + expect(JSON.parse(ws.send.mock.calls[0][0])).toMatchObject({ + type: "message_analyzed", + data: { id: "m1", ai_status: "clean" }, + }); + }); + + it("skips closed clients", () => { + const ws = { readyState: 3, send: vi.fn() }; + const broadcaster = createBroadcaster(); + + broadcaster.addClient(ws as any); + broadcaster.messageDeleted({ id: "m1", deleted_at: 123 }); + + expect(ws.send).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run tests/moderation/broadcaster.test.ts` + +Expected: FAIL because `src/moderation/broadcaster.ts` does not exist. + +- [ ] **Step 3: Extend shared types** + +Add to `src/moderation/types.ts`: + +```ts +export type AIStatus = "pending" | "clean" | "warn" | "flagged" | "error"; + +export interface MessageQuery { + guildId?: string; + channelId?: string; + threadId?: string; + status?: AIStatus[]; + userId?: string; + q?: string; + cursor?: string; + limit: number; +} + +export interface PageResult { + data: T[]; + nextCursor: string | null; +} + +export interface AnalysisResult { + messageId: string; + status: Exclude; + flags: string[]; + score: number; + analysis: string; +} + +export type ModerationWsEvent = + | { type: "ui_state"; state: unknown } + | { type: "user_state"; users: unknown[] } + | { type: "message_created"; data: MessageRecord } + | { type: "message_updated"; data: Partial & { id: string } } + | { type: "message_deleted"; data: { id: string; deleted_at: number } } + | { type: "message_analyzed"; data: MessageRecord } + | { type: "attachment_created"; data: AttachmentRecord } + | { type: "analysis_queue_status"; data: AnalysisQueueStatus }; + +export interface AnalysisQueueStatus { + queuedConversations: number; + activeRequests: number; + lastError: string | null; +} +``` + +- [ ] **Step 4: Create typed broadcaster** + +Create `src/moderation/broadcaster.ts`: + +```ts +import type { WebSocket } from "ws"; +import type { + AnalysisQueueStatus, + AttachmentRecord, + MessageRecord, + ModerationWsEvent, +} from "./types"; + +type ClientLike = Pick; + +function sendJson(clients: Set, event: ModerationWsEvent): void { + const payload = JSON.stringify({ ...event, timestamp: Date.now() }); + for (const client of clients) { + if (client.readyState === 1) client.send(payload); + } +} + +export function createBroadcaster() { + const clients = new Set(); + + return { + addClient(client: ClientLike) { + clients.add(client); + }, + removeClient(client: ClientLike) { + clients.delete(client); + }, + clientCount() { + return clients.size; + }, + uiState(state: unknown) { + sendJson(clients, { type: "ui_state", state }); + }, + userState(users: unknown[]) { + sendJson(clients, { type: "user_state", users }); + }, + messageCreated(data: MessageRecord) { + sendJson(clients, { type: "message_created", data }); + }, + messageUpdated(data: Partial & { id: string }) { + sendJson(clients, { type: "message_updated", data }); + }, + messageDeleted(data: { id: string; deleted_at: number }) { + sendJson(clients, { type: "message_deleted", data }); + }, + messageAnalyzed(data: MessageRecord) { + sendJson(clients, { type: "message_analyzed", data }); + }, + attachmentCreated(data: AttachmentRecord) { + sendJson(clients, { type: "attachment_created", data }); + }, + analysisQueueStatus(data: AnalysisQueueStatus) { + sendJson(clients, { type: "analysis_queue_status", data }); + }, + }; +} + +export type ModerationBroadcaster = ReturnType; +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pnpm vitest run tests/moderation/broadcaster.test.ts` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +Run: + +```bash +git add src/moderation/types.ts src/moderation/broadcaster.ts tests/moderation/broadcaster.test.ts +git commit -m "feat: add typed moderation broadcaster" +``` + +--- + +## Task 2: Cursor helpers and message store query API + +**Files:** +- Modify: `src/moderation/messageStore.ts` +- Test: `tests/moderation/messageStoreQueries.test.ts` + +- [ ] **Step 1: Write cursor pagination tests** + +Create `tests/moderation/messageStoreQueries.test.ts` with database mocking pattern used by existing tests. Use this behavioral test for cursor encoding functions exported from `messageStore.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { decodeCursor, encodeCursor } from "../../src/moderation/messageStore"; + +describe("message cursor helpers", () => { + it("round-trips created_at and id", () => { + const cursor = encodeCursor({ created_at: 1710000000000, id: "abc" }); + expect(decodeCursor(cursor)).toEqual({ created_at: 1710000000000, id: "abc" }); + }); + + it("returns null for invalid cursor", () => { + expect(decodeCursor("not-base64-json")).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run tests/moderation/messageStoreQueries.test.ts` + +Expected: FAIL because cursor helpers are missing. + +- [ ] **Step 3: Add cursor helpers and query interfaces** + +Add to `src/moderation/messageStore.ts` near the top: + +```ts +import { lt, sql } from "drizzle-orm"; +import type { AIStatus, MessageQuery, PageResult } from "./types"; + +interface CursorValue { + created_at: number; + id: string; +} + +export function encodeCursor(value: CursorValue): string { + return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); +} + +export function decodeCursor(cursor?: string): CursorValue | null { + if (!cursor) return null; + try { + const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); + if (typeof parsed.created_at !== "number" || typeof parsed.id !== "string") { + return null; + } + return parsed; + } catch { + return null; + } +} +``` + +Adjust imports to avoid duplicates: existing `drizzle-orm` import becomes: + +```ts +import { and, asc, desc, eq, ilike, isNull, lt, or, sql } from "drizzle-orm"; +``` + +If SQLite typechecking rejects `ilike`, use `sql` search in implementation below. + +- [ ] **Step 4: Add `listMessages` and `listReviewMessages`** + +Add to `src/moderation/messageStore.ts` after existing read functions: + +```ts +function normalizeLimit(limit: number): number { + return Math.max(1, Math.min(limit || 50, 100)); +} + +function statusesOrDefault(status?: AIStatus[]): AIStatus[] | undefined { + return status && status.length > 0 ? status : undefined; +} + +export async function listMessages( + query: MessageQuery, +): Promise> { + const db = getDatabase() as any; + const limit = normalizeLimit(query.limit); + const cursor = decodeCursor(query.cursor); + const filters = []; + + if (query.guildId) filters.push(eq(messagesTable.guild_id, query.guildId)); + if (query.channelId) filters.push(eq(messagesTable.channel_id, query.channelId)); + if (query.threadId) filters.push(eq(messagesTable.thread_id, query.threadId)); + if (query.userId) filters.push(eq(messagesTable.user_id, query.userId)); + if (cursor) filters.push(lt(messagesTable.created_at, cursor.created_at)); + if (query.q) { + const pattern = `%${query.q.toLowerCase()}%`; + filters.push(sql`lower(${messagesTable.content}) like ${pattern}`); + } + + const status = statusesOrDefault(query.status); + if (status) filters.push(sql`${messagesTable.ai_status} in ${status}`); + + const rows = await db + .select() + .from(messagesTable) + .where(filters.length > 0 ? and(...filters) : undefined) + .orderBy(desc(messagesTable.created_at), desc(messagesTable.id)) + .limit(limit + 1); + + const data = rows.slice(0, limit) as MessageRecord[]; + const next = rows.length > limit ? data[data.length - 1] : null; + return { + data, + nextCursor: next + ? encodeCursor({ created_at: next.created_at, id: next.id }) + : null, + }; +} + +export async function listReviewMessages(query: { + guildId?: string; + channelId?: string; + status?: AIStatus[]; + cursor?: string; + limit: number; +}): Promise> { + return listMessages({ + guildId: query.guildId, + channelId: query.channelId, + status: query.status ?? ["warn", "flagged", "error"], + cursor: query.cursor, + limit: query.limit, + }); +} +``` + +- [ ] **Step 5: Run focused tests** + +Run: `pnpm vitest run tests/moderation/messageStoreQueries.test.ts` + +Expected: PASS. + +- [ ] **Step 6: Run typecheck and fix query typing** + +Run: `pnpm run typecheck` + +Expected: PASS. If `sql in ${status}` fails for dialect typing, replace with explicit `or(...status.map((item) => eq(messagesTable.ai_status, item)))`. + +- [ ] **Step 7: Commit** + +Run: + +```bash +git add src/moderation/messageStore.ts tests/moderation/messageStoreQueries.test.ts +git commit -m "feat: add cursor-based message queries" +``` + +--- + +## Task 3: Conversation context builder + +**Files:** +- Create: `src/moderation/conversationContext.ts` +- Modify: `src/moderation/messageStore.ts` +- Test: `tests/moderation/conversationContext.test.ts` + +- [ ] **Step 1: Write context builder tests** + +Create `tests/moderation/conversationContext.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { buildConversationPromptMessages } from "../../src/moderation/conversationContext"; +import type { MessageRecord } from "../../src/moderation/types"; + +function message(id: string, content: string, created_at: number): MessageRecord { + return { + id, + guild_id: "g1", + channel_id: "c1", + thread_id: null, + user_id: `u-${id}`, + username: `user-${id}`, + avatar_url: null, + content, + edited_content: null, + created_at, + edited_at: null, + deleted_at: null, + type: "text", + metadata: null, + ai_status: "pending", + }; +} + +describe("buildConversationPromptMessages", () => { + it("marks target messages and keeps chronological order", () => { + const lines = buildConversationPromptMessages({ + contextBefore: [message("a", "hello", 1)], + targets: [message("b", "bad?", 2)], + maxTokens: 1000, + }); + + expect(lines).toContain("[context] id=a time=1970-01-01T00:00:00.001Z user=user-a: hello"); + expect(lines).toContain("[target] id=b time=1970-01-01T00:00:00.002Z user=user-b: bad?"); + expect(lines.indexOf("id=a")).toBeLessThan(lines.indexOf("id=b")); + }); + + it("uses edited content when present", () => { + const target = message("b", "original", 2); + target.edited_content = "edited"; + + const lines = buildConversationPromptMessages({ + contextBefore: [], + targets: [target], + maxTokens: 1000, + }); + + expect(lines).toContain("edited"); + expect(lines).not.toContain("original"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run tests/moderation/conversationContext.test.ts` + +Expected: FAIL because module does not exist. + +- [ ] **Step 3: Implement prompt context builder** + +Create `src/moderation/conversationContext.ts`: + +```ts +import type { MessageRecord } from "./types"; + +export interface ConversationContextInput { + contextBefore: MessageRecord[]; + targets: MessageRecord[]; + maxTokens: number; +} + +function textOf(message: MessageRecord): string { + return (message.edited_content || message.content || "").trim(); +} + +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +function formatLine(kind: "context" | "target", message: MessageRecord): string { + return `[${kind}] id=${message.id} time=${new Date(message.created_at).toISOString()} user=${message.username}: ${textOf(message)}`; +} + +export function buildConversationPromptMessages( + input: ConversationContextInput, +): string { + const targets = [...input.targets].sort((a, b) => a.created_at - b.created_at); + const contextBefore = [...input.contextBefore].sort( + (a, b) => a.created_at - b.created_at, + ); + + const targetLines = targets.map((message) => formatLine("target", message)); + const targetTokens = targetLines.reduce((sum, line) => sum + estimateTokens(line), 0); + const contextBudget = Math.max(0, input.maxTokens - targetTokens); + + const selectedContext: string[] = []; + let used = 0; + for (const message of [...contextBefore].reverse()) { + const line = formatLine("context", message); + const tokens = estimateTokens(line); + if (used + tokens > contextBudget) break; + selectedContext.unshift(line); + used += tokens; + } + + return [...selectedContext, ...targetLines].join("\n"); +} +``` + +- [ ] **Step 4: Add store query for prior context** + +Add to `src/moderation/messageStore.ts`: + +```ts +export async function getConversationContextBefore(input: { + channelId: string; + threadId: string | null; + beforeCreatedAt: number; + limit: number; +}): Promise { + const db = getDatabase() as any; + const locationFilter = input.threadId + ? eq(messagesTable.thread_id, input.threadId) + : eq(messagesTable.channel_id, input.channelId); + + const rows = await db + .select() + .from(messagesTable) + .where( + and( + locationFilter, + lt(messagesTable.created_at, input.beforeCreatedAt), + isNull(messagesTable.deleted_at), + ), + ) + .orderBy(desc(messagesTable.created_at)) + .limit(input.limit); + + return [...(rows as MessageRecord[])].reverse(); +} +``` + +- [ ] **Step 5: Run tests** + +Run: `pnpm vitest run tests/moderation/conversationContext.test.ts tests/moderation/messageStoreQueries.test.ts` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +Run: + +```bash +git add src/moderation/conversationContext.ts src/moderation/messageStore.ts tests/moderation/conversationContext.test.ts +git commit -m "feat: add conversation context builder" +``` + +--- + +## Task 4: Strict LLM moderation client + +**Files:** +- Create: `src/moderation/llmModerationClient.ts` +- Modify: `src/moderation/types.ts` +- Test: `tests/moderation/llmModerationClient.test.ts` + +- [ ] **Step 1: Write parser tests** + +Create `tests/moderation/llmModerationClient.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { parseModerationResponse } from "../../src/moderation/llmModerationClient"; + +describe("parseModerationResponse", () => { + it("parses valid keyed results", () => { + const result = parseModerationResponse( + JSON.stringify({ + results: [ + { + message_id: "m1", + status: "warn", + flags: ["provokasi"], + score: 0.7, + analysis: "Perlu peringatan.", + }, + ], + }), + ["m1"], + ); + + expect(result).toEqual([ + { + messageId: "m1", + status: "warn", + flags: ["provokasi"], + score: 0.7, + analysis: "Perlu peringatan.", + }, + ]); + }); + + it("rejects missing target ids", () => { + expect(() => + parseModerationResponse(JSON.stringify({ results: [] }), ["m1"]), + ).toThrow(/missing/i); + }); + + it("rejects unknown ids", () => { + expect(() => + parseModerationResponse( + JSON.stringify({ + results: [{ message_id: "m2", status: "clean", flags: [], score: 0, analysis: "OK" }], + }), + ["m1"], + ), + ).toThrow(/unknown/i); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run tests/moderation/llmModerationClient.test.ts` + +Expected: FAIL because module does not exist. + +- [ ] **Step 3: Implement parser and client skeleton** + +Create `src/moderation/llmModerationClient.ts`: + +```ts +import { config } from "../config"; +import { createChildLogger } from "../logger"; +import { retryWithBackoff } from "../retry"; +import type { AnalysisResult, MessageRecord } from "./types"; + +const logger = createChildLogger("llm-moderation-client"); + +interface ChatCompletionResponse { + choices?: Array<{ message?: { content?: string } }>; +} + +function extractJsonObject(content: string): unknown { + const start = content.indexOf("{"); + const end = content.lastIndexOf("}"); + if (start < 0 || end <= start) throw new Error("LLM response does not contain JSON object"); + return JSON.parse(content.slice(start, end + 1)); +} + +function normalizeStatus(value: unknown): AnalysisResult["status"] { + if (value === "clean" || value === "warn" || value === "flagged") return value; + throw new Error(`Invalid moderation status: ${String(value)}`); +} + +export function parseModerationResponse( + content: string, + targetIds: string[], +): AnalysisResult[] { + const parsed = extractJsonObject(content) as { results?: unknown[] }; + if (!Array.isArray(parsed.results)) throw new Error("LLM response missing results array"); + + const targets = new Set(targetIds); + const seen = new Set(); + const results = parsed.results.map((item) => { + const row = item as Record; + const messageId = String(row.message_id || ""); + if (!targets.has(messageId)) throw new Error(`LLM response contains unknown message id: ${messageId}`); + seen.add(messageId); + + const score = Number(row.score); + if (!Number.isFinite(score)) throw new Error(`Invalid moderation score for ${messageId}`); + + return { + messageId, + status: normalizeStatus(row.status), + flags: Array.isArray(row.flags) ? row.flags.map(String) : [], + score: Math.max(0, Math.min(1, score)), + analysis: typeof row.analysis === "string" ? row.analysis : "Tidak ada analisis.", + }; + }); + + const missing = targetIds.filter((id) => !seen.has(id)); + if (missing.length > 0) throw new Error(`LLM response missing target ids: ${missing.join(", ")}`); + + return results; +} + +export async function runModerationAnalysis(input: { + targets: MessageRecord[]; + contextText: string; +}): Promise<{ results: AnalysisResult[]; raw: unknown }> { + const targetIds = input.targets.map((message) => message.id); + const response = (await retryWithBackoff( + async () => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.AI_ANALYSIS_TIMEOUT_MS); + try { + const res = await fetch(`${config.AI_LLM_BASE_URL}/chat/completions`, { + method: "POST", + headers: { + Authorization: `Bearer ${config.AI_LLM_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: config.AI_LLM_MODEL, + messages: [ + { + role: "system", + content: + "Kamu moderator Discord komunitas. Nilai hanya pesan bertanda [target]. Gunakan konteks untuk memahami alur. Balas JSON object: {\"results\":[{\"message_id\":\"...\",\"status\":\"clean|warn|flagged\",\"flags\":[\"...\"],\"score\":0..1,\"analysis\":\"Bahasa Indonesia: alasan dan aksi disarankan\"}]}. Semua target id wajib ada.", + }, + { role: "user", content: input.contextText }, + ], + temperature: 0.2, + }), + signal: controller.signal, + }); + const text = await res.text(); + if (!res.ok) throw new Error(`AI request failed (${res.status}): ${text.slice(0, 500)}`); + return JSON.parse(text) as ChatCompletionResponse; + } finally { + clearTimeout(timeout); + } + }, + { retries: 2, logger }, + )) as ChatCompletionResponse; + + const content = response.choices?.[0]?.message?.content?.trim() || ""; + return { results: parseModerationResponse(content, targetIds), raw: response }; +} +``` + +- [ ] **Step 4: Run parser tests** + +Run: `pnpm vitest run tests/moderation/llmModerationClient.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add src/moderation/llmModerationClient.ts tests/moderation/llmModerationClient.test.ts +git commit -m "feat: add strict llm moderation client" +``` + +--- + +## Task 5: Debounced analysis queue + +**Files:** +- Replace internals: `src/moderation/aiAnalyzer.ts` +- Modify: `src/moderation/messageStore.ts` +- Test: `tests/moderation/analysisQueue.test.ts` + +- [ ] **Step 1: Write pure queue helper test** + +Create `tests/moderation/analysisQueue.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { getConversationKey, pickBatchWithinBudget } from "../../src/moderation/aiAnalyzer"; +import type { MessageRecord } from "../../src/moderation/types"; + +function message(id: string, content: string, thread_id: string | null = null): MessageRecord { + return { + id, + guild_id: "g1", + channel_id: "c1", + thread_id, + user_id: "u1", + username: "u1", + avatar_url: null, + content, + edited_content: null, + created_at: Number(id.replace("m", "")) || 1, + edited_at: null, + deleted_at: null, + type: "text", + metadata: null, + ai_status: "pending", + }; +} + +describe("analysis queue helpers", () => { + it("uses thread id before channel id", () => { + expect(getConversationKey(message("m1", "hello", "t1"))).toBe("t1"); + expect(getConversationKey(message("m1", "hello", null))).toBe("c1"); + }); + + it("picks batch within budget", () => { + const batch = pickBatchWithinBudget([message("m1", "a"), message("m2", "x".repeat(1000))], 50, 10); + expect(batch.map((item) => item.id)).toEqual(["m1"]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run tests/moderation/analysisQueue.test.ts` + +Expected: FAIL because helpers are missing. + +- [ ] **Step 3: Add store functions for pending analysis** + +Add to `src/moderation/messageStore.ts`: + +```ts +export async function getPendingMessagesByConversation( + conversationKey: string, + limit: number, +): Promise { + const db = getDatabase() as any; + const rows = await db + .select() + .from(messagesTable) + .where( + and( + eq(messagesTable.ai_status, "pending"), + isNull(messagesTable.deleted_at), + or( + eq(messagesTable.channel_id, conversationKey), + eq(messagesTable.thread_id, conversationKey), + ), + ), + ) + .orderBy(asc(messagesTable.created_at)) + .limit(limit); + + return rows as MessageRecord[]; +} + +export async function getPendingConversationKeys(limit: number): Promise { + const pending = await getPendingAIAnalysisMessages(limit); + return Array.from(new Set(pending.map((message) => message.thread_id || message.channel_id))); +} +``` + +- [ ] **Step 4: Replace `aiAnalyzer.ts` queue internals** + +Rewrite `src/moderation/aiAnalyzer.ts` around these exports while preserving public functions `queueMessageAnalysis` and `startPendingAIAnalysisWorker`: + +```ts +import { config } from "../config"; +import { createChildLogger } from "../logger"; +import { buildConversationPromptMessages } from "./conversationContext"; +import { runModerationAnalysis } from "./llmModerationClient"; +import { + getConversationContextBefore, + getMessageById, + getPendingConversationKeys, + getPendingMessagesByConversation, + updateMessageAIAnalysis, +} from "./messageStore"; +import type { MessageRecord } from "./types"; + +const logger = createChildLogger("ai-analyzer"); +const queuedConversations = new Set(); +const debounceTimers = new Map(); +let activeRequests = 0; +let lastError: string | null = null; + +const MAX_ACTIVE_REQUESTS = 1; +const MAX_BATCH_MESSAGES = 40; +const MAX_CONTEXT_MESSAGES = 20; +const MAX_PROMPT_TOKENS = 9_000; +const DEBOUNCE_MS = 1500; + +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +function messageText(message: MessageRecord): string { + return (message.edited_content || message.content || "").trim(); +} + +export function getConversationKey(message: MessageRecord): string { + return message.thread_id || message.channel_id; +} + +export function pickBatchWithinBudget( + messages: MessageRecord[], + tokenBudget: number, + maxMessages: number, +): MessageRecord[] { + const batch: MessageRecord[] = []; + let used = 0; + for (const message of messages) { + const tokens = estimateTokens(messageText(message)) + 32; + if (batch.length > 0 && (batch.length >= maxMessages || used + tokens > tokenBudget)) break; + batch.push(message); + used += tokens; + } + return batch; +} + +async function analyzeConversation(conversationKey: string): Promise { + if (!config.AI_ANALYSIS_ENABLED) return; + if (activeRequests >= MAX_ACTIVE_REQUESTS) { + queuedConversations.add(conversationKey); + setTimeout(() => drainQueue().catch((error) => logger.error({ error }, "AI queue drain failed")), 250); + return; + } + + activeRequests++; + try { + const pending = await getPendingMessagesByConversation(conversationKey, MAX_BATCH_MESSAGES); + const batch = pickBatchWithinBudget(pending, MAX_PROMPT_TOKENS, MAX_BATCH_MESSAGES); + if (batch.length === 0) return; + + const first = batch[0]; + const contextBefore = await getConversationContextBefore({ + channelId: first.channel_id, + threadId: first.thread_id, + beforeCreatedAt: first.created_at, + limit: MAX_CONTEXT_MESSAGES, + }); + const contextText = buildConversationPromptMessages({ + contextBefore, + targets: batch, + maxTokens: MAX_PROMPT_TOKENS, + }); + + const { results, raw } = await runModerationAnalysis({ targets: batch, contextText }); + for (const result of results) { + const row = await updateMessageAIAnalysis(result.messageId, { + status: result.status, + flags: JSON.stringify(result.flags), + score: result.score, + raw: JSON.stringify({ model: config.AI_LLM_MODEL, response: raw }).slice(0, 20_000), + analysis: result.analysis, + analyzedAt: Date.now(), + error: null, + }); + if (row) (globalThis as any).moderationBroadcaster?.messageAnalyzed(row); + } + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + logger.warn({ conversationKey, error }, "AI conversation analysis failed"); + const pending = await getPendingMessagesByConversation(conversationKey, MAX_BATCH_MESSAGES); + for (const message of pending) { + const row = await updateMessageAIAnalysis(message.id, { + status: "error", + flags: null, + score: null, + raw: null, + analysis: null, + analyzedAt: Date.now(), + error: lastError, + }); + if (row) (globalThis as any).moderationBroadcaster?.messageAnalyzed(row); + } + } finally { + activeRequests--; + } +} + +async function drainQueue(): Promise { + const keys = Array.from(queuedConversations); + queuedConversations.clear(); + for (const key of keys) await analyzeConversation(key); +} + +export function queueConversationAnalysis(conversationKey: string): void { + if (!config.AI_ANALYSIS_ENABLED) return; + queuedConversations.add(conversationKey); + const existing = debounceTimers.get(conversationKey); + if (existing) clearTimeout(existing); + debounceTimers.set( + conversationKey, + setTimeout(() => { + debounceTimers.delete(conversationKey); + drainQueue().catch((error) => logger.error({ error }, "AI queue failed")); + }, DEBOUNCE_MS), + ); +} + +export function queueMessageAnalysis(messageId: string): void { + if (!config.AI_ANALYSIS_ENABLED) return; + getMessageById(messageId) + .then((message) => { + if (message) queueConversationAnalysis(getConversationKey(message)); + }) + .catch((error) => logger.error({ messageId, error }, "Failed to queue message analysis")); +} + +export function getAnalysisQueueStatus() { + return { queuedConversations: queuedConversations.size, activeRequests, lastError }; +} + +export function startPendingAIAnalysisWorker(): void { + if (!config.AI_ANALYSIS_ENABLED) { + logger.info("AI analysis disabled"); + return; + } + + logger.info("AI analysis worker started"); + setInterval(async () => { + const keys = await getPendingConversationKeys(500); + for (const key of keys) queueConversationAnalysis(key); + }, 15000); +} +``` + +- [ ] **Step 5: Run queue tests** + +Run: `pnpm vitest run tests/moderation/analysisQueue.test.ts` + +Expected: PASS. + +- [ ] **Step 6: Run related tests and typecheck** + +Run: `pnpm vitest run tests/moderation && pnpm run typecheck` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +Run: + +```bash +git add src/moderation/aiAnalyzer.ts src/moderation/messageStore.ts tests/moderation/analysisQueue.test.ts +git commit -m "feat: debounce ai analysis by conversation" +``` + +--- + +## Task 6: Focused route modules + +**Files:** +- Create: `src/routes/messageRoutes.ts` +- Create: `src/routes/analysisRoutes.ts` +- Create: `src/routes/syncRoutes.ts` +- Create: `src/routes/uiStateRoutes.ts` +- Create: `src/routes/voiceRoutes.ts` +- Modify: `src/webserver.ts` +- Test: existing typecheck/smoke + +- [ ] **Step 1: Create message routes** + +Create `src/routes/messageRoutes.ts`: + +```ts +import { Router } from "express"; +import { AppError } from "../errors"; +import { + getAttachmentsByChannel, + getMessageById, + listMessages, + listReviewMessages, +} from "../moderation/messageStore"; +import type { AIStatus } from "../moderation/types"; + +function limitOf(value: unknown): number { + return Math.max(1, Math.min(Number(value) || 50, 100)); +} + +function statusesOf(value: unknown): AIStatus[] | undefined { + if (typeof value !== "string" || value.length === 0) return undefined; + return value.split(",").filter((item): item is AIStatus => + ["pending", "clean", "warn", "flagged", "error"].includes(item), + ); +} + +export function createMessageRoutes() { + const router = Router(); + + router.get("/messages", async (req, res, next) => { + try { + res.json( + await listMessages({ + guildId: req.query.guildId as string | undefined, + channelId: req.query.channelId as string | undefined, + threadId: req.query.threadId as string | undefined, + cursor: req.query.cursor as string | undefined, + limit: limitOf(req.query.limit), + status: statusesOf(req.query.status), + userId: req.query.userId as string | undefined, + q: req.query.q as string | undefined, + }), + ); + } catch (error) { + next(error); + } + }); + + router.get("/messages/:id", async (req, res, next) => { + try { + const message = await getMessageById(req.params.id); + if (!message) throw new AppError("Message not found", "MESSAGE_NOT_FOUND", 404); + res.json(message); + } catch (error) { + next(error); + } + }); + + router.get("/review", async (req, res, next) => { + try { + res.json( + await listReviewMessages({ + guildId: req.query.guildId as string | undefined, + channelId: req.query.channelId as string | undefined, + cursor: req.query.cursor as string | undefined, + limit: limitOf(req.query.limit), + status: statusesOf(req.query.status), + }), + ); + } catch (error) { + next(error); + } + }); + + router.get("/attachments", async (req, res, next) => { + try { + const channelId = req.query.channelId as string | undefined; + if (!channelId) throw new AppError("channelId is required", "MISSING_CHANNEL_ID", 400); + const data = await getAttachmentsByChannel(channelId, limitOf(req.query.limit), 0); + res.json({ data, nextCursor: null }); + } catch (error) { + next(error); + } + }); + + return router; +} +``` + +- [ ] **Step 2: Create analysis routes** + +Create `src/routes/analysisRoutes.ts`: + +```ts +import { Router } from "express"; +import { getAnalysisQueueStatus, queueMessageAnalysis } from "../moderation/aiAnalyzer"; +import { updateMessageAIAnalysis } from "../moderation/messageStore"; + +export function createAnalysisRoutes() { + const router = Router(); + + router.get("/analysis/status", (_req, res) => { + res.json(getAnalysisQueueStatus()); + }); + + router.post("/messages/:id/reanalyze", async (req, res, next) => { + try { + const row = await updateMessageAIAnalysis(req.params.id, { + status: "pending", + flags: null, + score: null, + raw: null, + analysis: null, + analyzedAt: null, + error: null, + }); + queueMessageAnalysis(req.params.id); + res.status(202).json(row); + } catch (error) { + next(error); + } + }); + + return router; +} +``` + +- [ ] **Step 3: Create sync routes** + +Create `src/routes/syncRoutes.ts`: + +```ts +import type { Client } from "discord.js-selfbot-v13"; +import { Router } from "express"; +import { AppError } from "../errors"; +import { syncSelectedChannelBacklog } from "../moderation/backlogSync"; + +export function createSyncRoutes(client: Client) { + const router = Router(); + + router.post("/backlog-sync", async (req, res, next) => { + try { + const { guildId, channelId } = req.body as { guildId?: string; channelId?: string }; + if (!guildId || !channelId) { + throw new AppError("guildId and channelId are required", "MISSING_BACKLOG_PARAMS", 400); + } + const count = await syncSelectedChannelBacklog(client, guildId, channelId); + res.json({ success: true, channelId, messagesSync: count }); + } catch (error) { + next(error); + } + }); + + return router; +} +``` + +- [ ] **Step 4: Create UI and voice route modules** + +Move existing `/api/ui-state`, `/api/guilds`, `/api/connect`, `/api/disconnect`, `/api/status` route bodies from `src/webserver.ts` into `src/routes/uiStateRoutes.ts` and `src/routes/voiceRoutes.ts`. Keep function signatures: + +```ts +export function createUIStateRoutes(input: { + getSharedUIState: () => unknown; + patchSharedUIState: (patch: Record) => unknown; +}) +``` + +```ts +export function createVoiceRoutes(voiceController: VoiceController) +``` + +- [ ] **Step 5: Register routes in webserver** + +In `src/webserver.ts`, import route factories and replace inline `/api/*` definitions with: + +```ts +app.use("/api", createUIStateRoutes({ getSharedUIState, patchSharedUIState })); +app.use("/api", createVoiceRoutes(voiceController)); +app.use("/api", createMessageRoutes()); +app.use("/api", createAnalysisRoutes()); +app.use("/api", createSyncRoutes(_client)); +``` + +Also instantiate broadcaster: + +```ts +const broadcaster = createBroadcaster(); +(globalThis as any).moderationBroadcaster = broadcaster; +``` + +Use broadcaster in WS connection add/remove and replace message broadcast globals where possible. + +- [ ] **Step 6: Run typecheck** + +Run: `pnpm run typecheck` + +Expected: PASS. + +- [ ] **Step 7: Run smoke tests** + +Run: `pnpm vitest run tests/smoke.test.ts tests/config.test.ts` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +Run: + +```bash +git add src/routes src/webserver.ts +git commit -m "refactor: split api routes by concern" +``` + +--- + +## Task 7: Capture path cleanup + +**Files:** +- Modify: `src/moderation/messageCapture.ts` +- Modify: `src/moderation/messageStore.ts` +- Test: existing moderation tests + +- [ ] **Step 1: Add upsert/reset store helpers** + +In `src/moderation/messageStore.ts`, add: + +```ts +export async function upsertMessageForCapture(message: MessageRecord): Promise { + const db = getDatabase() as any; + await db + .insert(messagesTable) + .values({ ...message, ai_status: "pending" }) + .onConflictDoUpdate({ + target: messagesTable.id, + set: { + content: message.content, + edited_content: message.edited_content, + metadata: message.metadata, + ai_status: "pending", + ai_error: null, + }, + }); +} +``` + +If `onConflictDoUpdate` target typing differs between Postgres/SQLite union tables, keep `insertMessage` and follow with `updateMessageAIAnalysis(id, { status: "pending", ... })` for the existing row path. + +- [ ] **Step 2: Narrow capture responsibilities** + +In `src/moderation/messageCapture.ts`: + +- Replace direct `getDatabase` and `messagesTable` query with `getMessageById`. +- Replace `globalThis.broadcastMessageCreated` with `(globalThis as any).moderationBroadcaster?.messageCreated(messageRecord)`. +- Replace `globalThis.broadcastMessageUpdated` with typed broadcaster call. +- Replace `globalThis.broadcastMessageDeleted` with typed broadcaster call. +- Queue analysis after insert/edit only. + +- [ ] **Step 3: Run moderation tests** + +Run: `pnpm vitest run tests/moderation && pnpm run typecheck` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +Run: + +```bash +git add src/moderation/messageCapture.ts src/moderation/messageStore.ts +git commit -m "refactor: keep message capture on fast path" +``` + +--- + +## Task 8: Database indexes and optional analysis run table + +**Files:** +- Modify: `src/database/schema.ts` +- Create migration via Drizzle: `drizzle/migrations/*.sql` +- Test: `tests/database.test.ts` + +- [ ] **Step 1: Add schema indexes** + +In both PostgreSQL and SQLite message table definitions, add indexes matching these names if not already present: + +```ts +channelCreatedIdx: pgIndex("idx_messages_channel_created").on(table.channel_id, table.created_at, table.id), +threadCreatedIdx: pgIndex("idx_messages_thread_created").on(table.thread_id, table.created_at, table.id), +aiStatusCreatedIdx: pgIndex("idx_messages_ai_status_created").on(table.ai_status, table.created_at, table.id), +guildAiStatusCreatedIdx: pgIndex("idx_messages_guild_ai_status_created").on(table.guild_id, table.ai_status, table.created_at, table.id), +``` + +Use `sqliteIndex` equivalents for SQLite. + +For attachments, add: + +```ts +channelCreatedIdx: pgIndex("idx_attachments_channel_created").on(table.channel_id, table.created_at, table.id), +threadCreatedIdx: pgIndex("idx_attachments_thread_created").on(table.thread_id, table.created_at, table.id), +``` + +Use `sqliteIndex` equivalents for SQLite. + +- [ ] **Step 2: Add `ai_analysis_runs` schema** + +Add PostgreSQL and SQLite table definitions with fields from spec, then export runtime-selected `aiAnalysisRunsTable`. + +- [ ] **Step 3: Generate migration** + +Run: `pnpm drizzle-kit generate` + +Expected: new migration file under `drizzle/migrations/`. + +- [ ] **Step 4: Run database tests** + +Run: `pnpm vitest run tests/database.test.ts && pnpm run typecheck` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add src/database/schema.ts drizzle/migrations drizzle/migrations/meta tests/database.test.ts +git commit -m "feat: add moderation query indexes" +``` + +--- + +## Task 9: React/Vite app scaffold + +**Files:** +- Modify: `package.json` +- Modify or create: `tsconfig.json` / `frontend/tsconfig.json` +- Create: `frontend/index.html` +- Create: `frontend/src/main.tsx` +- Create: `frontend/src/App.tsx` +- Create: `frontend/src/styles.css` + +- [ ] **Step 1: Install frontend dependencies** + +Run: + +```bash +pnpm add react react-dom @vitejs/plugin-react vite +pnpm add -D @types/react @types/react-dom +``` + +Expected: dependencies added to `package.json` and lockfile updated. + +- [ ] **Step 2: Add scripts** + +Modify root `package.json` scripts: + +```json +{ + "dev:server": "tsx watch src/index.ts", + "dev:web": "vite --host 0.0.0.0 frontend", + "build:web": "vite build frontend --outDir ../public/app --emptyOutDir", + "build": "pnpm run build:web && tsc --outDir dist" +} +``` + +Keep existing `dev` and `start` unless replacing them is intentional. + +- [ ] **Step 3: Create frontend HTML** + +Create `frontend/index.html`: + +```html + + + + + + Discord Moderation Watcher + + +
+ + + +``` + +- [ ] **Step 4: Create React entry** + +Create `frontend/src/main.tsx`: + +```tsx +import React from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); +``` + +Create `frontend/src/App.tsx`: + +```tsx +export function App() { + return ( +
+ +
Message feed loading...
+ +
+ ); +} +``` + +Create `frontend/src/styles.css`: + +```css +:root { + color-scheme: dark; + font-family: Inter, system-ui, sans-serif; + background: #0b1020; + color: #edf2ff; +} + +body { + margin: 0; +} + +.shell { + display: grid; + grid-template-columns: 280px minmax(0, 1fr) 360px; + min-height: 100vh; +} + +.sidebar, +.content, +.review { + padding: 20px; + border-right: 1px solid rgba(255, 255, 255, 0.08); +} +``` + +- [ ] **Step 5: Build frontend** + +Run: `pnpm run build:web` + +Expected: Vite build succeeds and writes `public/app`. + +- [ ] **Step 6: Commit** + +Run: + +```bash +git add package.json pnpm-lock.yaml frontend public/app +git commit -m "feat: scaffold react dashboard" +``` + +--- + +## Task 10: Frontend API and WebSocket clients + +**Files:** +- Create: `frontend/src/api/client.ts` +- Create: `frontend/src/ws/client.ts` +- Modify: `frontend/src/App.tsx` + +- [ ] **Step 1: Create API client** + +Create `frontend/src/api/client.ts`: + +```ts +export interface PageResult { + data: T[]; + nextCursor: string | null; +} + +export interface DashboardMessage { + id: string; + channel_id: string; + thread_id: string | null; + user_id: string; + username: string; + avatar_url: string | null; + content: string; + edited_content: string | null; + created_at: number; + edited_at: number | null; + deleted_at: number | null; + ai_status?: "pending" | "clean" | "warn" | "flagged" | "error" | null; + ai_moderation_flags?: string | null; + ai_moderation_score?: number | null; + ai_analysis?: string | null; + ai_error?: string | null; + metadata: string | null; +} + +async function request(url: string, init?: RequestInit): Promise { + const response = await fetch(url, { + headers: { "Content-Type": "application/json", ...(init?.headers || {}) }, + ...init, + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(error.message || response.statusText); + } + return response.json() as Promise; +} + +export function listMessages(params: URLSearchParams) { + return request>(`/api/messages?${params}`); +} + +export function listReview(params: URLSearchParams) { + return request>(`/api/review?${params}`); +} + +export function reanalyzeMessage(id: string) { + return request(`/api/messages/${id}/reanalyze`, { method: "POST" }); +} + +export function getGuilds() { + return request>("/api/guilds"); +} +``` + +- [ ] **Step 2: Create WS client** + +Create `frontend/src/ws/client.ts`: + +```ts +import type { DashboardMessage } from "../api/client"; + +export type DashboardEvent = + | { type: "message_created"; data: DashboardMessage } + | { type: "message_updated"; data: Partial & { id: string } } + | { type: "message_deleted"; data: { id: string; deleted_at: number } } + | { type: "message_analyzed"; data: DashboardMessage } + | { type: "analysis_queue_status"; data: unknown } + | { type: "ui_state"; state: unknown } + | { type: "user_state"; users: unknown[] }; + +export function connectDashboardSocket(onEvent: (event: DashboardEvent) => void) { + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + const socket = new WebSocket(`${protocol}//${location.host}/ws`); + socket.onmessage = (event) => { + if (typeof event.data !== "string") return; + onEvent(JSON.parse(event.data) as DashboardEvent); + }; + return socket; +} +``` + +- [ ] **Step 3: Smoke use in App** + +Update `frontend/src/App.tsx`: + +```tsx +import { useEffect, useState } from "react"; +import { listMessages, type DashboardMessage } from "./api/client"; +import { connectDashboardSocket } from "./ws/client"; + +export function App() { + const [messages, setMessages] = useState([]); + + useEffect(() => { + listMessages(new URLSearchParams({ limit: "30" })) + .then((page) => setMessages(page.data)) + .catch(() => setMessages([])); + + const socket = connectDashboardSocket((event) => { + if (event.type === "message_created") setMessages((items) => [event.data, ...items]); + if (event.type === "message_analyzed") { + setMessages((items) => items.map((item) => (item.id === event.data.id ? event.data : item))); + } + }); + return () => socket.close(); + }, []); + + return ( +
+ +
+ {messages.map((message) => ( +
{message.username}: {message.edited_content || message.content}
+ ))} +
+ +
+ ); +} +``` + +- [ ] **Step 4: Build frontend** + +Run: `pnpm run build:web` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add frontend/src +git commit -m "feat: add dashboard api clients" +``` + +--- + +## Task 11: Dashboard components and review flow + +**Files:** +- Create component/state files listed in File Structure +- Modify: `frontend/src/App.tsx` +- Modify: `frontend/src/styles.css` + +- [ ] **Step 1: Create message card** + +Create `frontend/src/components/messages/MessageCard.tsx`: + +```tsx +import type { DashboardMessage } from "../../api/client"; + +export function MessageCard({ message, onReanalyze }: { message: DashboardMessage; onReanalyze: (id: string) => void }) { + const status = message.ai_status || "pending"; + return ( +
+
+ {message.username || message.user_id} + +
+

{message.edited_content || message.content || "(empty message)"}

+
+ {status} + {message.edited_at ? edited : null} + {message.deleted_at ? deleted : null} +
+ {message.ai_analysis ?

{message.ai_analysis}

: null} + {message.ai_error ?

AI error: {message.ai_error}

: null} + +
+ ); +} +``` + +- [ ] **Step 2: Create feed and review panel** + +Create `frontend/src/components/messages/MessageFeed.tsx` and `frontend/src/components/review/ReviewPanel.tsx` using `MessageCard`. `ReviewPanel` filters messages where `ai_status` is `warn`, `flagged`, or `error`. + +- [ ] **Step 3: Add styles** + +Append to `frontend/src/styles.css`: + +```css +.message-card { + display: grid; + gap: 10px; + padding: 14px; + margin-bottom: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + background: rgba(255, 255, 255, 0.04); +} + +.message-card header { + display: flex; + justify-content: space-between; + gap: 12px; + color: #c9d7ff; +} + +.badges { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.badges span { + padding: 3px 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); +} + +.status-warn { + border-color: rgba(255, 196, 87, 0.55); +} + +.status-flagged, +.status-error { + border-color: rgba(255, 92, 122, 0.65); +} + +.analysis { + color: #b7c6ff; +} + +.error { + color: #ff8ca3; +} +``` + +- [ ] **Step 4: Wire App with reanalysis** + +Update `frontend/src/App.tsx` to call `reanalyzeMessage` and update the item to pending on success. + +- [ ] **Step 5: Build frontend** + +Run: `pnpm run build:web` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +Run: + +```bash +git add frontend/src +git commit -m "feat: add moderation review dashboard" +``` + +--- + +## Task 12: Final verification and cleanup + +**Files:** +- Modify: `public/index.html` or Express static route if needed +- Remove: obsolete static dashboard code only after React app is served correctly +- Run all tests/builds + +- [ ] **Step 1: Point root to React app** + +Update `src/webserver.ts` static serving so production root serves `public/app/index.html` when it exists. Keep fallback to old `public/index.html` during transition. + +- [ ] **Step 2: Run full backend verification** + +Run: + +```bash +pnpm run typecheck +pnpm run lint +pnpm run test +``` + +Expected: all PASS. + +- [ ] **Step 3: Run frontend build** + +Run: + +```bash +pnpm run build:web +``` + +Expected: PASS. + +- [ ] **Step 4: Manual browser verification** + +Run server: + +```bash +pnpm run dev +``` + +Open dashboard and verify: + +- Guild/channel selectors load. +- Message feed loads with cursor pagination. +- Review panel shows warn/flagged/error. +- WebSocket updates patch message status. +- Reanalyze button marks message pending and queues AI. +- Voice connect/listen/transmit controls still work. + +- [ ] **Step 5: Cleanup obsolete static FE** + +After browser verification, remove or stop linking old static inline dashboard code. Keep `dashboard.css` only if React still imports or copies needed styles; otherwise delete it. + +- [ ] **Step 6: Final commit** + +Run: + +```bash +git add src public frontend package.json pnpm-lock.yaml tests drizzle +git commit -m "feat: rebuild moderation dashboard flow" +``` + +--- + +## Self-Review + +- Spec coverage: backend boundaries, async AI queue, conversation context, split APIs, typed WS, DB indexes, React/Vite frontend, performance rules, and tests are all mapped to tasks. +- Placeholder scan: no `TBD` or vague implementation-only steps remain; tasks include paths, commands, expected results, and starter code. +- Type consistency: shared `AIStatus`, `MessageRecord`, `AnalysisResult`, `PageResult`, and event names are consistent across tasks. diff --git a/docs/superpowers/specs/2026-05-14-ai-message-flow-react-dashboard-design.md b/docs/superpowers/specs/2026-05-14-ai-message-flow-react-dashboard-design.md new file mode 100644 index 0000000..2bb0fb7 --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-ai-message-flow-react-dashboard-design.md @@ -0,0 +1,264 @@ +# AI Message Flow + React Dashboard Redesign + +## Goal + +Rebuild the moderation watcher flow so message capture is fast, AI analysis is contextual and reliable, APIs are split by concern, and the dashboard is maintainable. The implementation may fully replace the current static frontend and reorganize backend modules, while preserving existing voice functionality. + +## Current Problems + +- Message capture, AI queuing, DB access, and WebSocket broadcasting are tightly coupled. +- AI analysis batches depend on array ordering, which can mismatch results when the model returns malformed or partial JSON. +- Pending analysis is polled globally, not grouped by conversation, so context is weak and request efficiency is inconsistent. +- `/api/messages` mixes text/image concerns and uses offset pagination, which gets slower and less stable as rows grow. +- Frontend is a large static HTML file with inline state, API, WebSocket, rendering, and audio code. +- WebSocket broadcast uses untyped `globalThis` hooks across modules. + +## Backend Architecture + +### Message ingestion + +`messageCapture` becomes a narrow ingestion layer: + +1. Filter Discord events by guild and author. +2. Normalize message payload and metadata. +3. Upsert message/attachment records. +4. Set or reset `ai_status` to `pending` for new or edited text. +5. Emit typed domain events for WebSocket broadcasting and analysis queueing. + +It should not build prompts, manage AI batches, or query unrelated DB state. + +### Store/repository layer + +`messageStore` becomes the single message/attachment query boundary. It should expose focused functions: + +- `upsertMessage` +- `markMessageEdited` +- `markMessageDeleted` +- `listMessages` +- `listReviewMessages` +- `getConversationContext` +- `claimPendingMessagesForChannel` +- `saveAnalysisResults` +- `insertAttachments` + +Queries should use cursor pagination based on `(created_at, id)` instead of offset pagination. Common filters: `guildId`, `channelId`, `threadId`, `status`, `userId`, `q`, `limit`, `cursor`. + +### Analysis queue + +Add an `analysisQueue` module. It owns async AI processing and keeps capture fast. + +- Queue key: `thread_id ?? channel_id`. +- Debounce: 1–3 seconds per key to group nearby messages. +- Batch pending messages by conversation key and token budget. +- Only one or a small fixed number of active LLM requests. +- Backlog worker feeds the same queue; no separate analysis path. +- Edits reset a message to `pending` and enqueue its conversation key. + +If the process restarts, pending rows are recovered by a periodic lightweight scanner grouped by conversation key. + +### Conversation context builder + +Add `conversationContext` module: + +- Input: conversation key + target pending messages. +- Fetch context before the first target message, normally 20 prior messages. +- Include target messages and close neighboring messages when within budget. +- Mark target messages explicitly in the prompt. +- Keep context scoped to one channel/thread to avoid irrelevant noise. + +### LLM moderation client + +Add `llmModerationClient` module: + +- Own request shape, timeout, retry, JSON extraction, and validation. +- Prompt returns JSON keyed by `message_id`, not positional arrays. +- Expected response shape: + ```json + { + "results": [ + { + "message_id": "string", + "status": "clean|warn|flagged", + "flags": ["string"], + "score": 0.0, + "analysis": "Bahasa Indonesia summary + reason + suggested action" + } + ] + } + ``` +- Reject unknown IDs, invalid statuses, invalid scores, and missing target IDs. +- On partial model failure, retry once with smaller batch. If still invalid, mark only affected target messages as `error`. +- Store raw batch request/response in one run record if the DB migration is included; otherwise store compact raw metadata per message. + +## API Design + +Split API by use case so reads remain fast and obvious. + +### Message read APIs + +- `GET /api/messages` + - Query: `guildId`, `channelId`, `threadId`, `cursor`, `limit`, `status`, `userId`, `q`. + - Returns: `{ data, nextCursor }`. + - Uses indexed cursor pagination. + +- `GET /api/messages/:id` + - Returns one message with attachments and AI analysis. + +- `GET /api/review` + - Query: `guildId`, optional `channelId`, `status=warn,flagged,error`, `cursor`, `limit`. + - Optimized for moderator review panel. + +- `GET /api/attachments` + - Query: `channelId`, `threadId`, `cursor`, `limit`, `type`. + - Replaces image mode inside `/api/messages`. + +### Analysis APIs + +- `POST /api/messages/:id/reanalyze` + - Sets message to `pending` and queues its conversation. + - Returns `202 Accepted` with current message status. + +- `POST /api/analysis/requeue-pending` + - Admin/manual recovery endpoint for pending/error rows. + - Returns count queued. + +- `GET /api/analysis/status` + - Returns queue depth, active requests, last error, and pending counts. + +### Discord sync APIs + +- `POST /api/backlog-sync` + - Stays async-friendly: starts sync for guild/channel/thread and returns `202` with a job id or immediate summary if small. + - Sync inserts messages, then queues analysis through the same `analysisQueue`. + +### Voice/control APIs + +Keep existing voice APIs working, but move route registration into route modules: + +- `routes/voiceRoutes.ts` +- `routes/messageRoutes.ts` +- `routes/analysisRoutes.ts` +- `routes/syncRoutes.ts` +- `routes/uiStateRoutes.ts` + +`webserver.ts` should only create Express/WS server, install middleware, register routes, and start listening. + +## WebSocket Design + +Replace ad-hoc globals with a typed broadcaster module. + +Events: + +- `ui_state` +- `user_state` +- `message_created` +- `message_updated` +- `message_deleted` +- `message_analyzed` +- `attachment_created` +- `analysis_queue_status` + +Backend modules call broadcaster functions; they do not touch WebSocket clients directly. + +## Database Changes + +Add or verify indexes: + +- messages `(channel_id, created_at, id)` +- messages `(thread_id, created_at, id)` +- messages `(ai_status, created_at, id)` +- messages `(guild_id, ai_status, created_at, id)` +- attachments `(channel_id, created_at, id)` +- attachments `(thread_id, created_at, id)` + +Optional but preferred: + +- `ai_analysis_runs` table: + - `id` + - `conversation_key` + - `target_message_ids` + - `model` + - `request_tokens_estimate` + - `response_raw` + - `status` + - `error` + - `created_at` + - `completed_at` + +This avoids duplicating large raw LLM responses into every message row. + +## React/Vite Frontend + +Replace static inline dashboard code with a TypeScript React app. + +Suggested structure: + +- `frontend/src/api/` — typed REST clients +- `frontend/src/ws/` — WebSocket client and event types +- `frontend/src/state/` — small hooks for selected guild/channel, messages, review queue, voice state +- `frontend/src/components/voice/` — existing voice control/audio components +- `frontend/src/components/messages/` — feed, message card, filters, detail drawer +- `frontend/src/components/review/` — needs-review list and analysis status +- `frontend/src/components/layout/` — shell/sidebar/status cards + +UI layout: + +- Left sidebar: guild, voice channel, text channel/thread, connection state. +- Main area: message feed with filters and load-more cursor pagination. +- Right panel: review queue for `warn`, `flagged`, and `error` messages. +- Detail drawer/modal: message metadata, attachments, AI rationale, raw flags, reanalyze action. + +Voice features stay functionally equivalent. Audio capture/playback code can be moved into React hooks but should not be behaviorally rewritten unless needed. + +Build integration: + +- Add Vite dev/build scripts. +- Express serves the built app from a stable public directory in production. +- During development, either run Vite separately or proxy API/WS to Express. + +## Performance Rules + +- Message capture must not wait on AI. +- Read APIs use cursor pagination and indexes. +- AI batches are bounded by token estimate and message count. +- UI fetches initial pages, then patches via WebSocket. +- Backlog sync should not block dashboard interactions. +- Avoid storing full raw LLM response per message when a batch table is available. + +## Error Handling + +- Capture errors log and do not crash Discord client event handlers. +- AI request failures mark target messages `error` with a short reason. +- Invalid LLM JSON triggers retry/split before marking errors. +- API validation returns 400 with structured error code. +- WebSocket reconnect logic stays client-side. +- Manual reanalysis provides recovery for bad AI results. + +## Testing + +Backend: + +- Unit tests for conversation context selection. +- Unit tests for LLM response parser and validation. +- Unit tests for queue batching/debounce behavior. +- Integration tests for message cursor pagination and review filters. +- Existing voice tests remain unchanged. + +Frontend: + +- Typecheck and Vite build. +- Component-level smoke tests may be added if test tooling is already practical. +- Manual browser verification: channel select, message feed, review panel, WebSocket updates, reanalyze action, voice controls. + +## Implementation Scope + +This is a full redesign of the message/AI/dashboard path. Voice recording and live audio behavior should be preserved unless a change is required to integrate the React dashboard. + +Implementation should proceed incrementally: + +1. Backend boundaries and typed broadcaster. +2. Store/query improvements and indexes. +3. Analysis queue/context/client rewrite. +4. Split API routes. +5. React/Vite dashboard. +6. Verification and cleanup of old static dashboard code. diff --git a/src/database/migrate.ts b/src/database/migrate.ts index 14fe606..a68ad9b 100644 --- a/src/database/migrate.ts +++ b/src/database/migrate.ts @@ -28,7 +28,7 @@ export async function runMigrations(): Promise { } catch (error) { logger.error( { error: error instanceof Error ? error.message : String(error) }, - "Migration failed" + "Migration failed", ); throw error; } diff --git a/src/moderation/aiAnalyzer.ts b/src/moderation/aiAnalyzer.ts index 68b9663..9156774 100644 --- a/src/moderation/aiAnalyzer.ts +++ b/src/moderation/aiAnalyzer.ts @@ -117,7 +117,7 @@ async function processBatch( // Broadcast analyzed messages for (const row of analyzedRows) { - (globalThis as any).broadcastMessageAnalyzed?.(row); + (globalThis as any).moderationBroadcaster?.messageAnalyzed(row); } // Clear error cooldown on success @@ -147,7 +147,7 @@ async function processBatch( error: lastError, }); if (row) { - (globalThis as any).broadcastMessageAnalyzed?.(row); + (globalThis as any).moderationBroadcaster?.messageAnalyzed(row); } } diff --git a/src/moderation/messageStore.ts b/src/moderation/messageStore.ts index 6dd79c4..00f13e8 100644 --- a/src/moderation/messageStore.ts +++ b/src/moderation/messageStore.ts @@ -102,6 +102,13 @@ export async function updateMessageAsEdited( edited_content: editedContent, edited_at: editedAt, type: "edited", + ai_status: "pending", + ai_moderation_flags: null, + ai_moderation_score: null, + ai_moderation_raw: null, + ai_analysis: null, + ai_analyzed_at: null, + ai_error: null, }) .where(eq(messagesTable.id, messageId)); diff --git a/src/webserver.ts b/src/webserver.ts index 6efc488..71a89aa 100644 --- a/src/webserver.ts +++ b/src/webserver.ts @@ -220,26 +220,6 @@ export async function startWebserver( broadcaster.userState(users); } - (global as any).broadcastMessageCreated = (data: any) => { - broadcaster.messageCreated(data); - }; - - (global as any).broadcastMessageUpdated = (data: any) => { - broadcaster.messageUpdated(data); - }; - - (global as any).broadcastMessageDeleted = (data: any) => { - broadcaster.messageDeleted(data); - }; - - (global as any).broadcastAttachmentUploaded = (data: any) => { - broadcaster.attachmentCreated(data); - }; - - (global as any).broadcastMessageAnalyzed = (data: any) => { - broadcaster.messageAnalyzed(data); - }; - // --- Outbound: browser PCM (24kHz mono) → Opus → Discord --- const RATE = 48000; const CHANNELS = 2; diff --git a/tests/moderation/messageStoreQueries.test.ts b/tests/moderation/messageStoreQueries.test.ts index 84eccfa..fb7da2f 100644 --- a/tests/moderation/messageStoreQueries.test.ts +++ b/tests/moderation/messageStoreQueries.test.ts @@ -5,6 +5,8 @@ import { insertMessage, listMessages, listReviewMessages, + updateMessageAsEdited, + getMessageById, } from "../../src/moderation/messageStore"; import { getDatabase, @@ -506,4 +508,67 @@ describe("message query integration tests", () => { expect(overlap).toHaveLength(0); }); }); + + describe("updateMessageAsEdited", () => { + const createTestMessage = ( + overrides: Partial = {}, + ): MessageRecord => ({ + id: `msg-${Date.now()}-${Math.random().toString(36).slice(2)}`, + guild_id: "guild-123", + channel_id: "channel-456", + thread_id: null, + user_id: "user-789", + username: "testuser", + avatar_url: null, + content: "Test message", + edited_content: null, + created_at: Date.now(), + edited_at: null, + deleted_at: null, + type: "text", + metadata: null, + ai_status: "pending", + ...overrides, + }); + + it("resets ai_status to pending and clears AI fields when message is edited", async () => { + const messageId = `msg-edit-test-${Date.now()}`; + const msg = createTestMessage({ + id: messageId, + content: "original content", + ai_status: "clean", + ai_moderation_flags: "test_flag", + ai_moderation_score: 0.5, + ai_moderation_raw: '{"test": "data"}', + ai_analysis: "This is clean", + ai_analyzed_at: Date.now() - 10000, + ai_error: null, + }); + + // Insert message with AI analysis already done + await insertMessage(msg); + + // Verify initial state + let retrieved = await getMessageById(messageId); + expect(retrieved?.ai_status).toBe("clean"); + expect(retrieved?.ai_moderation_flags).toBe("test_flag"); + expect(retrieved?.ai_moderation_score).toBe(0.5); + expect(retrieved?.ai_analysis).toBe("This is clean"); + + // Edit the message + await updateMessageAsEdited(messageId, "edited content", Date.now()); + + // Verify AI fields are reset + retrieved = await getMessageById(messageId); + expect(retrieved?.edited_content).toBe("edited content"); + expect(retrieved?.type).toBe("edited"); + expect(retrieved?.ai_status).toBe("pending"); + expect(retrieved?.ai_moderation_flags).toBeNull(); + expect(retrieved?.ai_moderation_score).toBeNull(); + expect(retrieved?.ai_moderation_raw).toBeNull(); + expect(retrieved?.ai_analysis).toBeNull(); + expect(retrieved?.ai_analyzed_at).toBeNull(); + expect(retrieved?.ai_error).toBeNull(); + }); + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index faa6d98..5b53e74 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,7 @@ import { defineConfig } from "vitest/config"; +import { config } from "dotenv"; + +config({ path: ".env.test" }); export default defineConfig({ test: {