Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
870f7546fb | ||
|
|
a54c34c6b3 | ||
|
|
7375e2ed52 | ||
|
|
5196cb0221 | ||
|
|
631b5e1027 | ||
|
|
8049b70738 | ||
|
|
ea23c405fa | ||
|
|
4991164591 | ||
|
|
1590479f58 | ||
|
|
0de393625f | ||
|
|
cccfd89266 | ||
|
|
17aad2cc39 | ||
|
|
a6e2c1fa4f | ||
|
|
7e0d0d5123 | ||
|
|
4f4f92706c | ||
|
|
611ba39973 | ||
|
|
192685e1ce | ||
|
|
cacebfd94e | ||
|
|
709074935f | ||
|
|
9f02edd646 | ||
|
|
5fc3cf86d5 | ||
|
|
e81391484b | ||
|
|
3b8fe1b1c3 | ||
|
|
54e7220d06 | ||
|
|
46d889271e | ||
|
|
0c3a82ad77 | ||
|
|
b640079cb6 | ||
|
|
37d15456dc | ||
|
|
153e628aba | ||
|
|
a5f908dc06 | ||
|
|
7fc36170c8 | ||
|
|
ccfe769b2f | ||
|
|
3b688e0d0f | ||
|
|
29f59fd91f | ||
|
|
c3165ed702 | ||
|
|
c72fadbbb4 | ||
|
|
613840e2d1 | ||
|
|
24336186dc | ||
|
|
6b66863897 | ||
|
|
fc40609a3a | ||
|
|
e157ae3ce4 | ||
|
|
3f4c05f5bb | ||
|
|
9c25e0887c | ||
|
|
2aa12b9ec5 | ||
|
|
cab451f0e5 | ||
|
|
183a51003a | ||
|
|
f34811e9b6 | ||
|
|
2c30140ddd | ||
|
|
7e92de63d6 | ||
|
|
68be4352da | ||
|
|
754ef78ff1 | ||
|
|
2959744e0c | ||
|
|
cffa72c21f | ||
|
|
d64c5ba7b6 | ||
|
|
87851f7a71 | ||
|
|
91de43a6c6 | ||
|
|
f8fc08de41 | ||
|
|
dfa69e9f45 | ||
|
|
0bd4b4075e | ||
|
|
b679a02cb9 | ||
|
|
4ed1d3c053 | ||
|
|
8dbcc2b527 | ||
|
|
bda210475d | ||
|
|
9b78abaf3d | ||
|
|
53c4158511 | ||
|
|
a2a8c824bf | ||
|
|
dec548fa2f | ||
|
|
12033e5537 | ||
|
|
d06caa7e58 | ||
|
|
173ae3c61c | ||
|
|
2637bbc549 | ||
|
|
6fc68126ac | ||
|
|
28d09a24c9 | ||
|
|
f2ec642a0e | ||
|
|
8a491b9ec2 | ||
|
|
7d54d3a660 | ||
|
|
21509c25ff | ||
|
|
09724287cb | ||
|
|
1ac3d50315 | ||
|
|
9c48d50d25 | ||
|
|
8bd3ec8e83 | ||
|
|
5a4a203f01 | ||
|
|
7f0aa5a7ad | ||
|
|
eb0be981c6 | ||
|
|
466e117bd5 |
+1
-1
@@ -86,7 +86,7 @@ BACKLOG_SYNC_BATCH_SIZE=100 # Messages per backlog batch, max 100 (d
|
||||
AI_ANALYSIS_ENABLED=false # Enable AI content moderation (default: false)
|
||||
AI_LLM_API_KEY= # REQUIRED if AI_ANALYSIS_ENABLED=true. LLM API key
|
||||
AI_LLM_BASE_URL=https://9router.asepharyana.my.id/v1 # LLM API base URL (9router — OpenAI-compatible router, replaces omniroute)
|
||||
AI_LLM_MODEL=claude-opus-5 # LLM text model name (default: claude-opus-5)
|
||||
AI_LLM_MODEL=text # LLM text model name (default: text)
|
||||
# AI_LLM_VISION_MODEL= # Vision model for image analysis (falls back to AI_LLM_MODEL)
|
||||
# AI_LLM_EMBEDDING_MODEL= # Embedding model for semantic moderation cache (optional; enables near-duplicate text reuse to save LLM calls)
|
||||
# AI_LLM_EMBEDDING_MIN_SIMILARITY=0.97 # Min cosine similarity to reuse a cached verdict (default: 0.97)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
name: Dependabot Auto-Merge
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
steps:
|
||||
- name: Enable auto-merge for Dependabot PR
|
||||
run: gh pr merge --auto --merge "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,38 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/services/backend"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
production:
|
||||
dependency-type: "production"
|
||||
update-types: ["minor", "patch"]
|
||||
development:
|
||||
dependency-type: "development"
|
||||
update-types: ["minor", "patch"]
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/services/discord-gateway"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
production:
|
||||
dependency-type: "production"
|
||||
update-types: ["minor", "patch"]
|
||||
development:
|
||||
dependency-type: "development"
|
||||
update-types: ["minor", "patch"]
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/services/frontend"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
production:
|
||||
dependency-type: "production"
|
||||
update-types: ["minor", "patch"]
|
||||
development:
|
||||
dependency-type: "development"
|
||||
update-types: ["minor", "patch"]
|
||||
@@ -1,20 +0,0 @@
|
||||
name: Publish to FlakeHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
flakehub-publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: DeterminateSystems/determinate-nix-action@main
|
||||
- uses: DeterminateSystems/flakehub-push@main
|
||||
with:
|
||||
visibility: public
|
||||
rolling: true
|
||||
@@ -21,21 +21,21 @@
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^5.2.1",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.11.0",
|
||||
"pg": "^8.21.0",
|
||||
"pino": "^9.6.0",
|
||||
"ioredis": "^6.0.0",
|
||||
"pg": "^8.23.0",
|
||||
"pino": "^10.3.1",
|
||||
"prom-client": "^15.1.3",
|
||||
"ws": "^8.20.1",
|
||||
"ws": "^8.21.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "latest",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.9.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsx": "^4.22.2",
|
||||
"typescript": "^5.9.3",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "latest"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+485
-287
File diff suppressed because it is too large
Load Diff
@@ -469,17 +469,20 @@ export class MessagesRepository {
|
||||
const db = getDatabase();
|
||||
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const result = await db.execute(sql`
|
||||
SELECT channel_id,
|
||||
EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour,
|
||||
COUNT(*)::int AS c
|
||||
FROM messages
|
||||
WHERE created_at >= ${since}
|
||||
GROUP BY channel_id, hour
|
||||
ORDER BY channel_id, hour
|
||||
SELECT
|
||||
m.channel_id,
|
||||
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
|
||||
EXTRACT(HOUR FROM to_timestamp(m.created_at / 1000))::int AS hour,
|
||||
COUNT(*)::int AS c
|
||||
FROM messages m
|
||||
WHERE m.created_at >= ${since}
|
||||
GROUP BY m.channel_id, channel_name, hour
|
||||
ORDER BY channel_name, hour
|
||||
`);
|
||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||
return rows.map((r) => ({
|
||||
channelId: String(r.channel_id ?? "unknown"),
|
||||
channelName: String(r.channel_name ?? r.channel_id ?? "unknown"),
|
||||
hour: Number(r.hour ?? 0),
|
||||
count: Number(r.c ?? 0),
|
||||
}));
|
||||
@@ -491,31 +494,28 @@ export class MessagesRepository {
|
||||
*/
|
||||
async getRecentEdits(limit = 50, channelId?: string) {
|
||||
const db = getDatabase();
|
||||
const where = channelId
|
||||
? `WHERE m.channel_id = '${channelId.replace(/'/g, "''")}'`
|
||||
: "";
|
||||
const result = await db.execute(
|
||||
sql.raw(`
|
||||
SELECT
|
||||
e.id,
|
||||
e.message_id,
|
||||
e.old_content,
|
||||
e.edited_at,
|
||||
m.channel_id,
|
||||
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
|
||||
m.username
|
||||
FROM message_edits e
|
||||
JOIN messages m ON m.id = e.message_id
|
||||
${where}
|
||||
ORDER BY e.edited_at DESC
|
||||
LIMIT ${limit}
|
||||
`),
|
||||
);
|
||||
const result = await db.execute(sql`
|
||||
SELECT
|
||||
e.id,
|
||||
e.message_id,
|
||||
e.old_content,
|
||||
e.edited_at,
|
||||
m.channel_id,
|
||||
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
|
||||
m.username,
|
||||
COALESCE(m.edited_content, m.content) AS new_content
|
||||
FROM message_edits e
|
||||
JOIN messages m ON m.id = e.message_id
|
||||
${channelId ? sql`WHERE m.channel_id = ${channelId}` : sql``}
|
||||
ORDER BY e.edited_at DESC
|
||||
LIMIT ${limit}
|
||||
`);
|
||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||
return rows.map((r) => ({
|
||||
id: String(r.id),
|
||||
message_id: String(r.message_id),
|
||||
old_content: r.old_content ? String(r.old_content) : "",
|
||||
new_content: r.new_content ? String(r.new_content) : "",
|
||||
edited_at: r.edited_at ? Number(r.edited_at) : 0,
|
||||
channel_id: r.channel_id ? String(r.channel_id) : null,
|
||||
channel_name: r.channel_name ? String(r.channel_name) : null,
|
||||
|
||||
@@ -39,12 +39,15 @@ export class MessagesService {
|
||||
throw new ValidationError("message ID is required");
|
||||
}
|
||||
|
||||
const message = await messagesRepository.findById(id);
|
||||
const [message, editHistory] = await Promise.all([
|
||||
messagesRepository.findById(id),
|
||||
messagesRepository.getEditHistory(id),
|
||||
]);
|
||||
|
||||
if (!message) {
|
||||
throw new NotFoundError(`Message with ID ${id} not found`);
|
||||
}
|
||||
|
||||
const editHistory = await messagesRepository.getEditHistory(id);
|
||||
return {
|
||||
...message,
|
||||
edit_count: editHistory.length,
|
||||
|
||||
@@ -124,7 +124,8 @@ export class ModerationRepository {
|
||||
a.score,
|
||||
a.evidence,
|
||||
a.policy_version,
|
||||
m.username,
|
||||
a.username,
|
||||
a.server_nick,
|
||||
LEFT(m.content, 300) AS content
|
||||
FROM moderation_actions a
|
||||
LEFT JOIN messages m ON m.id = a.message_id
|
||||
@@ -155,6 +156,7 @@ export class ModerationRepository {
|
||||
evidence: parseJsonArray(r.evidence),
|
||||
policy_version: r.policy_version ? String(r.policy_version) : null,
|
||||
username: r.username ? String(r.username) : null,
|
||||
server_nick: r.server_nick ? String(r.server_nick) : null,
|
||||
content: r.content ? String(r.content) : null,
|
||||
}));
|
||||
|
||||
@@ -314,7 +316,7 @@ export class ModerationRepository {
|
||||
SELECT
|
||||
a.id, a.message_id, a.user_id, a.guild_id, a.action_type,
|
||||
a.reason, a.status, a.created_at, a.severity, a.confidence, a.score,
|
||||
m.username, LEFT(m.content, 300) AS content
|
||||
a.username, LEFT(m.content, 300) AS content
|
||||
FROM moderation_actions a
|
||||
LEFT JOIN messages m ON m.id = a.message_id
|
||||
WHERE a.created_at >= ${since}
|
||||
@@ -338,6 +340,7 @@ export class ModerationRepository {
|
||||
confidence: r.confidence != null ? Number(r.confidence) : null,
|
||||
score: r.score != null ? Number(r.score) : null,
|
||||
username: r.username ? String(r.username) : null,
|
||||
server_nick: r.server_nick ? String(r.server_nick) : null,
|
||||
content: r.content ? String(r.content) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -23,14 +23,26 @@ const speakers = new Map<string, LiveSpeaker>();
|
||||
const MAX_SPEAKERS = 200;
|
||||
|
||||
/**
|
||||
* Record a voice_active_user event. `speaking: true` upserts the speaker as
|
||||
* active; `speaking: false` marks them inactive while keeping them for the
|
||||
* activity timeline.
|
||||
* Speakers inactive for longer than this are auto-expired from the snapshot.
|
||||
* This handles the case where the gateway disconnects abruptly and never
|
||||
* sends `speaking: false` for active users.
|
||||
*/
|
||||
const SPEAKER_TTL_MS = 30_000;
|
||||
|
||||
/** Purge speakers that haven't been active recently. */
|
||||
function purgeStale(): void {
|
||||
const cutoff = Date.now() - SPEAKER_TTL_MS;
|
||||
for (const [id, s] of speakers) {
|
||||
if (!s.speaking && s.lastActiveAt < cutoff) {
|
||||
speakers.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* recordSpeaker(data) — apply a `voice_active_user` event. `speaking: true`
|
||||
* upserts the speaker as ACTIVE; `speaking: false` marks them inactive while
|
||||
* keeping them for the activity timeline.
|
||||
* Record a voice_active_user event. `speaking: true` upserts the speaker as
|
||||
* active; `speaking: false` marks them inactive while keeping them briefly
|
||||
* for the activity timeline (until TTL expiry).
|
||||
*/
|
||||
export function recordSpeaker(data: {
|
||||
userId: string;
|
||||
@@ -49,7 +61,6 @@ export function recordSpeaker(data: {
|
||||
};
|
||||
|
||||
if (speakers.size >= MAX_SPEAKERS && !existing) {
|
||||
// Drop the least-recently-active non-speaking speaker to stay bounded.
|
||||
let oldestId: string | null = null;
|
||||
let oldestTs = Infinity;
|
||||
for (const [id, s] of speakers) {
|
||||
@@ -65,18 +76,35 @@ export function recordSpeaker(data: {
|
||||
speakers.set(userId, speaker);
|
||||
}
|
||||
|
||||
/** All known speakers, most recently active first. */
|
||||
/**
|
||||
* All recently-active speakers, most recently active first.
|
||||
* Stale (non-speaking + old) entries are auto-purged.
|
||||
*/
|
||||
export function getActiveSpeakers(): LiveSpeaker[] {
|
||||
purgeStale();
|
||||
return [...speakers.values()].sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
||||
}
|
||||
|
||||
/** Only speakers currently flagged as speaking. */
|
||||
export function getSpeakingSpeakers(): LiveSpeaker[] {
|
||||
purgeStale();
|
||||
return [...speakers.values()]
|
||||
.filter((s) => s.speaking)
|
||||
.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark ALL tracked speakers as not-speaking and purge stale ones.
|
||||
* Called when the gateway disconnects from voice — ensures the authoritative
|
||||
* snapshot doesn't carry ghost speakers.
|
||||
*/
|
||||
export function clearAllSpeakers(): void {
|
||||
for (const [id, s] of speakers) {
|
||||
s.speaking = false;
|
||||
}
|
||||
purgeStale();
|
||||
}
|
||||
|
||||
/** Drop all tracked speakers (used on backend restart). */
|
||||
export function resetLiveSpeakers(): void {
|
||||
speakers.clear();
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
// Shared message row mapper for backend repository modules
|
||||
|
||||
function resolveServerNick(row: Record<string, unknown>): string | null {
|
||||
const metadata = row.metadata;
|
||||
if (!metadata || typeof metadata !== "string") {
|
||||
return (row.username as string | null) ?? null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(metadata) as {
|
||||
member?: { displayName?: string | null } | null;
|
||||
};
|
||||
return (
|
||||
parsed?.member?.displayName ?? (row.username as string | null) ?? null
|
||||
);
|
||||
} catch {
|
||||
return (row.username as string | null) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MappedMessage {
|
||||
id: string;
|
||||
guild_id: string;
|
||||
@@ -7,6 +24,8 @@ export interface MappedMessage {
|
||||
thread_id: string | null;
|
||||
user_id: string;
|
||||
username: string;
|
||||
/** Member's server-specific display name (nickname), from metadata.member.displayName. Falls back to username when absent. */
|
||||
server_nick: string | null;
|
||||
avatar_url: string | null;
|
||||
content: string;
|
||||
edited_content: string | null;
|
||||
@@ -42,6 +61,7 @@ export function mapMessageRow(row: Record<string, unknown>): MappedMessage {
|
||||
thread_id: (row.thread_id as string | null) ?? null,
|
||||
user_id: String(row.user_id ?? ""),
|
||||
username: String(row.username ?? ""),
|
||||
server_nick: resolveServerNick(row),
|
||||
avatar_url: (row.avatar_url as string | null) ?? null,
|
||||
content: String(row.content ?? ""),
|
||||
edited_content: (row.edited_content as string | null) ?? null,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import Redis from "ioredis";
|
||||
import { recordSpeaker } from "../modules/voice/live-speaker.js";
|
||||
import {
|
||||
clearAllSpeakers,
|
||||
recordSpeaker,
|
||||
} from "../modules/voice/live-speaker.js";
|
||||
import { config } from "../shared/config/index.js";
|
||||
import {
|
||||
DISCORD_CHANNEL_TO_WS_EVENT,
|
||||
DISCORD_VOICE_ACTIVE_USER,
|
||||
DISCORD_VOICE_PCM,
|
||||
DISCORD_VOICE_STOPPED,
|
||||
} from "../shared/index.js";
|
||||
import { createChildLogger } from "../shared/logger/index.js";
|
||||
import { broadcastBinary, broadcastEvent } from "./broadcast.js";
|
||||
@@ -84,6 +88,14 @@ function handleSubscriptionMessage(channel: string, message: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// When the gateway stops voice recording (disconnects from voice channel),
|
||||
// clear all speakers from the authoritative snapshot so frontends don't
|
||||
// show ghost participants.
|
||||
if (channel === DISCORD_VOICE_STOPPED) {
|
||||
clearAllSpeakers();
|
||||
logger.info("Voice recording stopped — cleared all live speakers");
|
||||
}
|
||||
|
||||
logger.debug({ channel, eventType }, "Broadcasting Redis event");
|
||||
broadcastEvent(eventType, data);
|
||||
}
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE "moderation_actions" ADD COLUMN IF NOT EXISTS "username" text;
|
||||
--> statement-breakpoint
|
||||
-- Backfill username from messages table for existing actions
|
||||
UPDATE "moderation_actions" a
|
||||
SET "username" = m."username"
|
||||
FROM "messages" m
|
||||
WHERE a."message_id" = m."id"
|
||||
AND a."username" IS NULL
|
||||
AND m."username" IS NOT NULL;
|
||||
+1
@@ -0,0 +1 @@
|
||||
ALTER TABLE "moderation_actions" ADD COLUMN IF NOT EXISTS "server_name" text;
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
-- Rename server_name (guild name) -> server_nick (member's server nickname)
|
||||
-- server_name was added in 0018 but never populated (all NULL), so renaming
|
||||
-- the empty column is safe and keeps retention semantics correct.
|
||||
-- Idempotent across all possible prior states:
|
||||
-- • server_name present, server_nick absent -> rename
|
||||
-- • both present (backfill created server_nick) -> drop the stale server_name
|
||||
-- • server_nick present only -> already done, no-op
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'moderation_actions' AND column_name = 'server_name'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'moderation_actions' AND column_name = 'server_nick'
|
||||
) THEN
|
||||
ALTER TABLE "moderation_actions" RENAME COLUMN "server_name" TO "server_nick";
|
||||
ELSIF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'moderation_actions' AND column_name = 'server_name'
|
||||
) THEN
|
||||
-- server_nick already exists (e.g. backfilled out-of-band) — drop the
|
||||
-- now-redundant server_name column instead of renaming into a conflict.
|
||||
ALTER TABLE "moderation_actions" DROP COLUMN "server_name";
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -120,6 +120,27 @@
|
||||
"when": 1787185000000,
|
||||
"tag": "0016_drop_user_reputations",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1787810000000,
|
||||
"tag": "0017_add_username_to_moderation_actions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1787820000000,
|
||||
"tag": "0018_add_server_name_to_moderation_actions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1787830000000,
|
||||
"tag": "0019_rename_server_name_to_server_nick",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -28,30 +28,30 @@
|
||||
"discord.js-selfbot-v13": "^3.7.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"ioredis": "^5.11.0",
|
||||
"ioredis": "^6.0.0",
|
||||
"libsodium-wrappers": "^0.8.4",
|
||||
"lru-cache": "^11.5.1",
|
||||
"openai": "^6.38.0",
|
||||
"opusscript": "^0.0.8",
|
||||
"openai": "^7.5.0",
|
||||
"opusscript": "^0.1.1",
|
||||
"p-limit": "^7.3.0",
|
||||
"p-retry": "^8.0.0",
|
||||
"pg": "^8.21.0",
|
||||
"pino": "^9.6.0",
|
||||
"piscina": "^5.1.4",
|
||||
"pg": "^8.23.0",
|
||||
"pino": "^10.3.1",
|
||||
"piscina": "^5.3.1",
|
||||
"prism-media": "2.0.0-alpha.0",
|
||||
"sharp": "^0.34.5",
|
||||
"sharp": "^0.35.3",
|
||||
"tiktoken": "^1.0.22",
|
||||
"ws": "^8.20.1",
|
||||
"ws": "^8.21.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "latest",
|
||||
"@types/node": "^25.9.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"tsx": "^4.22.2",
|
||||
"typescript": "^5.9.3",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "latest"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+660
-435
File diff suppressed because it is too large
Load Diff
@@ -163,12 +163,18 @@ export function startPendingAIAnalysisWorker(
|
||||
});
|
||||
}
|
||||
|
||||
messageStore.revertStuckProcessingMessages(300000).catch((err: unknown) => {
|
||||
logger.error(
|
||||
{ error: String(err) },
|
||||
"Failed to run stuck processing recovery",
|
||||
);
|
||||
});
|
||||
// Only revert stuck processing messages if there's active processing.
|
||||
// Avoids a DB query every recovery interval when the pipeline is idle.
|
||||
if (conversationProcessing.size > 0) {
|
||||
messageStore
|
||||
.revertStuckProcessingMessages(300000)
|
||||
.catch((err: unknown) => {
|
||||
logger.error(
|
||||
{ error: String(err) },
|
||||
"Failed to run stuck processing recovery",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
messageStore.getPendingConversationKeys(500),
|
||||
|
||||
@@ -39,7 +39,7 @@ export function deriveRecommendedAction(msg: MessageRecord): string {
|
||||
const severity = deriveSeverity(msg);
|
||||
if (
|
||||
msg.ai_status === "flagged" &&
|
||||
(severity === "critical" || severity === "high")
|
||||
(severity === "critical" || severity === "high" || severity === "medium")
|
||||
)
|
||||
return "delete";
|
||||
if (msg.ai_status === "flagged") return "review";
|
||||
@@ -135,10 +135,14 @@ export function isEligibleForAutoDelete(
|
||||
// Recommended action check
|
||||
const recommendedAction =
|
||||
analysisResult?.recommendedAction ?? deriveRecommendedAction(message);
|
||||
if (recommendedAction !== "delete" && recommendedAction !== "escalate") {
|
||||
if (
|
||||
recommendedAction !== "delete" &&
|
||||
recommendedAction !== "escalate" &&
|
||||
recommendedAction !== "warn"
|
||||
) {
|
||||
logger.debug(
|
||||
{ messageId: message.id, recommendedAction },
|
||||
"Message not eligible for auto-delete: recommended action is not delete/escalate",
|
||||
"Message not eligible for auto-delete: recommended action is not delete/escalate/warn",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Client, PermissionString } from "discord.js-selfbot-v13";
|
||||
import { LRUCache } from "lru-cache";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import { config } from "../../shared/config/config.js";
|
||||
import { parseRichMessageMetadata } from "../message-capture/messageMetadata.js";
|
||||
import { messageStore } from "../message-capture/messageStore.js";
|
||||
import type { MessageRecord } from "../message-capture/types.js";
|
||||
import {
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
} from "./autoDeleteEligibility.js";
|
||||
import { logDeletionToChannel } from "./autoDeleteLogger.js";
|
||||
import { sendDeletionNotification } from "./autoDeleteNotify.js";
|
||||
import { llmChat } from "./llmClient.js";
|
||||
import { verdictToActionFields } from "./verdictToActionFields.js";
|
||||
|
||||
const logger = createChildLogger("auto-delete-manager");
|
||||
@@ -94,6 +96,26 @@ export async function resetOffensiveNickname(
|
||||
}
|
||||
// setNickname(null) = remove nickname → Discord shows global username
|
||||
await member.setNickname(null, "[auto] nickname melanggar aturan server");
|
||||
|
||||
// ── Check if the global username is ALSO offensive ──────────────
|
||||
// After reset, the member shows their global username. If that is
|
||||
// also offensive (gambling/scam keywords), replace it with a random
|
||||
// generated username so the user can't circumvent the filter by
|
||||
// having an offensive global username.
|
||||
const refreshedMember = await guild.members.fetch(userId);
|
||||
const globalUsername = refreshedMember.user?.username ?? "";
|
||||
if (await isGlobalUsernameOffensiveAI(globalUsername)) {
|
||||
const randomName = generateRandomUsername();
|
||||
await refreshedMember.setNickname(
|
||||
randomName,
|
||||
"[auto] global username juga melanggar, diganti random",
|
||||
);
|
||||
logger.info(
|
||||
{ messageId, guildId, userId, globalUsername, randomName },
|
||||
"Global username also offensive — replaced with random username",
|
||||
);
|
||||
}
|
||||
|
||||
recentNicknameResets.set(cooldownKey, Date.now());
|
||||
logger.info(
|
||||
{ messageId, guildId, userId },
|
||||
@@ -119,6 +141,69 @@ export async function resetOffensiveNickname(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AI-Based Global Username Check ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ask the AI moderation LLM whether a global username is offensive
|
||||
* (gambling, scam, spam, NSFW, etc.). Returns `true` if the LLM
|
||||
* judges the username as violating server rules.
|
||||
*
|
||||
* Fail-open: if the LLM call fails or times out, returns `false`
|
||||
* so the nickname reset still completes — we don't want a broken LLM
|
||||
* to block enforcement.
|
||||
*/
|
||||
async function isGlobalUsernameOffensiveAI(username: string): Promise<boolean> {
|
||||
try {
|
||||
const completion = await llmChat({
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
'Kamu adalah moderator Discord. Tentukan apakah username berikut melanggar aturan server (judi, togel, scam, spam, NSFW, SARA, atau ofensif). Jawab HANYA dengan JSON: {"offensive": true} atau {"offensive": false}. Jangan penjelasan tambahan.',
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `Username: "${username}"`,
|
||||
},
|
||||
],
|
||||
max_tokens: 50,
|
||||
temperature: 0,
|
||||
stream: false,
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const content = completion?.choices?.[0]?.message?.content?.trim() ?? "";
|
||||
// Parse JSON response — handle both strict JSON and markdown-wrapped
|
||||
const jsonMatch = content.match(
|
||||
/\{[^}]*"offensive"\s*:\s*(true|false)[^}]*\}/,
|
||||
);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]) as { offensive: boolean };
|
||||
return parsed.offensive === true;
|
||||
}
|
||||
// Fallback: if response contains "true" anywhere, treat as offensive
|
||||
return content.toLowerCase().includes("true");
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{
|
||||
username,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"AI username check failed — failing open (not offensive)",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random fallback username: "User" + 5-digit number.
|
||||
* Guaranteed unique per call (random suffix).
|
||||
*/
|
||||
function generateRandomUsername(): string {
|
||||
const suffix = Math.floor(10000 + Math.random() * 90000);
|
||||
return `User${suffix}`;
|
||||
}
|
||||
|
||||
// ─── Error Handling Utilities ────────────────────────────────────────
|
||||
|
||||
function getErrorCode(error: unknown): number | string | undefined {
|
||||
@@ -130,7 +215,28 @@ function getErrorCode(error: unknown): number | string | undefined {
|
||||
|
||||
function isAlreadyDeletedError(error: unknown): boolean {
|
||||
const code = getErrorCode(error);
|
||||
return code === 10008 || code === 404 || code === "10008" || code === "404";
|
||||
// Discord REST error codes for "message not found":
|
||||
// 10008 = Unknown Message, 10003 = Unknown Channel,
|
||||
// 50001 = Missing Access (channel deleted/hidden), 404 = HTTP
|
||||
if (
|
||||
code === 10008 ||
|
||||
code === 10003 ||
|
||||
code === 50001 ||
|
||||
code === 404 ||
|
||||
code === "10008" ||
|
||||
code === "10003" ||
|
||||
code === "50001" ||
|
||||
code === "404"
|
||||
)
|
||||
return true;
|
||||
// Fallback: check the message text for the Discord "Unknown Message" string
|
||||
const msg =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: "";
|
||||
return msg.includes("Unknown Message") || msg.includes("Unknown Channel");
|
||||
}
|
||||
|
||||
function hasChannelMessagesApi(channel: unknown): channel is {
|
||||
@@ -164,6 +270,21 @@ function hasPermissionApi(channel: unknown): channel is {
|
||||
|
||||
// ─── Database Action Log ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract the member's server-specific display name (nickname) from a
|
||||
* captured message's metadata. Falls back to the global username when no
|
||||
* server nickname exists (member.displayName defaults to the guild nickname
|
||||
* when set, otherwise the global username).
|
||||
*/
|
||||
function resolveServerNick(message: MessageRecord): string | null {
|
||||
try {
|
||||
const parsed = parseRichMessageMetadata(message.metadata);
|
||||
return parsed?.member?.displayName ?? message.username ?? null;
|
||||
} catch {
|
||||
return message.username ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
async function logAutoDeleteAttempt(
|
||||
message: MessageRecord,
|
||||
result: AutoDeleteResult,
|
||||
@@ -176,6 +297,8 @@ async function logAutoDeleteAttempt(
|
||||
action_type: "delete_message",
|
||||
reason: result.reason,
|
||||
...verdictToActionFields(message),
|
||||
username: message.username,
|
||||
server_nick: resolveServerNick(message),
|
||||
executed_by: "auto-delete-manager",
|
||||
status: result.deleted
|
||||
? "executed"
|
||||
@@ -241,6 +364,8 @@ export async function attemptAutoDeleteFlaggedMessage(
|
||||
reason:
|
||||
"nickname melanggar aturan server (offensive_username); pesan dibiarkan",
|
||||
...verdictToActionFields(message),
|
||||
username: message.username,
|
||||
server_nick: resolveServerNick(message),
|
||||
executed_by: "auto-delete-manager",
|
||||
status: resetOk ? "executed" : "failed",
|
||||
error: resetOk ? null : "nickname_reset_failed",
|
||||
|
||||
@@ -163,20 +163,14 @@ export async function processBatch(
|
||||
messages,
|
||||
})) as AnalysisWorkerResponse;
|
||||
|
||||
// Do not broadcast or auto-delete if it's an API failure that will be reverted.
|
||||
// Broadcast + auto-delete only for successfully analyzed rows.
|
||||
// Error rows (API failures, parse failures, incomplete) will be
|
||||
// retried by the individual fallback queue — do NOT schedule
|
||||
// auto-delete for them (they'd be logged as not_eligible anyway).
|
||||
for (const row of result.rows) {
|
||||
let isApiFailure = false;
|
||||
if (row.ai_status === "error") {
|
||||
try {
|
||||
const flags = JSON.parse(row.ai_moderation_flags ?? "[]") as string[];
|
||||
isApiFailure = flags.includes("analysis_api_failed");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!isApiFailure) {
|
||||
broadcastAnalysisCompleted(row);
|
||||
scheduleAutoDelete(row);
|
||||
}
|
||||
if (row.ai_status === "error") continue;
|
||||
broadcastAnalysisCompleted(row);
|
||||
scheduleAutoDelete(row);
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
@@ -233,6 +227,10 @@ export async function processBatch(
|
||||
// conversation cooldown instead of an immediate individual retry.
|
||||
apiFailedMessages.push(msg);
|
||||
break;
|
||||
case "completed":
|
||||
// Successfully analyzed — already broadcast + auto-delete scheduled
|
||||
// above. Do NOT re-enqueue for individual fallback.
|
||||
break;
|
||||
default:
|
||||
// incomplete / parse_failed / unexplained drops stay retryable via
|
||||
// the individual fallback queue (same semantics as before).
|
||||
|
||||
@@ -136,6 +136,12 @@ export function formatMessageForPrompt(
|
||||
return `[${label}] id=${msg.id} time=${timestamp} user=${resolveDisplayName(msg)}: ${content}${mediaSuffix}${refInfo}`;
|
||||
}
|
||||
|
||||
/** Estimate tokens for a batch of strings — single encode call, ~5x faster than per-line. */
|
||||
export function estimateTokensBatch(texts: string[]): number {
|
||||
const combined = texts.join("\n");
|
||||
return getEncoder().encode(combined).length + texts.length * 15;
|
||||
}
|
||||
|
||||
/** Max content chars per context line — a single huge paste (log dump,
|
||||
* copypasta) must not eat the whole conversation budget. */
|
||||
const CONTEXT_LINE_CONTENT_MAX_CHARS = 1500;
|
||||
@@ -264,10 +270,7 @@ export function buildConversationContext(
|
||||
const targetLines = targets.map((msg) =>
|
||||
formatMessageForPrompt(msg, "target"),
|
||||
);
|
||||
let usedTokens = targetLines.reduce(
|
||||
(sum, line) => sum + estimateTokens(line),
|
||||
0,
|
||||
);
|
||||
let usedTokens = estimateTokensBatch(targetLines);
|
||||
|
||||
const contextLines = gatedNewestFirst.map((msg) =>
|
||||
formatMessageForPrompt(msg, "context"),
|
||||
|
||||
@@ -16,6 +16,7 @@ import { delay, retryWithBackoff } from "@/shared/utils/index";
|
||||
import { config } from "../../shared/config/config.js";
|
||||
import type { AnalysisResult } from "../message-capture/types.js";
|
||||
import { llmChat } from "./llmClient.js";
|
||||
import { parseModerationResponse } from "./moderationResponseParser.js";
|
||||
import { logModerationError } from "./responseLogger.js";
|
||||
|
||||
const log = createChildLogger("llm-caller");
|
||||
@@ -79,7 +80,8 @@ export async function callModerationLLM(
|
||||
];
|
||||
const completion = await llmChat({
|
||||
messages,
|
||||
max_tokens: maxTokens ?? 16384,
|
||||
max_tokens:
|
||||
maxTokens ?? config.AI_LLM_MAX_COMPLETION_TOKENS ?? 16384,
|
||||
jsonResponse: { type: "json_object" },
|
||||
retries: 0,
|
||||
signal,
|
||||
@@ -106,9 +108,6 @@ export async function callModerationLLM(
|
||||
if (!rawContent) throw new Error("No content in LLM response");
|
||||
|
||||
try {
|
||||
const { parseModerationResponse } = await import(
|
||||
"./moderationResponseParser.js"
|
||||
);
|
||||
return {
|
||||
parsed: parseModerationResponse(rawContent, targetIds),
|
||||
result: completion,
|
||||
|
||||
@@ -461,7 +461,9 @@ export async function runModerationAnalysis(
|
||||
cacheKey,
|
||||
stored,
|
||||
embeddingsByKey.get(cacheKey),
|
||||
).catch(() => {});
|
||||
).catch((err: unknown) => {
|
||||
log.warn({ cacheKey, error: String(err) }, "Cache write failed");
|
||||
});
|
||||
|
||||
// Dual-key write-back (2026-08-24): the FIRST analysis of a message runs
|
||||
// WITH conversation context (accurate), but its verdict is also stored
|
||||
|
||||
@@ -69,6 +69,26 @@ export async function buildCorrectedFewShotExamples(): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Few-shot correction cache (refreshes hourly)
|
||||
// ---------------------------------------------------------------------------
|
||||
let _correctedExamplesCache: string | null = null;
|
||||
let _correctedExamplesCacheAt = 0;
|
||||
const CORRECTED_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
async function getCachedCorrectedExamples(): Promise<string> {
|
||||
const now = Date.now();
|
||||
if (
|
||||
_correctedExamplesCache !== null &&
|
||||
now - _correctedExamplesCacheAt < CORRECTED_CACHE_TTL_MS
|
||||
) {
|
||||
return _correctedExamplesCache;
|
||||
}
|
||||
_correctedExamplesCache = await buildCorrectedFewShotExamples();
|
||||
_correctedExamplesCacheAt = now;
|
||||
return _correctedExamplesCache;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text-only batch
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -88,7 +108,22 @@ export async function runTextOnlyBatch(
|
||||
for (const url of extractUrlsFromText(msg.edited_content ?? msg.content))
|
||||
allUrls.add(url);
|
||||
}
|
||||
const urlArr = Array.from(allUrls).slice(0, 10);
|
||||
// Domain dedup: max 3 URLs per domain to avoid rate-limiting
|
||||
const domainCounts = new Map<string, number>();
|
||||
const urlArr: string[] = [];
|
||||
for (const url of allUrls) {
|
||||
try {
|
||||
const domain = new URL(url).hostname;
|
||||
const count = domainCounts.get(domain) ?? 0;
|
||||
if (count >= 3) continue;
|
||||
domainCounts.set(domain, count + 1);
|
||||
} catch {
|
||||
/* invalid URL, skip */
|
||||
continue;
|
||||
}
|
||||
urlArr.push(url);
|
||||
if (urlArr.length >= 10) break;
|
||||
}
|
||||
if (urlArr.length === 0) {
|
||||
return {
|
||||
text: new Map<string, string>(),
|
||||
@@ -192,7 +227,7 @@ export async function runTextOnlyBatch(
|
||||
// Corrected false-positive examples are static per batch — fetch ONCE
|
||||
// here instead of inside the per-sub-batch retry closure (which would
|
||||
// re-query the DB on every sub-batch and every parse-error retry).
|
||||
const correctedExamples = await buildCorrectedFewShotExamples();
|
||||
const correctedExamples = await getCachedCorrectedExamples();
|
||||
|
||||
// ── URL images → multimodal vision evidence (hoisted out of the sub-batch
|
||||
// loop) ───────────────────────────────────────────────────────────
|
||||
@@ -368,9 +403,13 @@ export async function runTextOnlyBatch(
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (err.name === "AbortError" || abortController.signal.aborted) {
|
||||
throw new Error(
|
||||
`Text-only batch sub-batch ${i + 1} timed out for messages ${targetIds.join(", ")}`,
|
||||
// Sub-batch timed out — log but DO NOT throw. Previous sub-batches'
|
||||
// results are already in allResults; throwing would discard them.
|
||||
log.warn(
|
||||
{ subBatch: i + 1, targetIds, timeoutMs },
|
||||
"Sub-batch timed out — preserving partial results from prior sub-batches",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
|
||||
@@ -103,6 +103,8 @@ export class ModerationHandler {
|
||||
| "kick_user"
|
||||
| "ban_user",
|
||||
reason: payload.reason ?? null,
|
||||
username: null,
|
||||
server_nick: null,
|
||||
executed_by: payload.executed_by ?? "command-handler",
|
||||
status: "executed",
|
||||
error: null,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Client } from "discord.js-selfbot-v13";
|
||||
import Redis from "ioredis";
|
||||
import { config } from "../../shared/config/config.js";
|
||||
import type { CommandMessage, CommandReply } from "../../shared/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
@@ -152,13 +153,28 @@ export class VoiceHandler {
|
||||
};
|
||||
}
|
||||
|
||||
// Double-check: verify the voice controller also reports connected
|
||||
if (this.voiceController) {
|
||||
const vcStatus = this.voiceController.getStatus();
|
||||
if (!vcStatus.connected) {
|
||||
this.logger.warn(
|
||||
"Player reports connected but voice controller says disconnected — stale state",
|
||||
);
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: false,
|
||||
data: null,
|
||||
error: "Voice channel connection is stale — reconnect first",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// IMPORTANT: transmitter needs its OWN Redis client because it calls
|
||||
// .subscribe() which converts the connection to subscriber mode. Reusing
|
||||
// the publish connection from CommandHandler would corrupt it and break
|
||||
// every command reply + status update.
|
||||
const { default: IORedis } = await import("ioredis");
|
||||
const transmitRedis = new IORedis(config.REDIS_URL);
|
||||
const transmitRedis = new Redis(config.REDIS_URL);
|
||||
await voiceTransmitter.start(transmitRedis);
|
||||
|
||||
const status = voiceTransmitter.getStatus();
|
||||
|
||||
@@ -50,6 +50,21 @@ export function finalizeSegment(input: SegmentFinalizerInput): void {
|
||||
} = input;
|
||||
|
||||
const endTime = currentSegment.endTime ?? Date.now();
|
||||
const durationMs = endTime - currentSegment.startTime;
|
||||
|
||||
// Discard segments shorter than 1 second — not useful as a recording,
|
||||
// would just be a blip of ambient noise or a mic click.
|
||||
const MIN_DURATION_MS = 1000;
|
||||
if (durationMs < MIN_DURATION_MS) {
|
||||
logger.debug(
|
||||
{ filename: currentSegment.filename, durationMs },
|
||||
"Segment too short, discarding",
|
||||
);
|
||||
// Clean up the OGG file
|
||||
fsPromises.unlink(currentSegment.filename).catch(() => {});
|
||||
fsPromises.unlink(currentSegment.jsonFilename).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.VERBOSE) {
|
||||
logger.info({ filename: currentSegment.filename }, "Segment saved");
|
||||
|
||||
@@ -23,10 +23,18 @@ export class VoiceTransmitter {
|
||||
private readonly TRANSMIT_CHANNEL = BACKEND_VOICE_TRANSMIT;
|
||||
/** Queue for PCM chunks when backpressure is active */
|
||||
private backpressureQueue: Buffer[] = [];
|
||||
/** Max queued chunks before dropping oldest (prevents unbounded memory growth) */
|
||||
private static readonly MAX_QUEUE = 500;
|
||||
/** Serialise start/stop to prevent races between rapid toggle commands */
|
||||
private gate = Promise.resolve();
|
||||
/** Set true before sending SIGTERM so exit handler knows it's intentional */
|
||||
private _expectedExit = false;
|
||||
/** Auto-stop timer: if no PCM received for this long, stop transmitter */
|
||||
private _activityTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** How long to wait without PCM before auto-stopping (10s) */
|
||||
private static readonly ACTIVITY_TIMEOUT_MS = 10_000;
|
||||
/** Max stderr bytes to retain for error reporting */
|
||||
private static readonly MAX_STDERR_BYTES = 4096;
|
||||
|
||||
/**
|
||||
* Start listening for PCM audio data from Redis and stream to Discord
|
||||
@@ -48,7 +56,6 @@ export class VoiceTransmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
this.redisSub = redis;
|
||||
this.isActive = true;
|
||||
|
||||
// Create PCM input stream
|
||||
@@ -115,10 +122,14 @@ export class VoiceTransmitter {
|
||||
this.pcmStream.pipe(this.ffmpegProcess.stdin);
|
||||
}
|
||||
|
||||
// Log FFmpeg stderr for debugging
|
||||
// Log FFmpeg stderr for debugging (cap to prevent unbounded growth)
|
||||
const stderrChunks: Buffer[] = [];
|
||||
let stderrLen = 0;
|
||||
this.ffmpegProcess.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderrChunks.push(chunk);
|
||||
stderrLen += chunk.length;
|
||||
if (stderrLen <= VoiceTransmitter.MAX_STDERR_BYTES) {
|
||||
stderrChunks.push(chunk);
|
||||
}
|
||||
});
|
||||
|
||||
this.ffmpegProcess.on("error", (err) => {
|
||||
@@ -135,8 +146,11 @@ export class VoiceTransmitter {
|
||||
const stderr = Buffer.concat(stderrChunks).toString();
|
||||
logger.error(
|
||||
{ code, stderr: stderr.slice(-500) },
|
||||
"FFmpeg exited with error",
|
||||
"FFmpeg exited with error — auto-stopping transmitter",
|
||||
);
|
||||
// FFmpeg crashed — auto-stop to prevent silent audio loss.
|
||||
// Fire-and-forget; stop() serialises with the gate.
|
||||
void this.stop();
|
||||
} else {
|
||||
logger.debug({ code }, "FFmpeg process exited");
|
||||
}
|
||||
@@ -155,11 +169,15 @@ export class VoiceTransmitter {
|
||||
);
|
||||
|
||||
// Subscribe to Redis channel for PCM data
|
||||
await this.redisSub.subscribe(this.TRANSMIT_CHANNEL);
|
||||
await redis.subscribe(this.TRANSMIT_CHANNEL);
|
||||
// Assign AFTER subscription succeeds — prevents race with stop()
|
||||
this.redisSub = redis;
|
||||
logger.info(
|
||||
{ channel: this.TRANSMIT_CHANNEL },
|
||||
"Subscribed to transmit channel",
|
||||
);
|
||||
// Start activity timer — will auto-stop if no PCM arrives within timeout
|
||||
this.resetActivityTimer();
|
||||
|
||||
this.redisSub.on("message", (channel, message) => {
|
||||
if (
|
||||
@@ -175,8 +193,21 @@ export class VoiceTransmitter {
|
||||
const pcmBuffer = Buffer.from(data.buffer, "base64");
|
||||
const stream = this.pcmStream;
|
||||
const canContinue = stream.write(pcmBuffer);
|
||||
// Backpressure: queue until drain
|
||||
// Reset activity timer — PCM is flowing
|
||||
this.resetActivityTimer();
|
||||
// Backpressure: queue until drain (cap to prevent memory leak)
|
||||
if (!canContinue) {
|
||||
if (this.backpressureQueue.length >= VoiceTransmitter.MAX_QUEUE) {
|
||||
// Drop oldest chunks to free memory — real-time audio,
|
||||
// stale data is useless
|
||||
const dropCount = Math.floor(VoiceTransmitter.MAX_QUEUE * 0.25);
|
||||
this.backpressureQueue.splice(0, dropCount);
|
||||
logger.debug(
|
||||
{ dropped: dropCount },
|
||||
"Backpressure overflow — dropping oldest PCM chunks",
|
||||
);
|
||||
}
|
||||
this.backpressureQueue.push(pcmBuffer);
|
||||
stream.once("drain", () => {
|
||||
const currentStream = this.pcmStream;
|
||||
if (!currentStream || !this.isActive) return;
|
||||
@@ -210,6 +241,30 @@ export class VoiceTransmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the voice activity timer. Called on every PCM chunk received.
|
||||
* If no chunks arrive for ACTIVITY_TIMEOUT_MS, auto-stop the transmitter
|
||||
* to prevent dead air and wasted resources.
|
||||
*/
|
||||
private resetActivityTimer(): void {
|
||||
this.clearActivityTimer();
|
||||
this._activityTimer = setTimeout(() => {
|
||||
if (!this.isActive) return;
|
||||
logger.warn(
|
||||
{ timeoutMs: VoiceTransmitter.ACTIVITY_TIMEOUT_MS },
|
||||
"Voice activity timeout — no PCM received, auto-stopping transmitter",
|
||||
);
|
||||
void this.stop();
|
||||
}, VoiceTransmitter.ACTIVITY_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
private clearActivityTimer(): void {
|
||||
if (this._activityTimer !== null) {
|
||||
clearTimeout(this._activityTimer);
|
||||
this._activityTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop transmitting and clean up resources
|
||||
*/
|
||||
@@ -232,6 +287,7 @@ export class VoiceTransmitter {
|
||||
|
||||
this.isActive = false;
|
||||
|
||||
this.clearActivityTimer();
|
||||
this.backpressureQueue = [];
|
||||
|
||||
if (this.pcmStream) {
|
||||
|
||||
@@ -150,7 +150,7 @@ export const configSchema = z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://9router.asepharyana.my.id/v1"),
|
||||
AI_LLM_MODEL: z.string().default("claude-opus-5"),
|
||||
AI_LLM_MODEL: z.string().default("text"),
|
||||
// Vision uses the SAME router/base URL as text moderation
|
||||
// (AI_LLM_BASE_URL) but a different model alias. The dedicated NVIDIA
|
||||
// multimodal endpoint was removed.
|
||||
@@ -197,6 +197,11 @@ export const configSchema = z
|
||||
.positive()
|
||||
.default(1024),
|
||||
AI_LLM_TEXT_BATCH_SIZE: z.coerce.number().int().positive().default(60),
|
||||
AI_LLM_MAX_COMPLETION_TOKENS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(16384),
|
||||
AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
|
||||
@@ -56,25 +56,56 @@ async function getFirstMigrationTag(): Promise<string> {
|
||||
return first.tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the Drizzle migration journal and return the `when` (folderMillis)
|
||||
* timestamp of the LAST migration entry. Drizzle's PG migrator applies a
|
||||
* migration only when `tracked_max_created_at < migration.when`, so seeding
|
||||
* the tracking table with this value marks every existing journal migration
|
||||
* as already applied.
|
||||
*/
|
||||
async function getLastMigrationWhen(): Promise<number> {
|
||||
const journalPath = join(
|
||||
process.cwd(),
|
||||
"drizzle/migrations/meta/_journal.json",
|
||||
);
|
||||
const raw = await readFile(journalPath, "utf-8");
|
||||
const journal: MigrationJournal = JSON.parse(raw);
|
||||
|
||||
if (!journal.entries || journal.entries.length === 0) {
|
||||
throw new Error(
|
||||
"Migration journal is empty — cannot determine last migration timestamp",
|
||||
);
|
||||
}
|
||||
|
||||
const last = journal.entries.reduce((newest, entry) =>
|
||||
entry.when > newest.when ? entry : newest,
|
||||
);
|
||||
return last.when;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed Drizzle's __drizzle_migrations tracking table for pre-existing databases
|
||||
* that were created manually or by an earlier migration system (e.g., the old
|
||||
* checkSchemaExists short-circuit). Without this, Drizzle attempts to re-create
|
||||
* all tables from 0000 and fails with "relation already exists".
|
||||
*
|
||||
* The Drizzle PG migrator (drizzle-orm 0.45.x) only reads the LAST tracked
|
||||
* migration (`SELECT ... ORDER BY created_at DESC LIMIT 1` from
|
||||
* <migrationsSchema>.__drizzle_migrations) and re-applies every journal
|
||||
* migration whose `when` is newer than that value. If the tracking table is
|
||||
* empty or missing the latest entries while the DB schema is already up to
|
||||
* date, re-running the migrations crashes with "column/relation already
|
||||
* exists" — putting the gateway into an infinite restart loop.
|
||||
*
|
||||
* So instead of early-returning when the table exists (which left it empty /
|
||||
* partial and caused this exact crash), we RECONCILE: when the DB is a
|
||||
* pre-existing one (app tables already present) and the schema already
|
||||
* reflects the latest migration, we seed the tracking table up to the latest
|
||||
* journal `when` so Drizzle skips everything instead of re-applying it.
|
||||
*/
|
||||
async function seedDrizzleHistory(client: PoolClient): Promise<void> {
|
||||
const exists = await client.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_name = '__drizzle_migrations'
|
||||
)
|
||||
`);
|
||||
const drizzleTableExists = exists.rows[0]?.exists === true;
|
||||
if (drizzleTableExists) {
|
||||
return; // already seeded, nothing to do
|
||||
}
|
||||
|
||||
// Check whether the app tables pre-exist (old ./migrations/ SQL or manual creation).
|
||||
// Check whether the app tables pre-exist (old ./migrations/ SQL or manual
|
||||
// creation). If not, this is a brand-new database — let Drizzle handle it.
|
||||
const hasTextCache = await client.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.columns
|
||||
@@ -85,12 +116,19 @@ async function seedDrizzleHistory(client: PoolClient): Promise<void> {
|
||||
return; // brand-new database, let Drizzle handle everything
|
||||
}
|
||||
|
||||
const firstMigrationTag = await getFirstMigrationTag();
|
||||
// Final schema "at latest migration" sentinel: the moderation_actions
|
||||
// table at 0019 has a server_nick column. If present, the schema is at (or
|
||||
// past) migration 0019, so any journal entries not yet tracked are safe to
|
||||
// mark as applied rather than re-running (which would fail).
|
||||
const atLatest = await client.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.columns
|
||||
WHERE table_name = 'moderation_actions' AND column_name = 'server_nick'
|
||||
)
|
||||
`);
|
||||
const schemaAtLatest = atLatest.rows[0]?.exists === true;
|
||||
|
||||
logger.info(
|
||||
{ firstMigrationTag },
|
||||
"Seeding Drizzle migration history — marking first migration as already applied on this pre-existing database",
|
||||
);
|
||||
const lastMigrationWhen = await getLastMigrationWhen();
|
||||
|
||||
// Create the Drizzle tracking table if it does not exist.
|
||||
// PG15+ locks down CREATE on the public schema for non-owner roles,
|
||||
@@ -128,21 +166,43 @@ async function seedDrizzleHistory(client: PoolClient): Promise<void> {
|
||||
throw createErr;
|
||||
}
|
||||
}
|
||||
// Drizzle's __drizzle_migrations table has no UNIQUE(hash)
|
||||
// constraint, so check manually before inserting.
|
||||
const alreadySeeded = await client.query(
|
||||
`SELECT 1 FROM "__drizzle_migrations" WHERE hash = $1 LIMIT 1`,
|
||||
[firstMigrationTag],
|
||||
);
|
||||
if (alreadySeeded.rows.length === 0) {
|
||||
await client.query(
|
||||
`INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES ($1, $2)`,
|
||||
[firstMigrationTag, Date.now()],
|
||||
);
|
||||
|
||||
// Current max tracked created_at. Drizzle only re-applies migrations newer
|
||||
// than this, so if it already reaches the journal's last `when`, nothing to do.
|
||||
const tracked = await client.query(`
|
||||
SELECT COALESCE(MAX(created_at), 0) AS max_created FROM "__drizzle_migrations"
|
||||
`);
|
||||
const trackedMax = Number(tracked.rows[0]?.max_created ?? 0);
|
||||
|
||||
if (trackedMax >= lastMigrationWhen) {
|
||||
return; // already fully tracked — Drizzle will apply only genuinely-pending ones
|
||||
}
|
||||
logger.info(
|
||||
{ firstMigrationTag },
|
||||
"Drizzle history seeded — first migration marked applied",
|
||||
|
||||
if (!schemaAtLatest) {
|
||||
// Schema is NOT yet at the latest journal migration — this is a genuinely
|
||||
// old DB that needs the pending migrations to run for real. Let Drizzle
|
||||
// apply them. (Idempotent migration SQL guards legacy partial states.)
|
||||
logger.info(
|
||||
{ trackedMax, lastMigrationWhen },
|
||||
"DB schema behind latest journal migration — leaving pending migrations to run",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Schema already reflects the latest migration but the tracking table is
|
||||
// behind (e.g. migrations applied manually/out-of-band without being
|
||||
// recorded). Seed up to the last journal `when` so Drizzle skips them
|
||||
// instead of re-running already-applied DDL and crashing. This is the
|
||||
// recovery that broke the gateway's infinite-restart loop.
|
||||
const journalTag =
|
||||
(await getFirstMigrationTag()) + `@${lastMigrationWhen}-reconciled`;
|
||||
await client.query(
|
||||
`INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES ($1, $2)`,
|
||||
[journalTag, lastMigrationWhen],
|
||||
);
|
||||
logger.warn(
|
||||
{ lastMigrationWhen },
|
||||
"Reconciled Drizzle migration history — marked schema-at-latest migrations as applied (preventing re-run of already-applied DDL)",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -642,6 +642,8 @@ export const pgModerationActionsTable = pgTable(
|
||||
score: pgReal("score"), // 0..1 raw model score
|
||||
evidence: pgText("evidence"), // JSON array of short quoted snippets
|
||||
policy_version: pgText("policy_version"), // rules.ts policy version string
|
||||
username: pgText("username"), // denormalized from messages table for retention safety
|
||||
server_nick: pgText("server_nick"), // member's server-specific display name (nickname), from metadata.member.displayName
|
||||
},
|
||||
(table) => ({
|
||||
messageIdIdx: pgIndex("idx_moderation_actions_message_id").on(
|
||||
|
||||
@@ -59,6 +59,8 @@ export const pgModerationActionsTable = pgTable(
|
||||
score: pgReal("score"), // 0..1 raw model score
|
||||
evidence: pgText("evidence"), // JSON array of short quoted snippets
|
||||
policy_version: pgText("policy_version"), // rules.ts policy version string
|
||||
username: pgText("username"), // denormalized from messages table for retention safety
|
||||
server_nick: pgText("server_nick"), // member's server-specific display name (nickname), from metadata.member.displayName
|
||||
},
|
||||
(table) => ({
|
||||
messageIdIdx: pgIndex("idx_moderation_actions_message_id").on(
|
||||
|
||||
@@ -208,6 +208,8 @@ export interface ModerationAction {
|
||||
guild_id: string;
|
||||
action_type: ModerationActionType;
|
||||
reason: string | null;
|
||||
username: string | null;
|
||||
server_nick: string | null;
|
||||
executed_by: string | null;
|
||||
status: "pending" | "executed" | "failed";
|
||||
error: string | null;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# GMW FE Rombak — Extend Tactical-HUD/GSAP Treatment Wide (2026-08-26)
|
||||
|
||||
## Context / lessons that constrain this plan
|
||||
- 2026-08-24: full novel navigation metaphor (constellation/three.js) was
|
||||
SHIPPED green on every gate then REJECTED by user for hurting usability.
|
||||
Rolled back entirely. Lesson: do NOT invent a new nav metaphor.
|
||||
- 2026-08-24: accepted direction = monochrome theme + game-menu style sidebar
|
||||
animation ON TOP of the familiar shell (rail/topbar/panel kept).
|
||||
- 2026-08-25: `dashboard` and `media` routes were already revamped into a
|
||||
"Tactical HUD" style with GSAP (`@gsap/react` useGSAP) reactive stages —
|
||||
commits 183a510, cab451f. This is the established, ACCEPTED visual language.
|
||||
- Remaining routes (`channels`, `voice`, `messages`, `moderation`,
|
||||
`recordings`, `analysis`, `glossary`) have NOT received the tactical-HUD/GSAP
|
||||
treatment — last touch was an error-handling/state pass (31e303c) or the
|
||||
constellation revert (eda5c75).
|
||||
|
||||
## Scope of THIS request
|
||||
User: "rombak fe dengan sekeren mungkin tapi tidak menyusahkan user, pewarnaan
|
||||
sesuai tidak berlebihan, pakai gsap, sekreatif mungkin."
|
||||
|
||||
Read against the history above, this means: EXTEND the already-accepted
|
||||
tactical-HUD + GSAP + monochrome visual language to the remaining routes,
|
||||
adding creative micro-interactions and view-specific "reactive stage" touches
|
||||
— NOT a new structural/navigation metaphor, NOT a color explosion.
|
||||
|
||||
## Hard constraints (do not violate)
|
||||
1. Keep the existing shell: top bar / nav rail / mobile bottom dock. No new
|
||||
navigation metaphor.
|
||||
2. Monochrome/greyscale token system stays as-is (`--color-signal/amber/
|
||||
vermilion` luminance greys). Do not introduce saturated colors; accent use
|
||||
stays restrained (used sparingly for state: active/alert/success).
|
||||
3. GSAP via `@gsap/react` `useGSAP({ scope, dependencies })` + `clearProps` on
|
||||
cleanup, per `dashboard-rombak/references/tactical-hud-gsap-pattern.md`.
|
||||
Respect `prefers-reduced-motion` (extend the kill-list in globals.css for
|
||||
any new `animate-*` class).
|
||||
4. Mobile lightness gate: any WebGL/heavy canvas work must gate off on
|
||||
<768px / pointer:coarse / saveData / deviceMemory<=4 (see AmbientCanvas
|
||||
pattern already in the codebase).
|
||||
5. Each route ships independently: edit → `pnpm format` → `npx @biomejs/biome
|
||||
check --write` → `pnpm lint` (0 errors, 0 warnings) → `tsc --noEmit` →
|
||||
`pnpm build` → commit (no Co-Authored-By trailer) → push → `gh run watch`
|
||||
for Build & Deploy (Nix) → live verify via browser_vision on
|
||||
imphnen.asepharyana.my.id/<route>/.
|
||||
6. NEVER claim "done" from reading code — every route gets a live screenshot
|
||||
check confirming: (a) not a stock template, (b) monochrome/restrained,
|
||||
(c) GSAP entrance/interaction actually fires, (d) nav/theme toggle/command
|
||||
palette still work.
|
||||
|
||||
## Per-route creative direction (reactive-stage motifs, one distinct idea per
|
||||
route so it doesn't feel copy-pasted from dashboard/media)
|
||||
- **channels**: node/roster telemetry — GSAP stagger-in channel cards with a
|
||||
"signal strength" bar-fill tween keyed to activity; hover reveals HUD-style
|
||||
meta readout.
|
||||
- **voice**: live speaker HUD — waveform/ring pulse tied to WS voice_state
|
||||
(GSAP timeline looping while speaking, `clearProps` on stop); ambient tile
|
||||
glow keyed to active speaker count.
|
||||
- **messages**: scan-line reveal on list mount + WS-driven "new message" slide
|
||||
insert (respect existing sortMessages fix — do not reintroduce the race).
|
||||
- **moderation**: alert-priority HUD — flagged severity as inverted mono
|
||||
badge with a pulse GSAP loop (no red, per accepted mono direction);
|
||||
queue-clear micro celebration (subtle, no confetti-color).
|
||||
- **recordings**: waveform scrub deck echoing media's frequency-deck visual
|
||||
language but for static recordings (progress-tied GSAP scrub, not looping).
|
||||
- **analysis**: gauge/metric reveal using the existing `radial-gauge.tsx`
|
||||
driven by a GSAP number tween (count-up) instead of an instant value snap.
|
||||
- **glossary**: lightweight — accordion/definition reveal with GSAP height
|
||||
auto + fade, kept minimal since it's low-traffic reference content.
|
||||
|
||||
## Verification checklist (must pass before calling any route "shipped")
|
||||
- [ ] tsc --noEmit clean
|
||||
- [ ] biome check: 0 errors AND 0 warnings
|
||||
- [ ] next build succeeds
|
||||
- [ ] CI green (Build & Deploy (Nix))
|
||||
- [ ] Live screenshot: monochrome, GSAP motion visible, nav/theme/command
|
||||
palette intact, not a stock template
|
||||
- [ ] Reduced-motion: new animate-* classes added to the kill-list
|
||||
- [ ] Mobile: WebGL/heavy work gated per existing pattern
|
||||
|
||||
## Rollback plan
|
||||
Every route ships as its own atomic commit. If user rejects a specific route's
|
||||
direction, `git revert <that commit>` — do not touch unrelated routes.
|
||||
@@ -12,27 +12,25 @@
|
||||
"dependencies": {
|
||||
"@orpc/client": "1.15.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.27.0",
|
||||
"motion": "^12.0.0",
|
||||
"next": "16.2.12",
|
||||
"lucide-react": "^1.33.0",
|
||||
"next": "16.3.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"partysocket": "1.3.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"swr": "^2.4.2",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"three": "^0.180.0"
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"swr": "^2.5.1",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.0",
|
||||
"@biomejs/biome": "2.5.10",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/three": "^0.180.0",
|
||||
"@types/three": "^0.185.4",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"puppeteer-core": "^25.7.0",
|
||||
"puppeteer-core": "^25.8.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+537
-376
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,7 @@
|
||||
import { PageTransition } from "@/components/shared";
|
||||
import { AnalysisView } from "./view";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function AnalysisPage() {
|
||||
return (
|
||||
<PageTransition>
|
||||
<AnalysisView />
|
||||
</PageTransition>
|
||||
);
|
||||
return <AnalysisView />;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Hash, Search, Sparkles, TrendingUp } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives";
|
||||
import { EmptyState, SectionHeader, SkeletonRows } from "@/components/shared";
|
||||
import { useChannels, useMessageSearch, useTopReactors } from "@/hooks";
|
||||
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
|
||||
import { aiTone } from "@/lib/ai-status";
|
||||
import {
|
||||
formatDuration,
|
||||
@@ -20,6 +21,15 @@ export function AnalysisView() {
|
||||
const { data: channels } = useChannels();
|
||||
const ambient = useAmbient();
|
||||
|
||||
const reactorsRef = useRef<HTMLDivElement>(null);
|
||||
const channelsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const resultsRef = useStaggerReveal<HTMLDivElement>(".search-result-card", {
|
||||
stagger: 0.03,
|
||||
y: 8,
|
||||
dependencies: [search.data?.length, query],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
ambient.set(
|
||||
query ? "amber" : "signal",
|
||||
@@ -28,74 +38,184 @@ export function AnalysisView() {
|
||||
);
|
||||
}, [query, ambient]);
|
||||
|
||||
// Bar-fill reveal for reactor leaderboard — CSS animation
|
||||
useEffect(() => {
|
||||
const container = reactorsRef.current;
|
||||
if (!container) return;
|
||||
const bars = container.querySelectorAll<HTMLElement>(".reactor-bar-fill");
|
||||
const counters = container.querySelectorAll<HTMLElement>(".reactor-count");
|
||||
if (bars.length === 0) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
bars.forEach((el) => (el.style.transform = "scaleX(1)"));
|
||||
counters.forEach((el) => {
|
||||
el.textContent = `+${el.getAttribute("data-target") ?? "0"}`;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
bars.forEach((el, i) => {
|
||||
el.style.transformOrigin = "left center";
|
||||
el.style.animationFillMode = "forwards";
|
||||
el.style.animationTimingFunction = "ease-out";
|
||||
el.style.animationName = "bar-fill-in";
|
||||
el.style.animationDuration = "0.7s";
|
||||
el.style.animationDelay = `${i * 0.06}s`;
|
||||
});
|
||||
|
||||
// Simple counter animation using rAF
|
||||
counters.forEach((el) => {
|
||||
const target = Number(el.getAttribute("data-target") ?? "0");
|
||||
const start = performance.now();
|
||||
const duration = 800;
|
||||
const step = (now: number) => {
|
||||
const progress = Math.min((now - start) / duration, 1);
|
||||
const eased = 1 - (1 - progress) ** 3;
|
||||
el.textContent = `+${Math.round(target * eased)}`;
|
||||
if (progress < 1) requestAnimationFrame(step);
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
});
|
||||
|
||||
return () => {
|
||||
bars.forEach((el) => {
|
||||
el.style.removeProperty("animation-name");
|
||||
el.style.removeProperty("animation-duration");
|
||||
el.style.removeProperty("animation-delay");
|
||||
el.style.removeProperty("animation-fill-mode");
|
||||
el.style.removeProperty("animation-timing-function");
|
||||
el.style.removeProperty("transform-origin");
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Channel rows stagger slide-in — CSS animation
|
||||
useEffect(() => {
|
||||
const container = channelsRef.current;
|
||||
if (!container) return;
|
||||
const rows = container.querySelectorAll<HTMLElement>(".channel-row");
|
||||
if (rows.length === 0) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
|
||||
rows.forEach((el, i) => {
|
||||
el.style.opacity = "0";
|
||||
el.style.animationFillMode = "forwards";
|
||||
el.style.animationTimingFunction = "ease-out";
|
||||
el.style.animationName = "stagger-slide-in";
|
||||
el.style.animationDuration = "0.35s";
|
||||
el.style.animationDelay = `${i * 0.04}s`;
|
||||
});
|
||||
|
||||
return () => {
|
||||
rows.forEach((el) => {
|
||||
el.style.removeProperty("opacity");
|
||||
el.style.removeProperty("animation-name");
|
||||
el.style.removeProperty("animation-duration");
|
||||
el.style.removeProperty("animation-delay");
|
||||
el.style.removeProperty("animation-fill-mode");
|
||||
el.style.removeProperty("animation-timing-function");
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<GlassPanel glow className="relative overflow-hidden">
|
||||
<div className="scan-line absolute inset-x-0 top-0" />
|
||||
<div className="space-y-4">
|
||||
{/* Tactical HUD Header Bar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="h-2 w-2 rounded-full bg-signal glow-pulse" />
|
||||
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
|
||||
Deep Scan · Semantic Search & Telemetry Analysis
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
|
||||
<span>ENGINE:</span>
|
||||
<span
|
||||
className="glitch-text rounded bg-signal/15 px-2 py-0.5 font-medium text-signal border border-signal/30"
|
||||
data-text={query.trim().length >= 2 ? "SCANNING" : "STANDBY"}
|
||||
>
|
||||
{query.trim().length >= 2 ? "SCANNING" : "STANDBY"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero Query Stage */}
|
||||
<GlassPanel className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sparkles className="size-5 text-signal" />
|
||||
<span className="flex size-8 items-center justify-center rounded-[6px] border border-signal/30 bg-signal/10 text-signal">
|
||||
<Sparkles className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="eyebrow">Semantic search</div>
|
||||
<h2 className="display text-balance text-2xl text-ink">
|
||||
Search the archive
|
||||
<h2 className="text-base font-semibold text-ink">
|
||||
Cross-Guild Archive Intelligence
|
||||
</h2>
|
||||
<p className="font-mono text-[11px] text-ink-muted">
|
||||
Query vectorized messages, heuristic flags, and user behavior
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-4">
|
||||
<Search className="absolute left-4 top-1/2 size-5 -translate-y-1/2 text-ink-faint" />
|
||||
<Search className="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-ink-faint" />
|
||||
<Input
|
||||
className="h-12 pl-12 text-base"
|
||||
placeholder="Find messages, patterns, flags…"
|
||||
className="h-10 pl-10 text-xs text-ink"
|
||||
placeholder="Search keywords, behavioral patterns, infraction terms..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{query.trim().length > 0 && query.trim().length < 2 && (
|
||||
<div className="mono mt-2 text-xs text-ink-faint">
|
||||
Type at least 2 characters…
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-5">
|
||||
<div className="grid gap-3 lg:grid-cols-5">
|
||||
{/* Search Results Column */}
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader
|
||||
eyebrow="results"
|
||||
title="Matches"
|
||||
eyebrow="search output"
|
||||
title="Matched Log Packets"
|
||||
action={
|
||||
<span className="mono text-xs text-ink-faint">
|
||||
{(search.data ?? []).length}
|
||||
<span className="mono text-xs text-[#8a8f98]">
|
||||
{(search.data ?? []).length} results
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{query.trim().length >= 2 && search.isLoading && (
|
||||
<SkeletonRows rows={6} />
|
||||
)}
|
||||
{(search.data ?? []).length === 0 ? (
|
||||
{(search.data ?? []).length === 0 && !search.isLoading ? (
|
||||
<EmptyState
|
||||
icon={<Search className="size-7" />}
|
||||
title="No matches yet"
|
||||
description="Run a search to surface messages across the guild."
|
||||
title="No packets matched"
|
||||
description="Enter a query string above to scan across all captured guild communication."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<div ref={resultsRef} className="space-y-1.5 mt-3">
|
||||
{(search.data ?? []).map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-start gap-3 rounded-[12px] border border-hairline bg-white/[0.03] p-3"
|
||||
className="search-result-card hud-card flex items-start gap-3 p-3 transition-all"
|
||||
>
|
||||
<Avatar src={m.avatar_url} name={m.username} size={32} />
|
||||
<Avatar
|
||||
src={m.avatar_url}
|
||||
name={m.server_nick ?? m.username}
|
||||
size={32}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-ink">
|
||||
{m.username}
|
||||
<span className="text-xs font-semibold text-ink">
|
||||
{m.server_nick ?? m.username}
|
||||
</span>
|
||||
<span className="mono text-[0.65rem] text-ink-faint">
|
||||
{m.server_nick && m.server_nick !== m.username && (
|
||||
<span className="font-mono text-[10px] text-ink-muted">
|
||||
@{m.username}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-mono text-[10px] text-ink-faint">
|
||||
{getMessageChannelLabel(m)}
|
||||
</span>
|
||||
{m.ai_status && (
|
||||
<Badge tone={aiTone(m.ai_status)} className="ml-auto">
|
||||
<Badge
|
||||
tone={aiTone(m.ai_status)}
|
||||
className="ml-auto font-mono text-[9px]"
|
||||
>
|
||||
{m.ai_analysis_duration_ms &&
|
||||
m.ai_analysis_duration_ms > 0
|
||||
? `${m.ai_status} · ${formatDuration(m.ai_analysis_duration_ms)}`
|
||||
@@ -103,7 +223,7 @@ export function AnalysisView() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm text-ink-soft">
|
||||
<div className="mt-1 text-xs text-ink-soft leading-relaxed">
|
||||
{renderMessageContent(m.content, m.metadata) || "(embed)"}
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,17 +233,18 @@ export function AnalysisView() {
|
||||
)}
|
||||
</GlassPanel>
|
||||
|
||||
<div className="space-y-5 lg:col-span-2">
|
||||
{/* Culture & Leaderboard Side Column */}
|
||||
<div className="space-y-3 lg:col-span-2">
|
||||
<GlassPanel>
|
||||
<SectionHeader
|
||||
eyebrow="culture"
|
||||
eyebrow="engagement"
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<TrendingUp className="size-4 text-signal" /> Top reactors
|
||||
<span className="flex items-center gap-1.5">
|
||||
<TrendingUp className="size-3.5 text-signal" /> Top Reactors
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div ref={reactorsRef} className="space-y-2 mt-3">
|
||||
{(reactors ?? []).slice(0, 6).map((r, i) => {
|
||||
const maxNet = reactors?.[0]?.net_count || 1;
|
||||
const pct = Math.max(
|
||||
@@ -133,27 +254,32 @@ export function AnalysisView() {
|
||||
return (
|
||||
<div
|
||||
key={r.user_id}
|
||||
className="flex items-center gap-3 text-sm"
|
||||
className="flex items-center gap-3 text-xs"
|
||||
>
|
||||
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||
<span className="w-28 shrink-0 truncate text-ink-soft sm:w-40">
|
||||
<span className="font-mono w-4 text-[10px] text-ink-faint">
|
||||
0{i + 1}
|
||||
</span>
|
||||
<span className="w-24 shrink-0 truncate font-mono text-xs text-ink sm:w-32">
|
||||
{r.username}
|
||||
</span>
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/8">
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
|
||||
<div
|
||||
className="h-full rounded-full bg-signal/70"
|
||||
className="reactor-bar-fill h-full rounded-full bg-signal"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="mono w-12 text-right text-xs text-signal">
|
||||
+{r.net_count}
|
||||
<span
|
||||
className="reactor-count font-mono w-10 text-right text-[11px] font-semibold text-signal"
|
||||
data-target={r.net_count}
|
||||
>
|
||||
+0
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(reactors ?? []).length === 0 && (
|
||||
<div className="py-4 text-center text-xs text-ink-faint">
|
||||
No data
|
||||
<div className="py-4 text-center font-mono text-xs text-ink-faint">
|
||||
NO REACTOR DATA
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -161,30 +287,31 @@ export function AnalysisView() {
|
||||
|
||||
<GlassPanel>
|
||||
<SectionHeader
|
||||
eyebrow="channels"
|
||||
eyebrow="volume"
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Hash className="size-4 text-signal" /> Top channels
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Hash className="size-3.5 text-signal" /> Top Active Channels
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div ref={channelsRef} className="space-y-2 mt-3">
|
||||
{(channels ?? []).slice(0, 6).map((c) => (
|
||||
<div
|
||||
key={c.channel_id}
|
||||
className="flex items-center gap-3 text-sm"
|
||||
className="channel-row hud-card flex items-center justify-between p-2 text-xs"
|
||||
>
|
||||
<span className="flex-1 truncate text-ink-soft">
|
||||
{c.channel_name ?? c.channel_id.slice(0, 8)}
|
||||
<span className="flex items-center gap-1.5 truncate font-mono text-xs text-ink">
|
||||
<Hash className="size-3 text-signal" />
|
||||
{c.channel_name ?? c.channel_id.slice(0, 10)}
|
||||
</span>
|
||||
<span className="mono text-xs text-ink-faint">
|
||||
{c.total_messages}
|
||||
<span className="font-mono text-[10px] text-ink-muted">
|
||||
{c.total_messages.toLocaleString()} msgs
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(channels ?? []).length === 0 && (
|
||||
<div className="py-4 text-center text-xs text-ink-faint">
|
||||
No data
|
||||
<div className="py-4 text-center font-mono text-xs text-ink-faint">
|
||||
NO CHANNEL DATA
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PageTransition } from "@/components/shared";
|
||||
import { getChannelCultures } from "@/lib/api/server";
|
||||
import type { ChannelCultureRow } from "@/lib/types";
|
||||
import { ChannelsView } from "./view";
|
||||
@@ -12,9 +11,5 @@ export default async function ChannelsPage() {
|
||||
} catch {
|
||||
cultures = undefined;
|
||||
}
|
||||
return (
|
||||
<PageTransition>
|
||||
<ChannelsView initialCultures={cultures} />
|
||||
</PageTransition>
|
||||
);
|
||||
return <ChannelsView initialCultures={cultures} />;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,23 @@ export function ChannelsView({
|
||||
}) {
|
||||
const { data: cultures } = useChannelCultures(100, initialCultures);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex size-3 items-center justify-center">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-signal opacity-75" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-signal" />
|
||||
</div>
|
||||
<h1 className="font-mono text-xs font-semibold tracking-widest text-ink uppercase">
|
||||
CHANNEL ROSTER · SIGNAL_MAP
|
||||
</h1>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-1.5 rounded-md bg-surface px-2 py-0.5 font-mono text-[11px] text-ink-soft">
|
||||
<span className="text-ink-faint">NODES:</span>
|
||||
<span className="font-bold text-signal">{cultures?.length ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cultures ? (
|
||||
<ChannelCultureGlossary cultures={cultures} />
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PageTransition } from "@/components/shared";
|
||||
import { getActivity, getDashboardStats } from "@/lib/api/server";
|
||||
import { DashboardView } from "./view";
|
||||
|
||||
@@ -15,9 +14,5 @@ export default async function DashboardPage() {
|
||||
} catch {
|
||||
// Backend unavailable — client hooks will surface the error state.
|
||||
}
|
||||
return (
|
||||
<PageTransition>
|
||||
<DashboardView initialStats={stats} initialActivity={activity} />
|
||||
</PageTransition>
|
||||
);
|
||||
return <DashboardView initialStats={stats} initialActivity={activity} />;
|
||||
}
|
||||
|
||||
@@ -1,46 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
Flag,
|
||||
AudioWaveform,
|
||||
ChevronRight,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
Radio,
|
||||
ShieldAlert,
|
||||
Shield,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { AreaActivity, RadialGauge } from "@/components/charts";
|
||||
import { GlassPanel } from "@/components/primitives";
|
||||
import {
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
MetricTile,
|
||||
PageTransition,
|
||||
SectionHeader,
|
||||
SkeletonHero,
|
||||
SkeletonMetricRow,
|
||||
SkeletonPanel,
|
||||
} from "@/components/shared";
|
||||
import { MetricTile, SectionHeader } from "@/components/shared/section";
|
||||
import {
|
||||
useActivity,
|
||||
useStats,
|
||||
useTopReactions,
|
||||
useTopReactors,
|
||||
} from "@/hooks";
|
||||
import { useActivity, useStats, useTopReactions } from "@/hooks";
|
||||
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import type { DashboardStats } from "@/lib/types";
|
||||
import { staggerDelay } from "@/lib/utils";
|
||||
|
||||
function deriveSignal(stats?: DashboardStats) {
|
||||
if (!stats) return { tone: "signal" as const, label: "nominal" };
|
||||
if (!stats)
|
||||
return { tone: "signal" as const, label: "NOMINAL", state: "STABLE" };
|
||||
const total = stats.total_flagged + stats.total_clean || 1;
|
||||
const ratio = stats.total_flagged / total;
|
||||
if (stats.moderation_overview.error > 0)
|
||||
return { tone: "vermilion" as const, label: "moderation fault" };
|
||||
return { tone: "vermilion" as const, label: "FAULT", state: "MOD_ERR" };
|
||||
if (ratio > 0.25)
|
||||
return { tone: "vermilion" as const, label: "elevated flags" };
|
||||
if (ratio > 0.1) return { tone: "amber" as const, label: "watch" };
|
||||
return { tone: "signal" as const, label: "nominal" };
|
||||
return {
|
||||
tone: "vermilion" as const,
|
||||
label: "ELEVATED",
|
||||
state: "HIGH_RISK",
|
||||
};
|
||||
if (ratio > 0.1)
|
||||
return { tone: "amber" as const, label: "WATCH", state: "ADVISORY" };
|
||||
return { tone: "signal" as const, label: "NOMINAL", state: "OPTIMAL" };
|
||||
}
|
||||
|
||||
export function DashboardView({
|
||||
@@ -56,11 +58,16 @@ export function DashboardView({
|
||||
error,
|
||||
mutate: mutateStats,
|
||||
} = useStats(initialStats);
|
||||
const { data: activity } = useActivity(14, initialActivity as never);
|
||||
const { data: reactors } = useTopReactors();
|
||||
const { data: activity } = useActivity(14, initialActivity);
|
||||
const { data: reactions } = useTopReactions();
|
||||
const ambient = useAmbient();
|
||||
|
||||
const hudRef = useStaggerReveal<HTMLDivElement>(".linear-tile", {
|
||||
stagger: 0.035,
|
||||
y: 8,
|
||||
dependencies: [stats],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const s = deriveSignal(stats);
|
||||
ambient.set(
|
||||
@@ -74,20 +81,16 @@ export function DashboardView({
|
||||
return <ErrorState error={error} onRetry={() => void mutateStats()} />;
|
||||
if (!stats && isLoading)
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-4">
|
||||
<SkeletonHero />
|
||||
<SkeletonMetricRow cols={4} />
|
||||
<SkeletonPanel rows={5} />
|
||||
<div className="grid gap-5 lg:grid-cols-5">
|
||||
<SkeletonPanel className="lg:col-span-3" rows={4} />
|
||||
<SkeletonPanel className="lg:col-span-2" rows={4} />
|
||||
</div>
|
||||
<SkeletonPanel rows={4} />
|
||||
</div>
|
||||
);
|
||||
if (!stats)
|
||||
return (
|
||||
<ErrorState
|
||||
error={error ?? new Error("No data")}
|
||||
error={error ?? new Error("No telemetry stream")}
|
||||
onRetry={() => void mutateStats()}
|
||||
/>
|
||||
);
|
||||
@@ -97,284 +100,152 @@ export function DashboardView({
|
||||
const cleanRatio = s.total_clean / total;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Hero */}
|
||||
<GlassPanel glow className="game-frame relative overflow-hidden">
|
||||
<div className="scan-line absolute inset-x-0 top-0" />
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="eyebrow mb-2">GMW · Operations Grid</div>
|
||||
<h2 className="display hero-clamp leading-none text-ink glow-signal">
|
||||
Ambient Field
|
||||
</h2>
|
||||
<p className="mt-2 max-w-md text-pretty text-sm text-ink-soft">
|
||||
Real-time moderation, voice & media presence across the monitored
|
||||
guild. {formatNumber(s.total_messages)} messages captured.
|
||||
</p>
|
||||
<PageTransition>
|
||||
<div ref={hudRef} className="space-y-4">
|
||||
{/* Tactical HUD Header Bar */}
|
||||
<div className="linear-tile flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="h-2 w-2 rounded-full bg-signal glow-pulse" />
|
||||
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
|
||||
Telemetry Overview · Node 01
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-ink-soft">
|
||||
<Radio className="size-4 text-signal animate-breathe" />
|
||||
<span className="mono text-xs uppercase tracking-wider">
|
||||
{deriveSignal(s).label}
|
||||
<div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
|
||||
<span>SIGNAL:</span>
|
||||
<span className="rounded bg-signal/15 px-2 py-0.5 font-medium text-signal border border-signal/30">
|
||||
STABLE_STREAM
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{/* Primary Metric Deck */}
|
||||
<div className="linear-tile grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricTile
|
||||
label="Messages"
|
||||
label="Total Messages"
|
||||
value={formatNumber(s.total_messages)}
|
||||
hint={`${formatNumber(s.today_messages)} today`}
|
||||
icon={<MessageSquare className="size-4" />}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Clean Filter Ratio"
|
||||
value={`${Math.round(cleanRatio * 100)}%`}
|
||||
hint={`${formatNumber(s.total_clean)} verified clean`}
|
||||
tone="signal"
|
||||
icon={<MessageSquare className="size-3.5" />}
|
||||
className="animate-stagger"
|
||||
style={staggerDelay(0)}
|
||||
icon={<Shield className="size-4" />}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Flagged"
|
||||
value={formatNumber(s.total_flagged)}
|
||||
tone={s.total_flagged > 0 ? "vermilion" : "neutral"}
|
||||
hint={`${s.today_flagged} today`}
|
||||
className="animate-stagger"
|
||||
style={staggerDelay(1)}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Active 24h"
|
||||
value={formatNumber(s.active_users_24h)}
|
||||
tone="signal"
|
||||
icon={<Users className="size-3.5" />}
|
||||
className="animate-stagger"
|
||||
style={staggerDelay(2)}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Voice clips"
|
||||
label="Voice Recordings"
|
||||
value={formatNumber(s.total_voice_recordings)}
|
||||
icon={<Mic className="size-3.5" />}
|
||||
className="animate-stagger"
|
||||
style={staggerDelay(3)}
|
||||
hint="Archived audio sessions"
|
||||
icon={<Mic className="size-4" />}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Total Profiles"
|
||||
value={formatNumber(s.total_profiles)}
|
||||
hint={`${formatNumber(s.active_users_24h)} active 24h`}
|
||||
icon={<Users className="size-4" />}
|
||||
/>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
{/* Activity */}
|
||||
<GlassPanel>
|
||||
<SectionHeader
|
||||
eyebrow="14-day signal"
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Activity className="size-4 text-signal" /> Activity & moderation
|
||||
</span>
|
||||
}
|
||||
action={
|
||||
<div className="flex items-center gap-3 text-xs text-ink-soft">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-full bg-signal" /> messages
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-full bg-vermilion" /> flagged
|
||||
</span>
|
||||
{/* Dynamic Activity Area & Telemetry Gauges */}
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<GlassPanel className="linear-tile lg:col-span-2">
|
||||
<SectionHeader
|
||||
eyebrow="Activity Stream"
|
||||
title="Message Volume & Signal Density"
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<AreaActivity daily={activity?.daily ?? []} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{activity ? (
|
||||
<AreaActivity daily={activity.daily} />
|
||||
) : (
|
||||
<LoadingState label="streaming" />
|
||||
)}
|
||||
</GlassPanel>
|
||||
</GlassPanel>
|
||||
|
||||
{/* Two-column: channels + moderation */}
|
||||
<div className="grid gap-5 lg:grid-cols-5">
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader eyebrow="throughput" title="Top channels" />
|
||||
<div className="space-y-2.5">
|
||||
{s.top_channels.slice(0, 7).map((c) => {
|
||||
const pct =
|
||||
(c.message_count / (s.top_channels[0]?.message_count || 1)) *
|
||||
100;
|
||||
return (
|
||||
<div key={c.channel_id} className="flex items-center gap-3">
|
||||
<span className="w-28 shrink-0 truncate text-sm text-ink-soft sm:w-40">
|
||||
{c.channel_name ?? c.channel_id.slice(0, 8)}
|
||||
</span>
|
||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/8">
|
||||
<div
|
||||
className="h-full rounded-full bg-signal/70"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="mono w-14 text-right text-xs text-ink-faint">
|
||||
{formatNumber(c.message_count)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<SectionHeader eyebrow="trust" title="Moderation" />
|
||||
<div className="flex items-center gap-5">
|
||||
<RadialGauge
|
||||
value={cleanRatio}
|
||||
tone={
|
||||
cleanRatio > 0.8
|
||||
? "signal"
|
||||
: cleanRatio > 0.6
|
||||
? "amber"
|
||||
: "vermilion"
|
||||
}
|
||||
label={`${Math.round(cleanRatio * 100)}%`}
|
||||
sublabel="clean"
|
||||
/>
|
||||
<div className="flex-1 space-y-2 text-sm">
|
||||
<Row
|
||||
icon={<ShieldAlert className="size-4 text-signal" />}
|
||||
label="Clean"
|
||||
value={formatNumber(s.total_clean)}
|
||||
<GlassPanel className="linear-tile flex flex-col justify-between">
|
||||
<div>
|
||||
<SectionHeader
|
||||
eyebrow="Security State"
|
||||
title="Moderation Accuracy"
|
||||
/>
|
||||
<Row
|
||||
icon={<Flag className="size-4 text-vermilion" />}
|
||||
label="Flagged"
|
||||
value={formatNumber(s.total_flagged)}
|
||||
/>
|
||||
<Row
|
||||
icon={<Activity className="size-4 text-amber" />}
|
||||
label="Warned"
|
||||
value={formatNumber(s.total_warned)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-around border-t border-hairline pt-3 text-center">
|
||||
<Mini
|
||||
label="pending"
|
||||
value={s.moderation_overview.pending}
|
||||
tone="amber"
|
||||
/>
|
||||
<Mini
|
||||
label="processing"
|
||||
value={s.moderation_overview.processing}
|
||||
tone="signal"
|
||||
/>
|
||||
<Mini
|
||||
label="errors"
|
||||
value={s.moderation_overview.error}
|
||||
tone="vermilion"
|
||||
/>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
|
||||
{/* Reactors + reactions */}
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<GlassPanel>
|
||||
<SectionHeader eyebrow="engagement" title="Top reactors" />
|
||||
<div className="space-y-2">
|
||||
{(reactors ?? []).slice(0, 6).map((r, i) => {
|
||||
const maxNet = reactors?.[0]?.net_count || 1;
|
||||
const pct = Math.max(4, Math.round((r.net_count / maxNet) * 100));
|
||||
return (
|
||||
<div key={r.user_id} className="flex items-center gap-3">
|
||||
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||
<span className="w-28 shrink-0 truncate text-sm text-ink-soft sm:w-40">
|
||||
{r.username}
|
||||
</span>
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/8">
|
||||
<div
|
||||
className="h-full rounded-full bg-signal/70"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="mono w-12 text-right text-xs text-signal">
|
||||
+{formatNumber(r.net_count)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(reactors ?? []).length === 0 && <EmptyHint />}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel>
|
||||
<SectionHeader eyebrow="culture" title="Top reactions" />
|
||||
<div className="space-y-3">
|
||||
{(reactions ?? []).slice(0, 5).map((m) => (
|
||||
<div key={m.message_id} className="flex items-start gap-3">
|
||||
<div className="flex flex-wrap gap-1 pt-0.5">
|
||||
{m.top_emojis.slice(0, 3).map((e, i) => (
|
||||
<span
|
||||
key={`${m.message_id}-${i}`}
|
||||
className="text-lg leading-none"
|
||||
>
|
||||
{e.emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-ink">
|
||||
{m.content || "(no text)"}
|
||||
</div>
|
||||
<div className="mono text-[0.65rem] text-ink-faint">
|
||||
{m.username} · {m.channel_name ?? m.channel_id.slice(0, 8)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="mono text-xs text-ink-soft">
|
||||
{m.reaction_count}
|
||||
</span>
|
||||
<div className="my-6 flex items-center justify-center">
|
||||
<RadialGauge
|
||||
value={Math.round(cleanRatio * 100)}
|
||||
label="Clean Ratio"
|
||||
tone={cleanRatio > 0.85 ? "signal" : "amber"}
|
||||
size={140}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{(reactions ?? []).length === 0 && <EmptyHint />}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-hairline pt-3 text-center">
|
||||
<div>
|
||||
<div className="eyebrow">CLEAN</div>
|
||||
<div className="font-sans text-sm font-semibold text-success">
|
||||
{formatNumber(s.total_clean)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="eyebrow">FLAGGED</div>
|
||||
<div className="font-sans text-sm font-semibold text-vermilion">
|
||||
{formatNumber(s.total_flagged)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
|
||||
{/* Secondary Insights Deck */}
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{reactions && reactions.length > 0 && (
|
||||
<GlassPanel className="linear-tile">
|
||||
<SectionHeader
|
||||
eyebrow="Engagement"
|
||||
title="Top Reacted Messages"
|
||||
/>
|
||||
<div className="mt-3 space-y-2">
|
||||
{reactions.slice(0, 4).map((r) => (
|
||||
<div
|
||||
key={r.message_id}
|
||||
className="hud-card flex items-center justify-between p-2.5"
|
||||
>
|
||||
<span className="truncate pr-2 text-xs text-ink-soft">
|
||||
{r.content || "[Media/Attachment]"}
|
||||
</span>
|
||||
<span className="font-mono text-xs font-semibold text-signal shrink-0">
|
||||
{formatNumber(r.reaction_count)} reactions
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
)}
|
||||
|
||||
<GlassPanel className="linear-tile flex flex-col justify-between">
|
||||
<div>
|
||||
<SectionHeader eyebrow="Navigation" title="Quick Diagnostics" />
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<Link
|
||||
href="/moderation"
|
||||
className="hud-card flex items-center justify-between p-2.5 text-xs text-ink-soft transition-colors hover:text-ink"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Shield className="size-3.5 text-signal" />
|
||||
Review Live Moderation Queue
|
||||
</span>
|
||||
<ChevronRight className="size-3.5 text-ink-muted" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/voice"
|
||||
className="hud-card flex items-center justify-between p-2.5 text-xs text-ink-soft transition-colors hover:text-ink"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<AudioWaveform className="size-3.5 text-success" />
|
||||
Inspect Active Voice Stages
|
||||
</span>
|
||||
<ChevronRight className="size-3.5 text-ink-muted" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
{icon}
|
||||
<span className="flex-1 text-ink-soft">{label}</span>
|
||||
<span className="mono text-ink">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Mini({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: "signal" | "amber" | "vermilion";
|
||||
}) {
|
||||
const color =
|
||||
tone === "vermilion"
|
||||
? "text-vermilion"
|
||||
: tone === "amber"
|
||||
? "text-amber"
|
||||
: "text-signal";
|
||||
return (
|
||||
<div>
|
||||
<div className={`display text-xl ${color}`}>{value}</div>
|
||||
<div className="eyebrow mt-0.5">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyHint() {
|
||||
return (
|
||||
<div className="py-6 text-center text-xs text-ink-faint">
|
||||
Awaiting data…
|
||||
</div>
|
||||
</PageTransition>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PageTransition } from "@/components/shared";
|
||||
import { getGlossary } from "@/lib/api/server";
|
||||
import type { GlossaryRow } from "@/lib/types";
|
||||
import { GlossaryView } from "./view";
|
||||
@@ -12,9 +11,5 @@ export default async function GlossaryPage() {
|
||||
} catch {
|
||||
terms = undefined;
|
||||
}
|
||||
return (
|
||||
<PageTransition>
|
||||
<GlossaryView initialTerms={terms} />
|
||||
</PageTransition>
|
||||
);
|
||||
return <GlossaryView initialTerms={terms} />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PageTransition } from "@/components/shared";
|
||||
import { getMediaStatus } from "@/lib/api/server";
|
||||
import { MediaView } from "./view";
|
||||
|
||||
@@ -11,9 +10,5 @@ export default async function MediaPage() {
|
||||
} catch {
|
||||
/* client hooks surface errors */
|
||||
}
|
||||
return (
|
||||
<PageTransition>
|
||||
<MediaView initialStatus={status} />
|
||||
</PageTransition>
|
||||
);
|
||||
return <MediaView initialStatus={status} />;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ListMusic,
|
||||
Play,
|
||||
Radio,
|
||||
Repeat,
|
||||
SkipForward,
|
||||
Square,
|
||||
Volume2,
|
||||
} from "lucide-react";
|
||||
import { Disc, Play, Repeat, SkipForward, Square } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { Equalizer } from "@/components/charts";
|
||||
import { Button, GlassPanel, Input, toast } from "@/components/primitives";
|
||||
import {
|
||||
ErrorState,
|
||||
PageTransition,
|
||||
SectionHeader,
|
||||
SkeletonHero,
|
||||
SkeletonPanel,
|
||||
@@ -26,9 +20,9 @@ import {
|
||||
useMediaStop,
|
||||
useMediaWsSync,
|
||||
} from "@/hooks";
|
||||
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
|
||||
import { formatDuration } from "@/lib/format";
|
||||
import type { MediaState } from "@/lib/types";
|
||||
import { staggerDelay } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||
@@ -51,30 +45,32 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||
const playing = media?.playing ?? false;
|
||||
const current = media?.current ?? null;
|
||||
const queueList = media?.queue ?? [];
|
||||
const queueTotal = queueList.reduce(
|
||||
(acc, it) => acc + (it.durationMs ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const mediaRef = useStaggerReveal<HTMLDivElement>(".media-tile", {
|
||||
stagger: 0.04,
|
||||
y: 8,
|
||||
dependencies: [playing, queueList.length],
|
||||
});
|
||||
|
||||
const tone = playing ? "signal" : queueList.length ? "amber" : "signal";
|
||||
useEffect(() => {
|
||||
ambient.set(
|
||||
tone,
|
||||
playing ? 0.5 : 0.25,
|
||||
playing ? "now playing" : "media idle",
|
||||
playing ? 0.45 : 0.2,
|
||||
playing ? "playing stream" : "media ready",
|
||||
);
|
||||
}, [tone, playing, ambient]);
|
||||
|
||||
const onPlay = async () => {
|
||||
const u = url.trim();
|
||||
if (!u) {
|
||||
toast({ title: "Enter a media URL", tone: "vermilion" });
|
||||
toast({ title: "Enter media URL", tone: "vermilion" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await queue.mutateAsync({ url: u, mode: "music" });
|
||||
setUrl("");
|
||||
toast({ title: "Queued", tone: "signal" });
|
||||
toast({ title: "Track queued", tone: "signal" });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Queue failed",
|
||||
@@ -88,212 +84,153 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||
return <ErrorState error={error} onRetry={() => void mutate()} />;
|
||||
if (!media && isLoading)
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-4">
|
||||
<SkeletonHero />
|
||||
<SkeletonPanel rows={4} />
|
||||
<SkeletonPanel rows={3} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const mockLevels = playing
|
||||
? [0.4, 0.7, 0.9, 0.6, 0.8, 0.5, 0.9, 0.7, 0.4, 0.8, 0.6, 0.9]
|
||||
: [0.08, 0.08, 0.08, 0.08, 0.08, 0.08];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<GlassPanel glow className="relative overflow-hidden">
|
||||
<div className="scan-line absolute inset-x-0 top-0" />
|
||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-center">
|
||||
<div
|
||||
className={`flex size-32 shrink-0 items-center justify-center rounded-full border border-hairline bg-gradient-to-br from-white/10 to-white/[0.02] ${playing ? "animate-spin-disc" : "animate-spin-disc paused"}`}
|
||||
>
|
||||
<div className="flex size-28 items-center justify-center overflow-hidden rounded-full bg-canvas/60">
|
||||
{current?.thumbnailUrl ? (
|
||||
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
|
||||
<img
|
||||
src={current.thumbnailUrl}
|
||||
alt=""
|
||||
className="size-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<ListMusic className="size-10 text-signal" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="eyebrow mb-1 flex items-center gap-2">
|
||||
{playing ? (
|
||||
<>
|
||||
<span aria-hidden className="flex h-3 items-end gap-[2px]">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={`eq-${i}`}
|
||||
className="w-[3px] animate-eq rounded-full bg-signal"
|
||||
style={{
|
||||
animationDelay: `${i * 160}ms`,
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="text-signal">Now playing</span>
|
||||
</>
|
||||
) : current ? (
|
||||
"Paused"
|
||||
) : (
|
||||
"Nothing queued"
|
||||
)}
|
||||
</div>
|
||||
<h2 className="display text-balance text-2xl text-ink">
|
||||
{current?.title ?? "Nothing queued"}
|
||||
</h2>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-faint">
|
||||
{current?.source && (
|
||||
<span className="mono truncate">{current.source}</span>
|
||||
)}
|
||||
{current?.mode && (
|
||||
<span className="pill capitalize">{current.mode}</span>
|
||||
)}
|
||||
{formatDuration(current?.durationMs) && (
|
||||
<span className="mono">
|
||||
{formatDuration(current?.durationMs)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onPlay}
|
||||
disabled={queue.isPending}
|
||||
>
|
||||
<Play className="size-4" /> Queue & play
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => skip.mutate()}
|
||||
disabled={skip.isPending}
|
||||
>
|
||||
<SkipForward className="size-4" /> Skip
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => stop.mutate()}
|
||||
disabled={stop.isPending}
|
||||
>
|
||||
<Square className="size-4" /> Stop
|
||||
</Button>
|
||||
<Button
|
||||
variant={media?.loop ? "primary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => loop.mutate(!media?.loop)}
|
||||
aria-pressed={!!media?.loop}
|
||||
>
|
||||
<Repeat className="size-4" /> Loop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Input
|
||||
placeholder="Paste a YouTube / music URL…"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && onPlay()}
|
||||
<PageTransition>
|
||||
<div ref={mediaRef} className="space-y-4">
|
||||
{/* Precision Sub-Header Bar */}
|
||||
<div className="media-tile flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
playing
|
||||
? "bg-signal shadow-[0_0_8px_var(--color-signal-glow)]"
|
||||
: "bg-ink-muted"
|
||||
}`}
|
||||
/>
|
||||
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
|
||||
Audio Engine · Media Gateway
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5">
|
||||
<Volume2 className="size-4 text-ink-faint" />
|
||||
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-signal/70"
|
||||
style={{
|
||||
width: `${Math.round((media?.musicVolume ?? 0) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="mono w-9 text-right text-[0.65rem] text-ink-faint">
|
||||
{Math.round((media?.musicVolume ?? 0) * 100)}%
|
||||
<div className="flex items-center gap-2 font-mono text-[11px]">
|
||||
<span className="text-ink-muted">ENGINE:</span>
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 font-medium border ${
|
||||
playing
|
||||
? "border-signal/30 bg-signal/10 text-signal"
|
||||
: "border-hairline bg-surface-2 text-ink-muted"
|
||||
}`}
|
||||
>
|
||||
{playing ? "STREAMING" : "IDLE"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel>
|
||||
<SectionHeader
|
||||
eyebrow="up next"
|
||||
title="Queue"
|
||||
action={
|
||||
<span className="mono text-xs text-ink-faint">
|
||||
{queueList.length} track{queueList.length === 1 ? "" : "s"}
|
||||
{queueTotal && ` · ${formatDuration(queueTotal)}`}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{queueList.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
||||
<Radio className="size-6 text-ink-faint" />
|
||||
<div className="text-sm text-ink-soft">Queue is empty</div>
|
||||
<div className="text-xs text-ink-faint">
|
||||
Paste a URL above to start playback.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{queueList.map((item, i) => {
|
||||
const isNext = i === 0 && playing;
|
||||
return (
|
||||
<div
|
||||
key={`${item.source}-${i}`}
|
||||
className={`animate-stagger flex items-center gap-3 rounded-[10px] border px-3 py-2.5 ${
|
||||
isNext
|
||||
? "border-signal/40 bg-signal/[0.07]"
|
||||
: "border-hairline bg-white/5"
|
||||
}`}
|
||||
style={staggerDelay(i)}
|
||||
>
|
||||
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||
{item.thumbnailUrl ? (
|
||||
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
|
||||
<img
|
||||
src={item.thumbnailUrl}
|
||||
alt=""
|
||||
className="size-9 shrink-0 rounded-md object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border border-hairline bg-white/5">
|
||||
<ListMusic className="size-4 text-ink-faint" />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-ink">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="mono truncate text-[0.65rem] text-ink-faint">
|
||||
{item.source}
|
||||
</div>
|
||||
</div>
|
||||
{isNext && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-signal/40 bg-signal/10 px-2 py-0.5 text-[0.6rem] font-medium text-signal">
|
||||
up next
|
||||
</span>
|
||||
)}
|
||||
<span className="pill hidden capitalize sm:inline-flex">
|
||||
{item.mode ?? "music"}
|
||||
</span>
|
||||
{formatDuration(item.durationMs) && (
|
||||
<span className="mono w-10 text-right text-[0.65rem] text-ink-faint">
|
||||
{formatDuration(item.durationMs)}
|
||||
</span>
|
||||
)}
|
||||
{/* Media Playback Deck */}
|
||||
<div className="media-tile">
|
||||
<GlassPanel className="p-5">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="flex size-11 items-center justify-center rounded-[8px] border border-hairline bg-surface-2 text-signal">
|
||||
<Disc
|
||||
className={`size-6 ${playing ? "animate-spin-disc text-signal" : "text-ink-muted"}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold tracking-tight text-ink">
|
||||
{current?.title ?? "No Active Media Stream"}
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-ink-muted">
|
||||
{current
|
||||
? `${formatDuration(current.durationMs ?? 0)} · High Fidelity Stream`
|
||||
: "Queue a track via URL below"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => loop.mutateAsync()}
|
||||
className={media?.loop ? "border-signal text-signal" : ""}
|
||||
>
|
||||
<Repeat className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => skip.mutateAsync()}
|
||||
>
|
||||
<SkipForward className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => stop.mutateAsync()}
|
||||
>
|
||||
<Square className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick URL Input */}
|
||||
<div className="mt-4 flex gap-2 border-t border-hairline pt-4">
|
||||
<Input
|
||||
placeholder="Paste YouTube, SoundCloud, or Direct Stream URL..."
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Button variant="primary" size="md" onClick={onPlay}>
|
||||
<Play className="size-3.5" />
|
||||
Queue Track
|
||||
</Button>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
|
||||
{/* Spectrum & Queue Grid */}
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<GlassPanel className="media-tile lg:col-span-2">
|
||||
<SectionHeader
|
||||
eyebrow="Visualizer"
|
||||
title="Realtime Frequency Array"
|
||||
/>
|
||||
<div className="my-6 flex items-center justify-center">
|
||||
<Equalizer bars={mockLevels} />
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel className="media-tile">
|
||||
<SectionHeader
|
||||
eyebrow="Playlist"
|
||||
title={`Queue (${queueList.length})`}
|
||||
/>
|
||||
<div className="mt-3 space-y-2">
|
||||
{queueList.length === 0 ? (
|
||||
<div className="py-8 text-center font-mono text-xs text-ink-muted">
|
||||
QUEUE EMPTY
|
||||
</div>
|
||||
) : (
|
||||
queueList.map((item, idx) => (
|
||||
<div
|
||||
key={item.id ?? idx}
|
||||
className="flex items-center justify-between rounded-[6px] border border-hairline bg-surface-2 p-2.5 text-xs text-ink-soft"
|
||||
>
|
||||
<span className="truncate pr-2 font-medium">
|
||||
{item.title}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-ink-muted shrink-0">
|
||||
{formatDuration(item.durationMs ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
</div>
|
||||
</PageTransition>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PageTransition } from "@/components/shared";
|
||||
import {
|
||||
getConfig,
|
||||
getGuilds,
|
||||
@@ -28,13 +27,11 @@ export default async function MessagesPage() {
|
||||
/* client hooks surface errors */
|
||||
}
|
||||
return (
|
||||
<PageTransition>
|
||||
<MessagesView
|
||||
initialGuilds={guilds}
|
||||
initialGuildId={config?.monitorGuildId ?? null}
|
||||
initialMessages={initialMessages}
|
||||
initialEdits={initialEdits}
|
||||
/>
|
||||
</PageTransition>
|
||||
<MessagesView
|
||||
initialGuilds={guilds}
|
||||
initialGuildId={config?.monitorGuildId ?? null}
|
||||
initialMessages={initialMessages}
|
||||
initialEdits={initialEdits}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import {
|
||||
AlertTriangle,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
History,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Paperclip,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ActivityHeatmap } from "@/components/ActivityHeatmap";
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
useReviewWsSync,
|
||||
useSemanticSearch,
|
||||
} from "@/hooks";
|
||||
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
|
||||
import { aiTone } from "@/lib/ai-status";
|
||||
import {
|
||||
formatBytes,
|
||||
@@ -55,9 +58,9 @@ import type {
|
||||
AiStatus,
|
||||
EditHistoryRow,
|
||||
Guild,
|
||||
MessageMetadata,
|
||||
MessageRecord,
|
||||
} from "@/lib/types";
|
||||
import { staggerDelay } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
export function MessagesView({
|
||||
@@ -101,10 +104,14 @@ export function MessagesView({
|
||||
channelId ?? undefined,
|
||||
initialMessages ?? undefined,
|
||||
);
|
||||
// Stream history one message per WS frame (replaces the 50-row batched fetch).
|
||||
// Drives snapshots into the SWR list above as they arrive; falls back to the
|
||||
// Stream history over WS.
|
||||
// Drives snapshots into the SWR list above buffered by rAF; falls back to the
|
||||
// SSR `initialMessages` seed if WS is unavailable.
|
||||
useMessagesStream(ws, guildId ?? "", channelId ?? undefined);
|
||||
const { streaming } = useMessagesStream(
|
||||
ws,
|
||||
guildId ?? "",
|
||||
channelId ?? undefined,
|
||||
);
|
||||
// Cursor to the next (older) page + whether more history exists.
|
||||
const { data: pageInfo } = useMessagesHasMore(
|
||||
guildId ?? "",
|
||||
@@ -217,9 +224,42 @@ export function MessagesView({
|
||||
prevLen.current = list.length;
|
||||
}, [list.length, searching]);
|
||||
|
||||
// Depend on viewMode and initial load flag, NOT display.length, so streaming 200 items doesn't thrash animation
|
||||
const streamRef = useStaggerReveal<HTMLDivElement>(".msg-feed-card", {
|
||||
stagger: 0.02,
|
||||
y: 6,
|
||||
dependencies: [viewMode],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<GlassPanel className="flex flex-wrap items-center gap-3">
|
||||
{/* Tactical HUD Header Bar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="h-2 w-2 rounded-full bg-signal glow-pulse" />
|
||||
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
|
||||
Chat Log Stream · Ingestion Stream
|
||||
</h1>
|
||||
{streaming && (
|
||||
<span className="flex items-center gap-1 font-mono text-[10px] text-signal animate-pulse">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
STREAMING
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
|
||||
<span>MODE:</span>
|
||||
<span
|
||||
className="glitch-text rounded bg-signal/15 px-2 py-0.5 font-medium text-signal border border-signal/30"
|
||||
data-text={searching ? "SEARCH_ACTIVE" : viewMode.toUpperCase()}
|
||||
>
|
||||
{searching ? "SEARCH_ACTIVE" : viewMode.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter and Mode Bar */}
|
||||
<GlassPanel className="flex flex-wrap items-center gap-3 p-3">
|
||||
<GuildChannelPicker
|
||||
mode="text"
|
||||
guildsInitial={initialGuilds}
|
||||
@@ -236,48 +276,72 @@ export function MessagesView({
|
||||
<div className="relative ml-auto w-full sm:w-64">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-ink-faint" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Search messages…"
|
||||
className="pl-9 text-xs"
|
||||
placeholder="Search captured logs..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSemanticMode((v) => !v)}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${
|
||||
semanticMode
|
||||
? "border-signal/40 bg-signal/10 text-signal"
|
||||
: "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]"
|
||||
}`}
|
||||
title="Toggle semantic (vector) search over the message archive"
|
||||
>
|
||||
{semanticMode ? "Semantic" : "Exact"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setViewMode((v) => (v === "feed" ? "timeline" : "feed"))
|
||||
}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${
|
||||
viewMode === "timeline"
|
||||
? "border-signal/40 bg-signal/10 text-signal"
|
||||
: "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]"
|
||||
}`}
|
||||
title="Toggle timeline (date-grouped) view"
|
||||
>
|
||||
{viewMode === "timeline" ? "Timeline" : "Feed"}
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5 rounded-[6px] border border-hairline bg-surface-2 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSemanticMode(false)}
|
||||
className={`rounded-[4px] px-2.5 py-1 font-mono text-[10px] font-medium transition-all ${
|
||||
!semanticMode
|
||||
? "bg-surface text-ink border border-hairline-focus shadow-xs"
|
||||
: "text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
EXACT
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSemanticMode(true)}
|
||||
className={`flex items-center gap-1 rounded-[4px] px-2.5 py-1 font-mono text-[10px] font-medium transition-all ${
|
||||
semanticMode
|
||||
? "bg-signal/20 text-signal border border-signal/40 shadow-xs"
|
||||
: "text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="size-3" />
|
||||
SEMANTIC
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 rounded-[6px] border border-hairline bg-surface-2 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("feed")}
|
||||
className={`rounded-[4px] px-2.5 py-1 font-mono text-[10px] font-medium transition-all ${
|
||||
viewMode === "feed"
|
||||
? "bg-surface text-ink border border-hairline-focus shadow-xs"
|
||||
: "text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
FEED
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("timeline")}
|
||||
className={`rounded-[4px] px-2.5 py-1 font-mono text-[10px] font-medium transition-all ${
|
||||
viewMode === "timeline"
|
||||
? "bg-surface text-ink border border-hairline-focus shadow-xs"
|
||||
: "text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
TIMELINE
|
||||
</button>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-5">
|
||||
<div className="grid gap-3 lg:grid-cols-5">
|
||||
{semanticSearching && (
|
||||
<GlassPanel className="lg:col-span-5">
|
||||
<SectionHeader
|
||||
eyebrow="semantic"
|
||||
title={`“${query}”`}
|
||||
eyebrow="semantic archive"
|
||||
title={`Vector Matches for “${query}”`}
|
||||
action={
|
||||
<span className="mono text-xs text-ink-faint">
|
||||
<span className="mono text-xs text-[#8a8f98]">
|
||||
{semantic.data?.length ?? 0} matches
|
||||
</span>
|
||||
}
|
||||
@@ -289,19 +353,18 @@ export function MessagesView({
|
||||
{semantic.data.map((r, i) => (
|
||||
<div
|
||||
key={r.message_id ?? i}
|
||||
className="animate-stagger flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3"
|
||||
style={staggerDelay(i)}
|
||||
className="hud-card flex items-start gap-3 p-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="mono text-[0.6rem] text-signal">
|
||||
{(r.score * 100).toFixed(0)}%
|
||||
<span className="font-mono text-[10px] font-semibold text-signal">
|
||||
{(r.score * 100).toFixed(0)}% RELEVANCE
|
||||
</span>
|
||||
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
||||
<span className="ml-auto font-mono text-[10px] text-ink-muted">
|
||||
{formatRelativeTime(r.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-3 text-sm text-ink-soft">
|
||||
<div className="mt-1 text-xs text-ink-soft leading-relaxed">
|
||||
{r.content}
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,19 +375,20 @@ export function MessagesView({
|
||||
<EmptyState
|
||||
icon={<Search className="size-7" />}
|
||||
title="No semantic matches"
|
||||
description="Try different wording — semantic search finds meaning, not exact text."
|
||||
description="Try different phrasing — vector search inspects contextual semantics."
|
||||
/>
|
||||
)}
|
||||
</GlassPanel>
|
||||
)}
|
||||
|
||||
{/* Message Stream Deck */}
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader
|
||||
eyebrow={searching ? "results" : "live feed"}
|
||||
title={searching ? `“${query}”` : "Messages"}
|
||||
eyebrow={searching ? "query results" : "realtime log"}
|
||||
title={searching ? `Matches for “${query}”` : "Message Stream"}
|
||||
action={
|
||||
<span className="mono text-xs text-ink-faint">
|
||||
{list.length} shown
|
||||
<span className="mono text-xs text-[#8a8f98]">
|
||||
{list.length} messages
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
@@ -335,38 +399,37 @@ export function MessagesView({
|
||||
) : list.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<MessageSquare className="size-7" />}
|
||||
title="No messages"
|
||||
description="Pick a guild to begin, or run a search."
|
||||
title="No messages captured"
|
||||
description="Select a channel or verify discord bridge is active."
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
{!searching && (
|
||||
<div className="mb-2 flex items-center justify-center gap-2">
|
||||
{loadMore.isPending ? (
|
||||
<span className="flex items-center gap-1.5 text-xs text-ink-soft">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Loading older…
|
||||
<span className="flex items-center gap-1.5 font-mono text-xs text-ink-muted">
|
||||
<Loader2 className="size-3.5 animate-spin text-signal" />
|
||||
FETCHING EARLIER PACKETS...
|
||||
<span className="typing-dots">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
</span>
|
||||
) : hasMore && loadedPages < MAX_OLDER_PAGES ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadOlder}
|
||||
className="rounded-full border border-hairline bg-white/[0.03] px-3 py-1 text-xs text-ink-soft transition-colors hover:bg-white/[0.06]"
|
||||
className="rounded-md border border-hairline bg-surface-2 px-3 py-1 font-mono text-[10px] text-ink-muted transition-colors hover:bg-surface hover:text-ink"
|
||||
>
|
||||
↑ Load older messages
|
||||
↑ LOAD PREVIOUS BATCH
|
||||
</button>
|
||||
) : loadedPages >= MAX_OLDER_PAGES ? (
|
||||
<span className="mono text-[0.65rem] text-ink-faint">
|
||||
capped at {MAX_OLDER_PAGES} older pages · use search for
|
||||
more
|
||||
</span>
|
||||
) : (
|
||||
messages &&
|
||||
messages.length > 0 && (
|
||||
<span className="mono text-[0.65rem] text-ink-faint">
|
||||
beginning of history
|
||||
</span>
|
||||
)
|
||||
<span className="font-mono text-[10px] text-ink-faint">
|
||||
{loadedPages >= MAX_OLDER_PAGES
|
||||
? `CAPPED AT ${MAX_OLDER_PAGES} PAGES`
|
||||
: "STREAM ROOT REACHED"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -375,8 +438,6 @@ export function MessagesView({
|
||||
className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1"
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
// Track whether the user is near the bottom (to follow live
|
||||
// messages) and auto-load older messages when scrolled to top.
|
||||
nearBottomRef.current =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
||||
if (searching || !hasMore || loadMore.isPending) return;
|
||||
@@ -386,44 +447,47 @@ export function MessagesView({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{viewMode === "timeline" && timelineNodes
|
||||
? timelineNodes.map((node, _i) =>
|
||||
node.type === "date" ? (
|
||||
<div
|
||||
key={`date-${node.iso}`}
|
||||
className="flex items-center gap-2 px-1 text-[0.65rem] text-ink-faint"
|
||||
>
|
||||
<Calendar className="size-3" />
|
||||
{node.label}
|
||||
</div>
|
||||
) : (
|
||||
<div ref={streamRef} className="space-y-1.5">
|
||||
{viewMode === "timeline" && timelineNodes
|
||||
? timelineNodes.map((node) =>
|
||||
node.type === "date" ? (
|
||||
<div
|
||||
key={`date-${node.iso}`}
|
||||
className="flex items-center gap-2 py-1 font-mono text-[10px] text-ink-muted"
|
||||
>
|
||||
<Calendar className="size-3 text-signal" />
|
||||
{node.label}
|
||||
</div>
|
||||
) : (
|
||||
<MessageRow
|
||||
key={node.m.id}
|
||||
m={node.m}
|
||||
selected={selected}
|
||||
onSelect={setSelected}
|
||||
/>
|
||||
),
|
||||
)
|
||||
: display.map((m) => (
|
||||
<MessageRow
|
||||
key={node.m.id}
|
||||
m={node.m}
|
||||
key={m.id}
|
||||
m={m}
|
||||
selected={selected}
|
||||
onSelect={setSelected}
|
||||
/>
|
||||
),
|
||||
)
|
||||
: display.map((m, _i) => (
|
||||
<MessageRow
|
||||
key={m.id}
|
||||
m={m}
|
||||
selected={selected}
|
||||
onSelect={setSelected}
|
||||
/>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
|
||||
{/* Message Inspector Detail Panel */}
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<SectionHeader eyebrow="inspect" title="Detail" />
|
||||
<SectionHeader eyebrow="telemetry analysis" title="Inspector Deck" />
|
||||
{!selected ? (
|
||||
<EmptyState
|
||||
title="Select a message"
|
||||
description="Click any message to inspect AI analysis, attachments and edit history."
|
||||
description="Click any packet in the stream to inspect AI flags, moderation heuristics, and attachments."
|
||||
/>
|
||||
) : detail.loading ? (
|
||||
<div className="space-y-2">
|
||||
@@ -436,7 +500,7 @@ export function MessagesView({
|
||||
attachments={detail.attachments}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState title="Not found" />
|
||||
<EmptyState title="Packet not found" />
|
||||
)}
|
||||
</GlassPanel>
|
||||
</div>
|
||||
@@ -493,12 +557,25 @@ function MessageDetail({
|
||||
}) {
|
||||
const flags = safeParseJsonArray(m.ai_moderation_flags);
|
||||
const cats = safeParseJsonArray(m.ai_categories);
|
||||
const editHistory =
|
||||
(m as { edit_history?: Array<{ old_content: string; edited_at: number }> })
|
||||
.edit_history ?? [];
|
||||
const editCount = (m as { edit_count?: number }).edit_count ?? 0;
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar src={m.avatar_url} name={m.username} size={40} />
|
||||
<Avatar
|
||||
src={m.avatar_url}
|
||||
name={m.server_nick ?? m.username}
|
||||
size={40}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-semibold text-ink">{m.username}</div>
|
||||
<div className="font-semibold text-ink">
|
||||
{m.server_nick ?? m.username}
|
||||
</div>
|
||||
{m.server_nick && m.server_nick !== m.username && (
|
||||
<div className="text-[11px] text-ink-muted">@{m.username}</div>
|
||||
)}
|
||||
<div className="mono text-[0.65rem] text-ink-faint">
|
||||
{getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)}
|
||||
</div>
|
||||
@@ -511,15 +588,15 @@ function MessageDetail({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">
|
||||
<div className="hud-card p-3 text-xs text-ink-soft leading-relaxed">
|
||||
{renderMessageContent(m.edited_content ?? m.content, m.metadata) ||
|
||||
"(no text)"}
|
||||
"(no text content)"}
|
||||
</div>
|
||||
|
||||
{m.ai_analysis && (
|
||||
<div>
|
||||
<div className="eyebrow mb-1">AI analysis</div>
|
||||
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">
|
||||
<div className="eyebrow mb-1">AI heuristic reasoning</div>
|
||||
<div className="hud-card p-3 text-xs text-ink-muted leading-relaxed">
|
||||
{m.ai_analysis}
|
||||
</div>
|
||||
</div>
|
||||
@@ -540,6 +617,44 @@ function MessageDetail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit history — before / after diff */}
|
||||
{editCount > 0 && editHistory.length > 0 && (
|
||||
<div>
|
||||
<div className="eyebrow mb-1.5 flex items-center gap-1.5">
|
||||
<History className="size-3" /> Edit history ({editCount})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{editHistory.map((e, i) => (
|
||||
<div
|
||||
key={`${m.id}-edit-${i}`}
|
||||
className="grid grid-cols-2 gap-1.5 rounded-[6px] border border-hairline bg-surface-2/50 text-[10px] leading-relaxed"
|
||||
>
|
||||
<div className="overflow-hidden rounded-l-[5px] border-r border-hairline">
|
||||
<div className="border-b border-hairline bg-vermilion/5 px-2 py-0.5 font-mono text-[8px] font-semibold uppercase tracking-wider text-vermilion">
|
||||
Before
|
||||
</div>
|
||||
<div className="max-h-20 overflow-y-auto p-1.5">
|
||||
<pre className="whitespace-pre-wrap break-words text-ink-faint/80">
|
||||
{e.old_content || <em>(empty)</em>}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-r-[5px]">
|
||||
<div className="border-b border-hairline bg-success/5 px-2 py-0.5 font-mono text-[8px] font-semibold uppercase tracking-wider text-success">
|
||||
After
|
||||
</div>
|
||||
<div className="max-h-20 overflow-y-auto p-1.5">
|
||||
<pre className="whitespace-pre-wrap break-words text-ink-soft">
|
||||
{m.content || <em>(empty)</em>}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div>
|
||||
<div className="eyebrow mb-1 flex items-center gap-1.5">
|
||||
@@ -552,11 +667,11 @@ function MessageDetail({
|
||||
href={a.discord_url ?? a.uploaded_url ?? "#"}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft hover:text-ink"
|
||||
className="hud-card flex items-center gap-2 px-3 py-2 text-xs text-ink-soft hover:text-ink"
|
||||
>
|
||||
<ImageIcon className="size-3.5 text-signal" />
|
||||
<span className="flex-1 truncate">{a.filename}</span>
|
||||
<span className="mono text-ink-faint">
|
||||
<span className="mono text-[10px] text-ink-muted">
|
||||
{formatBytes(a.size)}
|
||||
</span>
|
||||
</a>
|
||||
@@ -568,6 +683,22 @@ function MessageDetail({
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse metadata JSON safely — returns null on malformed / missing data. */
|
||||
function parseMeta(raw: string | null | undefined): MessageMetadata | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as MessageMetadata;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when an attachment content-type looks like an image we can inline. */
|
||||
function isImageType(ct?: string | null): boolean {
|
||||
if (!ct) return false;
|
||||
return ct.startsWith("image/");
|
||||
}
|
||||
|
||||
/** Single message card used by both the live feed and the date-grouped timeline. */
|
||||
function MessageRow({
|
||||
m,
|
||||
@@ -578,35 +709,158 @@ function MessageRow({
|
||||
selected: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const meta = parseMeta(m.metadata);
|
||||
const attachments = meta?.attachments ?? [];
|
||||
const embeds = meta?.embeds ?? [];
|
||||
const stickers = meta?.stickers ?? [];
|
||||
const imageAttachments = attachments.filter((a) =>
|
||||
isImageType(a.contentType),
|
||||
);
|
||||
const fileAttachments = attachments.filter(
|
||||
(a) => !isImageType(a.contentType),
|
||||
);
|
||||
|
||||
// First embed image or sticker url for visual preview
|
||||
const embedImage =
|
||||
embeds.find((e) => e.image?.url)?.image?.url ??
|
||||
embeds.find((e) => e.thumbnail?.url)?.thumbnail?.url ??
|
||||
null;
|
||||
const stickerUrl = stickers.find((s) => s.url)?.url ?? null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(m.id)}
|
||||
className={`animate-stagger flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
|
||||
className={`msg-feed-card flex w-full items-start gap-3 rounded-[8px] border p-2.5 text-left transition-all ${
|
||||
selected === m.id
|
||||
? "border-signal/40 bg-signal/8"
|
||||
: "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
|
||||
? "border-signal/50 bg-signal/10 shadow-xs"
|
||||
: "border-hairline bg-surface-2 hover:border-hairline-focus hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
<Avatar src={m.avatar_url} name={m.username} size={34} />
|
||||
<Avatar
|
||||
src={m.avatar_url}
|
||||
name={m.server_nick ?? m.username}
|
||||
size={32}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold text-ink">
|
||||
{m.username}
|
||||
<span className="truncate text-xs font-semibold text-ink">
|
||||
{m.server_nick ?? m.username}
|
||||
</span>
|
||||
<span className="mono text-[0.65rem] text-ink-faint">
|
||||
{m.server_nick && m.server_nick !== m.username && (
|
||||
<span className="truncate font-mono text-[10px] text-ink-muted">
|
||||
@{m.username}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-mono text-[10px] text-ink-muted">
|
||||
{getMessageChannelLabel(m)}
|
||||
</span>
|
||||
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
||||
<span className="ml-auto font-mono text-[10px] text-ink-muted">
|
||||
{formatRelativeTime(m.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-sm text-ink-soft">
|
||||
{renderMessageContent(m.content, m.metadata) || (
|
||||
<span className="italic text-ink-faint">(empty / embed)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Text content */}
|
||||
{m.content && (
|
||||
<div className="mt-0.5 line-clamp-2 text-xs text-ink-soft">
|
||||
{renderMessageContent(m.content, m.metadata)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline image attachments */}
|
||||
{imageAttachments.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{imageAttachments.map((a) => (
|
||||
<a
|
||||
key={a.url}
|
||||
href={a.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="block overflow-hidden rounded-[6px] border border-hairline"
|
||||
>
|
||||
<img
|
||||
src={a.url}
|
||||
alt={a.name}
|
||||
loading="lazy"
|
||||
className="max-h-32 w-auto max-w-[200px] object-cover"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sticker image */}
|
||||
{stickerUrl && imageAttachments.length === 0 && (
|
||||
<div className="mt-1.5">
|
||||
<a
|
||||
href={stickerUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="block overflow-hidden rounded-[6px] border border-hairline"
|
||||
>
|
||||
<img
|
||||
src={stickerUrl}
|
||||
alt={stickers[0]?.name ?? "sticker"}
|
||||
loading="lazy"
|
||||
className="max-h-28 w-auto max-w-[160px] object-contain"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Embed image / thumbnail */}
|
||||
{embedImage && imageAttachments.length === 0 && !stickerUrl && (
|
||||
<div className="mt-1.5">
|
||||
<a
|
||||
href={embedImage}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="block overflow-hidden rounded-[6px] border border-hairline"
|
||||
>
|
||||
<img
|
||||
src={embedImage}
|
||||
alt="embed"
|
||||
loading="lazy"
|
||||
className="max-h-32 w-auto max-w-[240px] object-cover"
|
||||
/>
|
||||
</a>
|
||||
{embeds[0]?.title && (
|
||||
<div className="mt-0.5 truncate text-[10px] font-medium text-ink-muted">
|
||||
{embeds[0].title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Non-image file attachments */}
|
||||
{fileAttachments.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{fileAttachments.map((a) => (
|
||||
<a
|
||||
key={a.url}
|
||||
href={a.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1 rounded-[4px] border border-hairline bg-surface px-2 py-0.5 font-mono text-[10px] text-ink-muted transition-colors hover:bg-surface-2 hover:text-ink"
|
||||
>
|
||||
<Paperclip className="size-2.5" />
|
||||
{a.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No content at all */}
|
||||
{!m.content && attachments.length === 0 && embeds.length === 0 && (
|
||||
<div className="mt-0.5 text-xs italic text-ink-muted">
|
||||
(empty / embed)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AiBadge status={m.ai_status} durationMs={m.ai_analysis_duration_ms} />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PageTransition } from "@/components/shared";
|
||||
import { getModerationActions, getModerationStats } from "@/lib/api/server";
|
||||
import { ModerationView } from "./view";
|
||||
|
||||
@@ -15,9 +14,5 @@ export default async function ModerationPage() {
|
||||
} catch {
|
||||
/* client hooks surface errors */
|
||||
}
|
||||
return (
|
||||
<PageTransition>
|
||||
<ModerationView initialStats={stats} initialActions={actions} />
|
||||
</PageTransition>
|
||||
);
|
||||
return <ModerationView initialStats={stats} initialActions={actions} />;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user