Compare commits
88
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7b0f02620 | ||
|
|
120aa95818 | ||
|
|
870f7546fb | ||
|
|
565b3da086 | ||
|
|
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 |
No files matched your search
+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_ANALYSIS_ENABLED=false # Enable AI content moderation (default: false)
|
||||||
AI_LLM_API_KEY= # REQUIRED if AI_ANALYSIS_ENABLED=true. LLM API key
|
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_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_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_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)
|
# 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",
|
"drizzle-orm": "^0.45.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"ioredis": "^5.11.0",
|
"ioredis": "^6.0.0",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.23.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^10.3.1",
|
||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3",
|
||||||
"ws": "^8.20.1",
|
"ws": "^8.21.3",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "latest",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^25.9.0",
|
"@types/node": "^26.2.0",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.23.1",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"tsx": "^4.22.2",
|
"tsx": "^4.23.12",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^7.0.2",
|
||||||
"vitest": "latest"
|
"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 db = getDatabase();
|
||||||
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
const result = await db.execute(sql`
|
const result = await db.execute(sql`
|
||||||
SELECT channel_id,
|
SELECT
|
||||||
EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour,
|
m.channel_id,
|
||||||
COUNT(*)::int AS c
|
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
|
||||||
FROM messages
|
EXTRACT(HOUR FROM to_timestamp(m.created_at / 1000))::int AS hour,
|
||||||
WHERE created_at >= ${since}
|
COUNT(*)::int AS c
|
||||||
GROUP BY channel_id, hour
|
FROM messages m
|
||||||
ORDER BY channel_id, hour
|
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>[]) || [];
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
channelId: String(r.channel_id ?? "unknown"),
|
channelId: String(r.channel_id ?? "unknown"),
|
||||||
|
channelName: String(r.channel_name ?? r.channel_id ?? "unknown"),
|
||||||
hour: Number(r.hour ?? 0),
|
hour: Number(r.hour ?? 0),
|
||||||
count: Number(r.c ?? 0),
|
count: Number(r.c ?? 0),
|
||||||
}));
|
}));
|
||||||
@@ -491,31 +494,28 @@ export class MessagesRepository {
|
|||||||
*/
|
*/
|
||||||
async getRecentEdits(limit = 50, channelId?: string) {
|
async getRecentEdits(limit = 50, channelId?: string) {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const where = channelId
|
const result = await db.execute(sql`
|
||||||
? `WHERE m.channel_id = '${channelId.replace(/'/g, "''")}'`
|
SELECT
|
||||||
: "";
|
e.id,
|
||||||
const result = await db.execute(
|
e.message_id,
|
||||||
sql.raw(`
|
e.old_content,
|
||||||
SELECT
|
e.edited_at,
|
||||||
e.id,
|
m.channel_id,
|
||||||
e.message_id,
|
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
|
||||||
e.old_content,
|
m.username,
|
||||||
e.edited_at,
|
COALESCE(m.edited_content, m.content) AS new_content
|
||||||
m.channel_id,
|
FROM message_edits e
|
||||||
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
|
JOIN messages m ON m.id = e.message_id
|
||||||
m.username
|
${channelId ? sql`WHERE m.channel_id = ${channelId}` : sql``}
|
||||||
FROM message_edits e
|
ORDER BY e.edited_at DESC
|
||||||
JOIN messages m ON m.id = e.message_id
|
LIMIT ${limit}
|
||||||
${where}
|
`);
|
||||||
ORDER BY e.edited_at DESC
|
|
||||||
LIMIT ${limit}
|
|
||||||
`),
|
|
||||||
);
|
|
||||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
id: String(r.id),
|
id: String(r.id),
|
||||||
message_id: String(r.message_id),
|
message_id: String(r.message_id),
|
||||||
old_content: r.old_content ? String(r.old_content) : "",
|
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,
|
edited_at: r.edited_at ? Number(r.edited_at) : 0,
|
||||||
channel_id: r.channel_id ? String(r.channel_id) : null,
|
channel_id: r.channel_id ? String(r.channel_id) : null,
|
||||||
channel_name: r.channel_name ? String(r.channel_name) : 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");
|
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) {
|
if (!message) {
|
||||||
throw new NotFoundError(`Message with ID ${id} not found`);
|
throw new NotFoundError(`Message with ID ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const editHistory = await messagesRepository.getEditHistory(id);
|
|
||||||
return {
|
return {
|
||||||
...message,
|
...message,
|
||||||
edit_count: editHistory.length,
|
edit_count: editHistory.length,
|
||||||
|
|||||||
@@ -124,7 +124,8 @@ export class ModerationRepository {
|
|||||||
a.score,
|
a.score,
|
||||||
a.evidence,
|
a.evidence,
|
||||||
a.policy_version,
|
a.policy_version,
|
||||||
m.username,
|
a.username,
|
||||||
|
a.server_nick,
|
||||||
LEFT(m.content, 300) AS content
|
LEFT(m.content, 300) AS content
|
||||||
FROM moderation_actions a
|
FROM moderation_actions a
|
||||||
LEFT JOIN messages m ON m.id = a.message_id
|
LEFT JOIN messages m ON m.id = a.message_id
|
||||||
@@ -155,6 +156,7 @@ export class ModerationRepository {
|
|||||||
evidence: parseJsonArray(r.evidence),
|
evidence: parseJsonArray(r.evidence),
|
||||||
policy_version: r.policy_version ? String(r.policy_version) : null,
|
policy_version: r.policy_version ? String(r.policy_version) : null,
|
||||||
username: r.username ? String(r.username) : 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,
|
content: r.content ? String(r.content) : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -314,7 +316,7 @@ export class ModerationRepository {
|
|||||||
SELECT
|
SELECT
|
||||||
a.id, a.message_id, a.user_id, a.guild_id, a.action_type,
|
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,
|
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
|
FROM moderation_actions a
|
||||||
LEFT JOIN messages m ON m.id = a.message_id
|
LEFT JOIN messages m ON m.id = a.message_id
|
||||||
WHERE a.created_at >= ${since}
|
WHERE a.created_at >= ${since}
|
||||||
@@ -338,6 +340,7 @@ export class ModerationRepository {
|
|||||||
confidence: r.confidence != null ? Number(r.confidence) : null,
|
confidence: r.confidence != null ? Number(r.confidence) : null,
|
||||||
score: r.score != null ? Number(r.score) : null,
|
score: r.score != null ? Number(r.score) : null,
|
||||||
username: r.username ? String(r.username) : 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,
|
content: r.content ? String(r.content) : null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,14 +23,26 @@ const speakers = new Map<string, LiveSpeaker>();
|
|||||||
const MAX_SPEAKERS = 200;
|
const MAX_SPEAKERS = 200;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record a voice_active_user event. `speaking: true` upserts the speaker as
|
* Speakers inactive for longer than this are auto-expired from the snapshot.
|
||||||
* active; `speaking: false` marks them inactive while keeping them for the
|
* This handles the case where the gateway disconnects abruptly and never
|
||||||
* activity timeline.
|
* 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`
|
* Record a voice_active_user event. `speaking: true` upserts the speaker as
|
||||||
* upserts the speaker as ACTIVE; `speaking: false` marks them inactive while
|
* active; `speaking: false` marks them inactive while keeping them briefly
|
||||||
* keeping them for the activity timeline.
|
* for the activity timeline (until TTL expiry).
|
||||||
*/
|
*/
|
||||||
export function recordSpeaker(data: {
|
export function recordSpeaker(data: {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -49,7 +61,6 @@ export function recordSpeaker(data: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (speakers.size >= MAX_SPEAKERS && !existing) {
|
if (speakers.size >= MAX_SPEAKERS && !existing) {
|
||||||
// Drop the least-recently-active non-speaking speaker to stay bounded.
|
|
||||||
let oldestId: string | null = null;
|
let oldestId: string | null = null;
|
||||||
let oldestTs = Infinity;
|
let oldestTs = Infinity;
|
||||||
for (const [id, s] of speakers) {
|
for (const [id, s] of speakers) {
|
||||||
@@ -65,18 +76,35 @@ export function recordSpeaker(data: {
|
|||||||
speakers.set(userId, speaker);
|
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[] {
|
export function getActiveSpeakers(): LiveSpeaker[] {
|
||||||
|
purgeStale();
|
||||||
return [...speakers.values()].sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
return [...speakers.values()].sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only speakers currently flagged as speaking. */
|
/** Only speakers currently flagged as speaking. */
|
||||||
export function getSpeakingSpeakers(): LiveSpeaker[] {
|
export function getSpeakingSpeakers(): LiveSpeaker[] {
|
||||||
|
purgeStale();
|
||||||
return [...speakers.values()]
|
return [...speakers.values()]
|
||||||
.filter((s) => s.speaking)
|
.filter((s) => s.speaking)
|
||||||
.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
.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). */
|
/** Drop all tracked speakers (used on backend restart). */
|
||||||
export function resetLiveSpeakers(): void {
|
export function resetLiveSpeakers(): void {
|
||||||
speakers.clear();
|
speakers.clear();
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
// Shared message row mapper for backend repository modules
|
// 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 {
|
export interface MappedMessage {
|
||||||
id: string;
|
id: string;
|
||||||
guild_id: string;
|
guild_id: string;
|
||||||
@@ -7,6 +24,8 @@ export interface MappedMessage {
|
|||||||
thread_id: string | null;
|
thread_id: string | null;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
username: 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;
|
avatar_url: string | null;
|
||||||
content: string;
|
content: string;
|
||||||
edited_content: string | null;
|
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,
|
thread_id: (row.thread_id as string | null) ?? null,
|
||||||
user_id: String(row.user_id ?? ""),
|
user_id: String(row.user_id ?? ""),
|
||||||
username: String(row.username ?? ""),
|
username: String(row.username ?? ""),
|
||||||
|
server_nick: resolveServerNick(row),
|
||||||
avatar_url: (row.avatar_url as string | null) ?? null,
|
avatar_url: (row.avatar_url as string | null) ?? null,
|
||||||
content: String(row.content ?? ""),
|
content: String(row.content ?? ""),
|
||||||
edited_content: (row.edited_content as string | null) ?? null,
|
edited_content: (row.edited_content as string | null) ?? null,
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import Redis from "ioredis";
|
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 { config } from "../shared/config/index.js";
|
||||||
import {
|
import {
|
||||||
DISCORD_CHANNEL_TO_WS_EVENT,
|
DISCORD_CHANNEL_TO_WS_EVENT,
|
||||||
DISCORD_VOICE_ACTIVE_USER,
|
DISCORD_VOICE_ACTIVE_USER,
|
||||||
DISCORD_VOICE_PCM,
|
DISCORD_VOICE_PCM,
|
||||||
|
DISCORD_VOICE_STOPPED,
|
||||||
} from "../shared/index.js";
|
} from "../shared/index.js";
|
||||||
import { createChildLogger } from "../shared/logger/index.js";
|
import { createChildLogger } from "../shared/logger/index.js";
|
||||||
import { broadcastBinary, broadcastEvent } from "./broadcast.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");
|
logger.debug({ channel, eventType }, "Broadcasting Redis event");
|
||||||
broadcastEvent(eventType, data);
|
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,
|
"when": 1787185000000,
|
||||||
"tag": "0016_drop_user_reputations",
|
"tag": "0016_drop_user_reputations",
|
||||||
"breakpoints": true
|
"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",
|
"discord.js-selfbot-v13": "^3.7.1",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"ioredis": "^5.11.0",
|
"ioredis": "^6.0.0",
|
||||||
"libsodium-wrappers": "^0.8.4",
|
"libsodium-wrappers": "^0.8.4",
|
||||||
"lru-cache": "^11.5.1",
|
"lru-cache": "^11.5.1",
|
||||||
"openai": "^6.38.0",
|
"openai": "^7.5.0",
|
||||||
"opusscript": "^0.0.8",
|
"opusscript": "^0.1.1",
|
||||||
"p-limit": "^7.3.0",
|
"p-limit": "^7.3.0",
|
||||||
"p-retry": "^8.0.0",
|
"p-retry": "^8.0.0",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.23.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^10.3.1",
|
||||||
"piscina": "^5.1.4",
|
"piscina": "^5.3.1",
|
||||||
"prism-media": "2.0.0-alpha.0",
|
"prism-media": "2.0.0-alpha.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.35.3",
|
||||||
"tiktoken": "^1.0.22",
|
"tiktoken": "^1.0.22",
|
||||||
"ws": "^8.20.1",
|
"ws": "^8.21.3",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "latest",
|
||||||
"@types/node": "^25.9.0",
|
"@types/node": "^26.2.0",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.23.1",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"tsx": "^4.22.2",
|
"tsx": "^4.23.12",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^7.0.2",
|
||||||
"vitest": "latest"
|
"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) => {
|
// Only revert stuck processing messages if there's active processing.
|
||||||
logger.error(
|
// Avoids a DB query every recovery interval when the pipeline is idle.
|
||||||
{ error: String(err) },
|
if (conversationProcessing.size > 0) {
|
||||||
"Failed to run stuck processing recovery",
|
messageStore
|
||||||
);
|
.revertStuckProcessingMessages(300000)
|
||||||
});
|
.catch((err: unknown) => {
|
||||||
|
logger.error(
|
||||||
|
{ error: String(err) },
|
||||||
|
"Failed to run stuck processing recovery",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
messageStore.getPendingConversationKeys(500),
|
messageStore.getPendingConversationKeys(500),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function deriveRecommendedAction(msg: MessageRecord): string {
|
|||||||
const severity = deriveSeverity(msg);
|
const severity = deriveSeverity(msg);
|
||||||
if (
|
if (
|
||||||
msg.ai_status === "flagged" &&
|
msg.ai_status === "flagged" &&
|
||||||
(severity === "critical" || severity === "high")
|
(severity === "critical" || severity === "high" || severity === "medium")
|
||||||
)
|
)
|
||||||
return "delete";
|
return "delete";
|
||||||
if (msg.ai_status === "flagged") return "review";
|
if (msg.ai_status === "flagged") return "review";
|
||||||
@@ -135,10 +135,14 @@ export function isEligibleForAutoDelete(
|
|||||||
// Recommended action check
|
// Recommended action check
|
||||||
const recommendedAction =
|
const recommendedAction =
|
||||||
analysisResult?.recommendedAction ?? deriveRecommendedAction(message);
|
analysisResult?.recommendedAction ?? deriveRecommendedAction(message);
|
||||||
if (recommendedAction !== "delete" && recommendedAction !== "escalate") {
|
if (
|
||||||
|
recommendedAction !== "delete" &&
|
||||||
|
recommendedAction !== "escalate" &&
|
||||||
|
recommendedAction !== "warn"
|
||||||
|
) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
{ messageId: message.id, recommendedAction },
|
{ 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Client, PermissionString } from "discord.js-selfbot-v13";
|
|||||||
import { LRUCache } from "lru-cache";
|
import { LRUCache } from "lru-cache";
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { config } from "../../shared/config/config.js";
|
import { config } from "../../shared/config/config.js";
|
||||||
|
import { parseRichMessageMetadata } from "../message-capture/messageMetadata.js";
|
||||||
import { messageStore } from "../message-capture/messageStore.js";
|
import { messageStore } from "../message-capture/messageStore.js";
|
||||||
import type { MessageRecord } from "../message-capture/types.js";
|
import type { MessageRecord } from "../message-capture/types.js";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
} from "./autoDeleteEligibility.js";
|
} from "./autoDeleteEligibility.js";
|
||||||
import { logDeletionToChannel } from "./autoDeleteLogger.js";
|
import { logDeletionToChannel } from "./autoDeleteLogger.js";
|
||||||
import { sendDeletionNotification } from "./autoDeleteNotify.js";
|
import { sendDeletionNotification } from "./autoDeleteNotify.js";
|
||||||
|
import { llmChat } from "./llmClient.js";
|
||||||
import { verdictToActionFields } from "./verdictToActionFields.js";
|
import { verdictToActionFields } from "./verdictToActionFields.js";
|
||||||
|
|
||||||
const logger = createChildLogger("auto-delete-manager");
|
const logger = createChildLogger("auto-delete-manager");
|
||||||
@@ -94,6 +96,26 @@ export async function resetOffensiveNickname(
|
|||||||
}
|
}
|
||||||
// setNickname(null) = remove nickname → Discord shows global username
|
// setNickname(null) = remove nickname → Discord shows global username
|
||||||
await member.setNickname(null, "[auto] nickname melanggar aturan server");
|
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());
|
recentNicknameResets.set(cooldownKey, Date.now());
|
||||||
logger.info(
|
logger.info(
|
||||||
{ messageId, guildId, userId },
|
{ 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 ────────────────────────────────────────
|
// ─── Error Handling Utilities ────────────────────────────────────────
|
||||||
|
|
||||||
function getErrorCode(error: unknown): number | string | undefined {
|
function getErrorCode(error: unknown): number | string | undefined {
|
||||||
@@ -130,7 +215,28 @@ function getErrorCode(error: unknown): number | string | undefined {
|
|||||||
|
|
||||||
function isAlreadyDeletedError(error: unknown): boolean {
|
function isAlreadyDeletedError(error: unknown): boolean {
|
||||||
const code = getErrorCode(error);
|
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 {
|
function hasChannelMessagesApi(channel: unknown): channel is {
|
||||||
@@ -164,6 +270,21 @@ function hasPermissionApi(channel: unknown): channel is {
|
|||||||
|
|
||||||
// ─── Database Action Log ─────────────────────────────────────────────
|
// ─── 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(
|
async function logAutoDeleteAttempt(
|
||||||
message: MessageRecord,
|
message: MessageRecord,
|
||||||
result: AutoDeleteResult,
|
result: AutoDeleteResult,
|
||||||
@@ -176,6 +297,8 @@ async function logAutoDeleteAttempt(
|
|||||||
action_type: "delete_message",
|
action_type: "delete_message",
|
||||||
reason: result.reason,
|
reason: result.reason,
|
||||||
...verdictToActionFields(message),
|
...verdictToActionFields(message),
|
||||||
|
username: message.username,
|
||||||
|
server_nick: resolveServerNick(message),
|
||||||
executed_by: "auto-delete-manager",
|
executed_by: "auto-delete-manager",
|
||||||
status: result.deleted
|
status: result.deleted
|
||||||
? "executed"
|
? "executed"
|
||||||
@@ -241,6 +364,8 @@ export async function attemptAutoDeleteFlaggedMessage(
|
|||||||
reason:
|
reason:
|
||||||
"nickname melanggar aturan server (offensive_username); pesan dibiarkan",
|
"nickname melanggar aturan server (offensive_username); pesan dibiarkan",
|
||||||
...verdictToActionFields(message),
|
...verdictToActionFields(message),
|
||||||
|
username: message.username,
|
||||||
|
server_nick: resolveServerNick(message),
|
||||||
executed_by: "auto-delete-manager",
|
executed_by: "auto-delete-manager",
|
||||||
status: resetOk ? "executed" : "failed",
|
status: resetOk ? "executed" : "failed",
|
||||||
error: resetOk ? null : "nickname_reset_failed",
|
error: resetOk ? null : "nickname_reset_failed",
|
||||||
|
|||||||
@@ -163,20 +163,14 @@ export async function processBatch(
|
|||||||
messages,
|
messages,
|
||||||
})) as AnalysisWorkerResponse;
|
})) 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) {
|
for (const row of result.rows) {
|
||||||
let isApiFailure = false;
|
if (row.ai_status === "error") continue;
|
||||||
if (row.ai_status === "error") {
|
broadcastAnalysisCompleted(row);
|
||||||
try {
|
scheduleAutoDelete(row);
|
||||||
const flags = JSON.parse(row.ai_moderation_flags ?? "[]") as string[];
|
|
||||||
isApiFailure = flags.includes("analysis_api_failed");
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isApiFailure) {
|
|
||||||
broadcastAnalysisCompleted(row);
|
|
||||||
scheduleAutoDelete(row);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
@@ -233,6 +227,10 @@ export async function processBatch(
|
|||||||
// conversation cooldown instead of an immediate individual retry.
|
// conversation cooldown instead of an immediate individual retry.
|
||||||
apiFailedMessages.push(msg);
|
apiFailedMessages.push(msg);
|
||||||
break;
|
break;
|
||||||
|
case "completed":
|
||||||
|
// Successfully analyzed — already broadcast + auto-delete scheduled
|
||||||
|
// above. Do NOT re-enqueue for individual fallback.
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
// incomplete / parse_failed / unexplained drops stay retryable via
|
// incomplete / parse_failed / unexplained drops stay retryable via
|
||||||
// the individual fallback queue (same semantics as before).
|
// 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}`;
|
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,
|
/** Max content chars per context line — a single huge paste (log dump,
|
||||||
* copypasta) must not eat the whole conversation budget. */
|
* copypasta) must not eat the whole conversation budget. */
|
||||||
const CONTEXT_LINE_CONTENT_MAX_CHARS = 1500;
|
const CONTEXT_LINE_CONTENT_MAX_CHARS = 1500;
|
||||||
@@ -264,10 +270,7 @@ export function buildConversationContext(
|
|||||||
const targetLines = targets.map((msg) =>
|
const targetLines = targets.map((msg) =>
|
||||||
formatMessageForPrompt(msg, "target"),
|
formatMessageForPrompt(msg, "target"),
|
||||||
);
|
);
|
||||||
let usedTokens = targetLines.reduce(
|
let usedTokens = estimateTokensBatch(targetLines);
|
||||||
(sum, line) => sum + estimateTokens(line),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
const contextLines = gatedNewestFirst.map((msg) =>
|
const contextLines = gatedNewestFirst.map((msg) =>
|
||||||
formatMessageForPrompt(msg, "context"),
|
formatMessageForPrompt(msg, "context"),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { delay, retryWithBackoff } from "@/shared/utils/index";
|
|||||||
import { config } from "../../shared/config/config.js";
|
import { config } from "../../shared/config/config.js";
|
||||||
import type { AnalysisResult } from "../message-capture/types.js";
|
import type { AnalysisResult } from "../message-capture/types.js";
|
||||||
import { llmChat } from "./llmClient.js";
|
import { llmChat } from "./llmClient.js";
|
||||||
|
import { parseModerationResponse } from "./moderationResponseParser.js";
|
||||||
import { logModerationError } from "./responseLogger.js";
|
import { logModerationError } from "./responseLogger.js";
|
||||||
|
|
||||||
const log = createChildLogger("llm-caller");
|
const log = createChildLogger("llm-caller");
|
||||||
@@ -79,7 +80,8 @@ export async function callModerationLLM(
|
|||||||
];
|
];
|
||||||
const completion = await llmChat({
|
const completion = await llmChat({
|
||||||
messages,
|
messages,
|
||||||
max_tokens: maxTokens ?? 16384,
|
max_tokens:
|
||||||
|
maxTokens ?? config.AI_LLM_MAX_COMPLETION_TOKENS ?? 16384,
|
||||||
jsonResponse: { type: "json_object" },
|
jsonResponse: { type: "json_object" },
|
||||||
retries: 0,
|
retries: 0,
|
||||||
signal,
|
signal,
|
||||||
@@ -106,9 +108,6 @@ export async function callModerationLLM(
|
|||||||
if (!rawContent) throw new Error("No content in LLM response");
|
if (!rawContent) throw new Error("No content in LLM response");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { parseModerationResponse } = await import(
|
|
||||||
"./moderationResponseParser.js"
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
parsed: parseModerationResponse(rawContent, targetIds),
|
parsed: parseModerationResponse(rawContent, targetIds),
|
||||||
result: completion,
|
result: completion,
|
||||||
|
|||||||
@@ -461,7 +461,9 @@ export async function runModerationAnalysis(
|
|||||||
cacheKey,
|
cacheKey,
|
||||||
stored,
|
stored,
|
||||||
embeddingsByKey.get(cacheKey),
|
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
|
// Dual-key write-back (2026-08-24): the FIRST analysis of a message runs
|
||||||
// WITH conversation context (accurate), but its verdict is also stored
|
// 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
|
// Text-only batch
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -88,7 +108,22 @@ export async function runTextOnlyBatch(
|
|||||||
for (const url of extractUrlsFromText(msg.edited_content ?? msg.content))
|
for (const url of extractUrlsFromText(msg.edited_content ?? msg.content))
|
||||||
allUrls.add(url);
|
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) {
|
if (urlArr.length === 0) {
|
||||||
return {
|
return {
|
||||||
text: new Map<string, string>(),
|
text: new Map<string, string>(),
|
||||||
@@ -192,7 +227,7 @@ export async function runTextOnlyBatch(
|
|||||||
// Corrected false-positive examples are static per batch — fetch ONCE
|
// Corrected false-positive examples are static per batch — fetch ONCE
|
||||||
// here instead of inside the per-sub-batch retry closure (which would
|
// 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).
|
// 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
|
// ── URL images → multimodal vision evidence (hoisted out of the sub-batch
|
||||||
// loop) ───────────────────────────────────────────────────────────
|
// loop) ───────────────────────────────────────────────────────────
|
||||||
@@ -368,9 +403,13 @@ export async function runTextOnlyBatch(
|
|||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.name === "AbortError" || abortController.signal.aborted) {
|
if (err.name === "AbortError" || abortController.signal.aborted) {
|
||||||
throw new Error(
|
// Sub-batch timed out — log but DO NOT throw. Previous sub-batches'
|
||||||
`Text-only batch sub-batch ${i + 1} timed out for messages ${targetIds.join(", ")}`,
|
// 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;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ export class ModerationHandler {
|
|||||||
| "kick_user"
|
| "kick_user"
|
||||||
| "ban_user",
|
| "ban_user",
|
||||||
reason: payload.reason ?? null,
|
reason: payload.reason ?? null,
|
||||||
|
username: null,
|
||||||
|
server_nick: null,
|
||||||
executed_by: payload.executed_by ?? "command-handler",
|
executed_by: payload.executed_by ?? "command-handler",
|
||||||
status: "executed",
|
status: "executed",
|
||||||
error: null,
|
error: null,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Client } from "discord.js-selfbot-v13";
|
import type { Client } from "discord.js-selfbot-v13";
|
||||||
|
import Redis from "ioredis";
|
||||||
import { config } from "../../shared/config/config.js";
|
import { config } from "../../shared/config/config.js";
|
||||||
import type { CommandMessage, CommandReply } from "../../shared/index.js";
|
import type { CommandMessage, CommandReply } from "../../shared/index.js";
|
||||||
import { createChildLogger } from "../../shared/logger/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 {
|
try {
|
||||||
// IMPORTANT: transmitter needs its OWN Redis client because it calls
|
// IMPORTANT: transmitter needs its OWN Redis client because it calls
|
||||||
// .subscribe() which converts the connection to subscriber mode. Reusing
|
// .subscribe() which converts the connection to subscriber mode. Reusing
|
||||||
// the publish connection from CommandHandler would corrupt it and break
|
// the publish connection from CommandHandler would corrupt it and break
|
||||||
// every command reply + status update.
|
// every command reply + status update.
|
||||||
const { default: IORedis } = await import("ioredis");
|
const transmitRedis = new Redis(config.REDIS_URL);
|
||||||
const transmitRedis = new IORedis(config.REDIS_URL);
|
|
||||||
await voiceTransmitter.start(transmitRedis);
|
await voiceTransmitter.start(transmitRedis);
|
||||||
|
|
||||||
const status = voiceTransmitter.getStatus();
|
const status = voiceTransmitter.getStatus();
|
||||||
|
|||||||
@@ -50,6 +50,21 @@ export function finalizeSegment(input: SegmentFinalizerInput): void {
|
|||||||
} = input;
|
} = input;
|
||||||
|
|
||||||
const endTime = currentSegment.endTime ?? Date.now();
|
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) {
|
if (config.VERBOSE) {
|
||||||
logger.info({ filename: currentSegment.filename }, "Segment saved");
|
logger.info({ filename: currentSegment.filename }, "Segment saved");
|
||||||
|
|||||||
@@ -23,10 +23,18 @@ export class VoiceTransmitter {
|
|||||||
private readonly TRANSMIT_CHANNEL = BACKEND_VOICE_TRANSMIT;
|
private readonly TRANSMIT_CHANNEL = BACKEND_VOICE_TRANSMIT;
|
||||||
/** Queue for PCM chunks when backpressure is active */
|
/** Queue for PCM chunks when backpressure is active */
|
||||||
private backpressureQueue: Buffer[] = [];
|
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 */
|
/** Serialise start/stop to prevent races between rapid toggle commands */
|
||||||
private gate = Promise.resolve();
|
private gate = Promise.resolve();
|
||||||
/** Set true before sending SIGTERM so exit handler knows it's intentional */
|
/** Set true before sending SIGTERM so exit handler knows it's intentional */
|
||||||
private _expectedExit = false;
|
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
|
* Start listening for PCM audio data from Redis and stream to Discord
|
||||||
@@ -48,7 +56,6 @@ export class VoiceTransmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.redisSub = redis;
|
|
||||||
this.isActive = true;
|
this.isActive = true;
|
||||||
|
|
||||||
// Create PCM input stream
|
// Create PCM input stream
|
||||||
@@ -115,10 +122,14 @@ export class VoiceTransmitter {
|
|||||||
this.pcmStream.pipe(this.ffmpegProcess.stdin);
|
this.pcmStream.pipe(this.ffmpegProcess.stdin);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log FFmpeg stderr for debugging
|
// Log FFmpeg stderr for debugging (cap to prevent unbounded growth)
|
||||||
const stderrChunks: Buffer[] = [];
|
const stderrChunks: Buffer[] = [];
|
||||||
|
let stderrLen = 0;
|
||||||
this.ffmpegProcess.stderr?.on("data", (chunk: Buffer) => {
|
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) => {
|
this.ffmpegProcess.on("error", (err) => {
|
||||||
@@ -135,8 +146,11 @@ export class VoiceTransmitter {
|
|||||||
const stderr = Buffer.concat(stderrChunks).toString();
|
const stderr = Buffer.concat(stderrChunks).toString();
|
||||||
logger.error(
|
logger.error(
|
||||||
{ code, stderr: stderr.slice(-500) },
|
{ 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 {
|
} else {
|
||||||
logger.debug({ code }, "FFmpeg process exited");
|
logger.debug({ code }, "FFmpeg process exited");
|
||||||
}
|
}
|
||||||
@@ -155,11 +169,15 @@ export class VoiceTransmitter {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Subscribe to Redis channel for PCM data
|
// 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(
|
logger.info(
|
||||||
{ channel: this.TRANSMIT_CHANNEL },
|
{ channel: this.TRANSMIT_CHANNEL },
|
||||||
"Subscribed to 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) => {
|
this.redisSub.on("message", (channel, message) => {
|
||||||
if (
|
if (
|
||||||
@@ -175,8 +193,21 @@ export class VoiceTransmitter {
|
|||||||
const pcmBuffer = Buffer.from(data.buffer, "base64");
|
const pcmBuffer = Buffer.from(data.buffer, "base64");
|
||||||
const stream = this.pcmStream;
|
const stream = this.pcmStream;
|
||||||
const canContinue = stream.write(pcmBuffer);
|
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 (!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", () => {
|
stream.once("drain", () => {
|
||||||
const currentStream = this.pcmStream;
|
const currentStream = this.pcmStream;
|
||||||
if (!currentStream || !this.isActive) return;
|
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
|
* Stop transmitting and clean up resources
|
||||||
*/
|
*/
|
||||||
@@ -232,6 +287,7 @@ export class VoiceTransmitter {
|
|||||||
|
|
||||||
this.isActive = false;
|
this.isActive = false;
|
||||||
|
|
||||||
|
this.clearActivityTimer();
|
||||||
this.backpressureQueue = [];
|
this.backpressureQueue = [];
|
||||||
|
|
||||||
if (this.pcmStream) {
|
if (this.pcmStream) {
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export const configSchema = z
|
|||||||
.string()
|
.string()
|
||||||
.url()
|
.url()
|
||||||
.default("https://9router.asepharyana.my.id/v1"),
|
.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
|
// Vision uses the SAME router/base URL as text moderation
|
||||||
// (AI_LLM_BASE_URL) but a different model alias. The dedicated NVIDIA
|
// (AI_LLM_BASE_URL) but a different model alias. The dedicated NVIDIA
|
||||||
// multimodal endpoint was removed.
|
// multimodal endpoint was removed.
|
||||||
@@ -197,6 +197,11 @@ export const configSchema = z
|
|||||||
.positive()
|
.positive()
|
||||||
.default(1024),
|
.default(1024),
|
||||||
AI_LLM_TEXT_BATCH_SIZE: z.coerce.number().int().positive().default(60),
|
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
|
AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS: z.coerce
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
|
|||||||
@@ -56,25 +56,56 @@ async function getFirstMigrationTag(): Promise<string> {
|
|||||||
return first.tag;
|
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
|
* 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
|
* that were created manually or by an earlier migration system (e.g., the old
|
||||||
* checkSchemaExists short-circuit). Without this, Drizzle attempts to re-create
|
* checkSchemaExists short-circuit). Without this, Drizzle attempts to re-create
|
||||||
* all tables from 0000 and fails with "relation already exists".
|
* 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> {
|
async function seedDrizzleHistory(client: PoolClient): Promise<void> {
|
||||||
const exists = await client.query(`
|
// Check whether the app tables pre-exist (old ./migrations/ SQL or manual
|
||||||
SELECT EXISTS (
|
// creation). If not, this is a brand-new database — let Drizzle handle it.
|
||||||
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).
|
|
||||||
const hasTextCache = await client.query(`
|
const hasTextCache = await client.query(`
|
||||||
SELECT EXISTS (
|
SELECT EXISTS (
|
||||||
SELECT FROM information_schema.columns
|
SELECT FROM information_schema.columns
|
||||||
@@ -85,12 +116,19 @@ async function seedDrizzleHistory(client: PoolClient): Promise<void> {
|
|||||||
return; // brand-new database, let Drizzle handle everything
|
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(
|
const lastMigrationWhen = await getLastMigrationWhen();
|
||||||
{ firstMigrationTag },
|
|
||||||
"Seeding Drizzle migration history — marking first migration as already applied on this pre-existing database",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create the Drizzle tracking table if it does not exist.
|
// Create the Drizzle tracking table if it does not exist.
|
||||||
// PG15+ locks down CREATE on the public schema for non-owner roles,
|
// 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;
|
throw createErr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Drizzle's __drizzle_migrations table has no UNIQUE(hash)
|
|
||||||
// constraint, so check manually before inserting.
|
// Current max tracked created_at. Drizzle only re-applies migrations newer
|
||||||
const alreadySeeded = await client.query(
|
// than this, so if it already reaches the journal's last `when`, nothing to do.
|
||||||
`SELECT 1 FROM "__drizzle_migrations" WHERE hash = $1 LIMIT 1`,
|
const tracked = await client.query(`
|
||||||
[firstMigrationTag],
|
SELECT COALESCE(MAX(created_at), 0) AS max_created FROM "__drizzle_migrations"
|
||||||
);
|
`);
|
||||||
if (alreadySeeded.rows.length === 0) {
|
const trackedMax = Number(tracked.rows[0]?.max_created ?? 0);
|
||||||
await client.query(
|
|
||||||
`INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES ($1, $2)`,
|
if (trackedMax >= lastMigrationWhen) {
|
||||||
[firstMigrationTag, Date.now()],
|
return; // already fully tracked — Drizzle will apply only genuinely-pending ones
|
||||||
);
|
|
||||||
}
|
}
|
||||||
logger.info(
|
|
||||||
{ firstMigrationTag },
|
if (!schemaAtLatest) {
|
||||||
"Drizzle history seeded — first migration marked applied",
|
// 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
|
score: pgReal("score"), // 0..1 raw model score
|
||||||
evidence: pgText("evidence"), // JSON array of short quoted snippets
|
evidence: pgText("evidence"), // JSON array of short quoted snippets
|
||||||
policy_version: pgText("policy_version"), // rules.ts policy version string
|
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) => ({
|
(table) => ({
|
||||||
messageIdIdx: pgIndex("idx_moderation_actions_message_id").on(
|
messageIdIdx: pgIndex("idx_moderation_actions_message_id").on(
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export const pgModerationActionsTable = pgTable(
|
|||||||
score: pgReal("score"), // 0..1 raw model score
|
score: pgReal("score"), // 0..1 raw model score
|
||||||
evidence: pgText("evidence"), // JSON array of short quoted snippets
|
evidence: pgText("evidence"), // JSON array of short quoted snippets
|
||||||
policy_version: pgText("policy_version"), // rules.ts policy version string
|
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) => ({
|
(table) => ({
|
||||||
messageIdIdx: pgIndex("idx_moderation_actions_message_id").on(
|
messageIdIdx: pgIndex("idx_moderation_actions_message_id").on(
|
||||||
|
|||||||
@@ -208,6 +208,8 @@ export interface ModerationAction {
|
|||||||
guild_id: string;
|
guild_id: string;
|
||||||
action_type: ModerationActionType;
|
action_type: ModerationActionType;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
|
username: string | null;
|
||||||
|
server_nick: string | null;
|
||||||
executed_by: string | null;
|
executed_by: string | null;
|
||||||
status: "pending" | "executed" | "failed";
|
status: "pending" | "executed" | "failed";
|
||||||
error: string | null;
|
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": {
|
"dependencies": {
|
||||||
"@orpc/client": "1.15.0",
|
"@orpc/client": "1.15.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.27.0",
|
"lucide-react": "^1.34.0",
|
||||||
"motion": "^12.0.0",
|
"next": "16.3.2",
|
||||||
"next": "16.2.12",
|
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"partysocket": "1.3.0",
|
"partysocket": "1.3.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.8",
|
||||||
"swr": "^2.4.2",
|
"swr": "^2.5.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0"
|
||||||
"three": "^0.180.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.2.0",
|
"@biomejs/biome": "2.5.10",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^26.2.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/three": "^0.180.0",
|
"@types/three": "^0.185.4",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"puppeteer-core": "^25.7.0",
|
"puppeteer-core": "^25.8.0",
|
||||||
"tailwindcss": "^4",
|
"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";
|
import { AnalysisView } from "./view";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default function AnalysisPage() {
|
export default function AnalysisPage() {
|
||||||
return (
|
return <AnalysisView />;
|
||||||
<PageTransition>
|
|
||||||
<AnalysisView />
|
|
||||||
</PageTransition>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Hash, Search, Sparkles, TrendingUp } from "lucide-react";
|
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 { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives";
|
import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives";
|
||||||
import { EmptyState, SectionHeader, SkeletonRows } from "@/components/shared";
|
import { EmptyState, SectionHeader, SkeletonRows } from "@/components/shared";
|
||||||
import { useChannels, useMessageSearch, useTopReactors } from "@/hooks";
|
import { useChannels, useMessageSearch, useTopReactors } from "@/hooks";
|
||||||
|
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
|
||||||
import { aiTone } from "@/lib/ai-status";
|
import { aiTone } from "@/lib/ai-status";
|
||||||
import {
|
import {
|
||||||
formatDuration,
|
formatDuration,
|
||||||
@@ -20,6 +21,15 @@ export function AnalysisView() {
|
|||||||
const { data: channels } = useChannels();
|
const { data: channels } = useChannels();
|
||||||
const ambient = useAmbient();
|
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(() => {
|
useEffect(() => {
|
||||||
ambient.set(
|
ambient.set(
|
||||||
query ? "amber" : "signal",
|
query ? "amber" : "signal",
|
||||||
@@ -28,74 +38,184 @@ export function AnalysisView() {
|
|||||||
);
|
);
|
||||||
}, [query, ambient]);
|
}, [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 (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-4">
|
||||||
<GlassPanel glow className="relative overflow-hidden">
|
{/* Tactical HUD Header Bar */}
|
||||||
<div className="scan-line absolute inset-x-0 top-0" />
|
<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">
|
<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>
|
||||||
<div className="eyebrow">Semantic search</div>
|
<h2 className="text-base font-semibold text-ink">
|
||||||
<h2 className="display text-balance text-2xl text-ink">
|
Cross-Guild Archive Intelligence
|
||||||
Search the archive
|
|
||||||
</h2>
|
</h2>
|
||||||
|
<p className="font-mono text-[11px] text-ink-muted">
|
||||||
|
Query vectorized messages, heuristic flags, and user behavior
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative mt-4">
|
<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
|
<Input
|
||||||
className="h-12 pl-12 text-base"
|
className="h-10 pl-10 text-xs text-ink"
|
||||||
placeholder="Find messages, patterns, flags…"
|
placeholder="Search keywords, behavioral patterns, infraction terms..."
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</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">
|
<GlassPanel className="lg:col-span-3">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
eyebrow="results"
|
eyebrow="search output"
|
||||||
title="Matches"
|
title="Matched Log Packets"
|
||||||
action={
|
action={
|
||||||
<span className="mono text-xs text-ink-faint">
|
<span className="mono text-xs text-[#8a8f98]">
|
||||||
{(search.data ?? []).length}
|
{(search.data ?? []).length} results
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{query.trim().length >= 2 && search.isLoading && (
|
{query.trim().length >= 2 && search.isLoading && (
|
||||||
<SkeletonRows rows={6} />
|
<SkeletonRows rows={6} />
|
||||||
)}
|
)}
|
||||||
{(search.data ?? []).length === 0 ? (
|
{(search.data ?? []).length === 0 && !search.isLoading ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<Search className="size-7" />}
|
icon={<Search className="size-7" />}
|
||||||
title="No matches yet"
|
title="No packets matched"
|
||||||
description="Run a search to surface messages across the guild."
|
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) => (
|
{(search.data ?? []).map((m) => (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
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="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-ink">
|
<span className="text-xs font-semibold text-ink">
|
||||||
{m.username}
|
{m.server_nick ?? m.username}
|
||||||
</span>
|
</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)}
|
{getMessageChannelLabel(m)}
|
||||||
</span>
|
</span>
|
||||||
{m.ai_status && (
|
{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 &&
|
||||||
m.ai_analysis_duration_ms > 0
|
m.ai_analysis_duration_ms > 0
|
||||||
? `${m.ai_status} · ${formatDuration(m.ai_analysis_duration_ms)}`
|
? `${m.ai_status} · ${formatDuration(m.ai_analysis_duration_ms)}`
|
||||||
@@ -103,7 +223,7 @@ export function AnalysisView() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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)"}
|
{renderMessageContent(m.content, m.metadata) || "(embed)"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,17 +233,18 @@ export function AnalysisView() {
|
|||||||
)}
|
)}
|
||||||
</GlassPanel>
|
</GlassPanel>
|
||||||
|
|
||||||
<div className="space-y-5 lg:col-span-2">
|
{/* Culture & Leaderboard Side Column */}
|
||||||
|
<div className="space-y-3 lg:col-span-2">
|
||||||
<GlassPanel>
|
<GlassPanel>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
eyebrow="culture"
|
eyebrow="engagement"
|
||||||
title={
|
title={
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-1.5">
|
||||||
<TrendingUp className="size-4 text-signal" /> Top reactors
|
<TrendingUp className="size-3.5 text-signal" /> Top Reactors
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-2">
|
<div ref={reactorsRef} className="space-y-2 mt-3">
|
||||||
{(reactors ?? []).slice(0, 6).map((r, i) => {
|
{(reactors ?? []).slice(0, 6).map((r, i) => {
|
||||||
const maxNet = reactors?.[0]?.net_count || 1;
|
const maxNet = reactors?.[0]?.net_count || 1;
|
||||||
const pct = Math.max(
|
const pct = Math.max(
|
||||||
@@ -133,27 +254,32 @@ export function AnalysisView() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={r.user_id}
|
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="font-mono w-4 text-[10px] text-ink-faint">
|
||||||
<span className="w-28 shrink-0 truncate text-ink-soft sm:w-40">
|
0{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="w-24 shrink-0 truncate font-mono text-xs text-ink sm:w-32">
|
||||||
{r.username}
|
{r.username}
|
||||||
</span>
|
</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
|
<div
|
||||||
className="h-full rounded-full bg-signal/70"
|
className="reactor-bar-fill h-full rounded-full bg-signal"
|
||||||
style={{ width: `${pct}%` }}
|
style={{ width: `${pct}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="mono w-12 text-right text-xs text-signal">
|
<span
|
||||||
+{r.net_count}
|
className="reactor-count font-mono w-10 text-right text-[11px] font-semibold text-signal"
|
||||||
|
data-target={r.net_count}
|
||||||
|
>
|
||||||
|
+0
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(reactors ?? []).length === 0 && (
|
{(reactors ?? []).length === 0 && (
|
||||||
<div className="py-4 text-center text-xs text-ink-faint">
|
<div className="py-4 text-center font-mono text-xs text-ink-faint">
|
||||||
No data
|
NO REACTOR DATA
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -161,30 +287,31 @@ export function AnalysisView() {
|
|||||||
|
|
||||||
<GlassPanel>
|
<GlassPanel>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
eyebrow="channels"
|
eyebrow="volume"
|
||||||
title={
|
title={
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-1.5">
|
||||||
<Hash className="size-4 text-signal" /> Top channels
|
<Hash className="size-3.5 text-signal" /> Top Active Channels
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-2">
|
<div ref={channelsRef} className="space-y-2 mt-3">
|
||||||
{(channels ?? []).slice(0, 6).map((c) => (
|
{(channels ?? []).slice(0, 6).map((c) => (
|
||||||
<div
|
<div
|
||||||
key={c.channel_id}
|
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">
|
<span className="flex items-center gap-1.5 truncate font-mono text-xs text-ink">
|
||||||
{c.channel_name ?? c.channel_id.slice(0, 8)}
|
<Hash className="size-3 text-signal" />
|
||||||
|
{c.channel_name ?? c.channel_id.slice(0, 10)}
|
||||||
</span>
|
</span>
|
||||||
<span className="mono text-xs text-ink-faint">
|
<span className="font-mono text-[10px] text-ink-muted">
|
||||||
{c.total_messages}
|
{c.total_messages.toLocaleString()} msgs
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{(channels ?? []).length === 0 && (
|
{(channels ?? []).length === 0 && (
|
||||||
<div className="py-4 text-center text-xs text-ink-faint">
|
<div className="py-4 text-center font-mono text-xs text-ink-faint">
|
||||||
No data
|
NO CHANNEL DATA
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { PageTransition } from "@/components/shared";
|
|
||||||
import { getChannelCultures } from "@/lib/api/server";
|
import { getChannelCultures } from "@/lib/api/server";
|
||||||
import type { ChannelCultureRow } from "@/lib/types";
|
import type { ChannelCultureRow } from "@/lib/types";
|
||||||
import { ChannelsView } from "./view";
|
import { ChannelsView } from "./view";
|
||||||
@@ -12,9 +11,5 @@ export default async function ChannelsPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
cultures = undefined;
|
cultures = undefined;
|
||||||
}
|
}
|
||||||
return (
|
return <ChannelsView initialCultures={cultures} />;
|
||||||
<PageTransition>
|
|
||||||
<ChannelsView initialCultures={cultures} />
|
|
||||||
</PageTransition>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,23 @@ export function ChannelsView({
|
|||||||
}) {
|
}) {
|
||||||
const { data: cultures } = useChannelCultures(100, initialCultures);
|
const { data: cultures } = useChannelCultures(100, initialCultures);
|
||||||
return (
|
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 ? (
|
{cultures ? (
|
||||||
<ChannelCultureGlossary cultures={cultures} />
|
<ChannelCultureGlossary cultures={cultures} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { PageTransition } from "@/components/shared";
|
|
||||||
import { getActivity, getDashboardStats } from "@/lib/api/server";
|
import { getActivity, getDashboardStats } from "@/lib/api/server";
|
||||||
import { DashboardView } from "./view";
|
import { DashboardView } from "./view";
|
||||||
|
|
||||||
@@ -15,9 +14,5 @@ export default async function DashboardPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
// Backend unavailable — client hooks will surface the error state.
|
// Backend unavailable — client hooks will surface the error state.
|
||||||
}
|
}
|
||||||
return (
|
return <DashboardView initialStats={stats} initialActivity={activity} />;
|
||||||
<PageTransition>
|
|
||||||
<DashboardView initialStats={stats} initialActivity={activity} />
|
|
||||||
</PageTransition>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,46 +1,48 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Activity,
|
AudioWaveform,
|
||||||
Flag,
|
ChevronRight,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Mic,
|
Mic,
|
||||||
Radio,
|
Shield,
|
||||||
ShieldAlert,
|
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import { AreaActivity, RadialGauge } from "@/components/charts";
|
import { AreaActivity, RadialGauge } from "@/components/charts";
|
||||||
import { GlassPanel } from "@/components/primitives";
|
import { GlassPanel } from "@/components/primitives";
|
||||||
import {
|
import {
|
||||||
ErrorState,
|
ErrorState,
|
||||||
LoadingState,
|
MetricTile,
|
||||||
|
PageTransition,
|
||||||
|
SectionHeader,
|
||||||
SkeletonHero,
|
SkeletonHero,
|
||||||
SkeletonMetricRow,
|
SkeletonMetricRow,
|
||||||
SkeletonPanel,
|
SkeletonPanel,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import { MetricTile, SectionHeader } from "@/components/shared/section";
|
import { useActivity, useStats, useTopReactions } from "@/hooks";
|
||||||
import {
|
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
|
||||||
useActivity,
|
|
||||||
useStats,
|
|
||||||
useTopReactions,
|
|
||||||
useTopReactors,
|
|
||||||
} from "@/hooks";
|
|
||||||
import { formatNumber } from "@/lib/format";
|
import { formatNumber } from "@/lib/format";
|
||||||
import type { DashboardStats } from "@/lib/types";
|
import type { DashboardStats } from "@/lib/types";
|
||||||
import { staggerDelay } from "@/lib/utils";
|
|
||||||
|
|
||||||
function deriveSignal(stats?: DashboardStats) {
|
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 total = stats.total_flagged + stats.total_clean || 1;
|
||||||
const ratio = stats.total_flagged / total;
|
const ratio = stats.total_flagged / total;
|
||||||
if (stats.moderation_overview.error > 0)
|
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)
|
if (ratio > 0.25)
|
||||||
return { tone: "vermilion" as const, label: "elevated flags" };
|
return {
|
||||||
if (ratio > 0.1) return { tone: "amber" as const, label: "watch" };
|
tone: "vermilion" as const,
|
||||||
return { tone: "signal" as const, label: "nominal" };
|
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({
|
export function DashboardView({
|
||||||
@@ -56,11 +58,16 @@ export function DashboardView({
|
|||||||
error,
|
error,
|
||||||
mutate: mutateStats,
|
mutate: mutateStats,
|
||||||
} = useStats(initialStats);
|
} = useStats(initialStats);
|
||||||
const { data: activity } = useActivity(14, initialActivity as never);
|
const { data: activity } = useActivity(14, initialActivity);
|
||||||
const { data: reactors } = useTopReactors();
|
|
||||||
const { data: reactions } = useTopReactions();
|
const { data: reactions } = useTopReactions();
|
||||||
const ambient = useAmbient();
|
const ambient = useAmbient();
|
||||||
|
|
||||||
|
const hudRef = useStaggerReveal<HTMLDivElement>(".linear-tile", {
|
||||||
|
stagger: 0.035,
|
||||||
|
y: 8,
|
||||||
|
dependencies: [stats],
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const s = deriveSignal(stats);
|
const s = deriveSignal(stats);
|
||||||
ambient.set(
|
ambient.set(
|
||||||
@@ -74,20 +81,16 @@ export function DashboardView({
|
|||||||
return <ErrorState error={error} onRetry={() => void mutateStats()} />;
|
return <ErrorState error={error} onRetry={() => void mutateStats()} />;
|
||||||
if (!stats && isLoading)
|
if (!stats && isLoading)
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-4">
|
||||||
<SkeletonHero />
|
<SkeletonHero />
|
||||||
<SkeletonMetricRow cols={4} />
|
<SkeletonMetricRow cols={4} />
|
||||||
<SkeletonPanel rows={5} />
|
<SkeletonPanel rows={4} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
if (!stats)
|
if (!stats)
|
||||||
return (
|
return (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
error={error ?? new Error("No data")}
|
error={error ?? new Error("No telemetry stream")}
|
||||||
onRetry={() => void mutateStats()}
|
onRetry={() => void mutateStats()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -97,284 +100,152 @@ export function DashboardView({
|
|||||||
const cleanRatio = s.total_clean / total;
|
const cleanRatio = s.total_clean / total;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<PageTransition>
|
||||||
{/* Hero */}
|
<div ref={hudRef} className="space-y-4">
|
||||||
<GlassPanel glow className="game-frame relative overflow-hidden">
|
{/* Tactical HUD Header Bar */}
|
||||||
<div className="scan-line absolute inset-x-0 top-0" />
|
<div className="linear-tile flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
<div className="flex items-center gap-2.5">
|
||||||
<div>
|
<span className="h-2 w-2 rounded-full bg-signal glow-pulse" />
|
||||||
<div className="eyebrow mb-2">GMW · Operations Grid</div>
|
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
|
||||||
<h2 className="display hero-clamp leading-none text-ink glow-signal">
|
Telemetry Overview · Node 01
|
||||||
Ambient Field
|
</h1>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-ink-soft">
|
<div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
|
||||||
<Radio className="size-4 text-signal animate-breathe" />
|
<span>SIGNAL:</span>
|
||||||
<span className="mono text-xs uppercase tracking-wider">
|
<span className="rounded bg-signal/15 px-2 py-0.5 font-medium text-signal border border-signal/30">
|
||||||
{deriveSignal(s).label}
|
STABLE_STREAM
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<MetricTile
|
||||||
label="Messages"
|
label="Total Messages"
|
||||||
value={formatNumber(s.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"
|
tone="signal"
|
||||||
icon={<MessageSquare className="size-3.5" />}
|
icon={<Shield className="size-4" />}
|
||||||
className="animate-stagger"
|
|
||||||
style={staggerDelay(0)}
|
|
||||||
/>
|
/>
|
||||||
<MetricTile
|
<MetricTile
|
||||||
label="Flagged"
|
label="Voice Recordings"
|
||||||
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"
|
|
||||||
value={formatNumber(s.total_voice_recordings)}
|
value={formatNumber(s.total_voice_recordings)}
|
||||||
icon={<Mic className="size-3.5" />}
|
hint="Archived audio sessions"
|
||||||
className="animate-stagger"
|
icon={<Mic className="size-4" />}
|
||||||
style={staggerDelay(3)}
|
/>
|
||||||
|
<MetricTile
|
||||||
|
label="Total Profiles"
|
||||||
|
value={formatNumber(s.total_profiles)}
|
||||||
|
hint={`${formatNumber(s.active_users_24h)} active 24h`}
|
||||||
|
icon={<Users className="size-4" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</GlassPanel>
|
|
||||||
|
|
||||||
{/* Activity */}
|
{/* Dynamic Activity Area & Telemetry Gauges */}
|
||||||
<GlassPanel>
|
<div className="grid gap-3 lg:grid-cols-3">
|
||||||
<SectionHeader
|
<GlassPanel className="linear-tile lg:col-span-2">
|
||||||
eyebrow="14-day signal"
|
<SectionHeader
|
||||||
title={
|
eyebrow="Activity Stream"
|
||||||
<span className="flex items-center gap-2">
|
title="Message Volume & Signal Density"
|
||||||
<Activity className="size-4 text-signal" /> Activity & moderation
|
/>
|
||||||
</span>
|
<div className="mt-4">
|
||||||
}
|
<AreaActivity daily={activity?.daily ?? []} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
</GlassPanel>
|
||||||
/>
|
|
||||||
{activity ? (
|
|
||||||
<AreaActivity daily={activity.daily} />
|
|
||||||
) : (
|
|
||||||
<LoadingState label="streaming" />
|
|
||||||
)}
|
|
||||||
</GlassPanel>
|
|
||||||
|
|
||||||
{/* Two-column: channels + moderation */}
|
<GlassPanel className="linear-tile flex flex-col justify-between">
|
||||||
<div className="grid gap-5 lg:grid-cols-5">
|
<div>
|
||||||
<GlassPanel className="lg:col-span-3">
|
<SectionHeader
|
||||||
<SectionHeader eyebrow="throughput" title="Top channels" />
|
eyebrow="Security State"
|
||||||
<div className="space-y-2.5">
|
title="Moderation Accuracy"
|
||||||
{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)}
|
|
||||||
/>
|
/>
|
||||||
<Row
|
<div className="my-6 flex items-center justify-center">
|
||||||
icon={<Flag className="size-4 text-vermilion" />}
|
<RadialGauge
|
||||||
label="Flagged"
|
value={Math.round(cleanRatio * 100)}
|
||||||
value={formatNumber(s.total_flagged)}
|
label="Clean Ratio"
|
||||||
/>
|
tone={cleanRatio > 0.85 ? "signal" : "amber"}
|
||||||
<Row
|
size={140}
|
||||||
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>
|
</div>
|
||||||
))}
|
</div>
|
||||||
{(reactions ?? []).length === 0 && <EmptyHint />}
|
<div className="grid grid-cols-2 gap-2 border-t border-hairline pt-3 text-center">
|
||||||
</div>
|
<div>
|
||||||
</GlassPanel>
|
<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>
|
||||||
</div>
|
</PageTransition>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { PageTransition } from "@/components/shared";
|
|
||||||
import { getGlossary } from "@/lib/api/server";
|
import { getGlossary } from "@/lib/api/server";
|
||||||
import type { GlossaryRow } from "@/lib/types";
|
import type { GlossaryRow } from "@/lib/types";
|
||||||
import { GlossaryView } from "./view";
|
import { GlossaryView } from "./view";
|
||||||
@@ -12,9 +11,5 @@ export default async function GlossaryPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
terms = undefined;
|
terms = undefined;
|
||||||
}
|
}
|
||||||
return (
|
return <GlossaryView initialTerms={terms} />;
|
||||||
<PageTransition>
|
|
||||||
<GlossaryView initialTerms={terms} />
|
|
||||||
</PageTransition>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { PageTransition } from "@/components/shared";
|
|
||||||
import { getMediaStatus } from "@/lib/api/server";
|
import { getMediaStatus } from "@/lib/api/server";
|
||||||
import { MediaView } from "./view";
|
import { MediaView } from "./view";
|
||||||
|
|
||||||
@@ -11,9 +10,5 @@ export default async function MediaPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
/* client hooks surface errors */
|
/* client hooks surface errors */
|
||||||
}
|
}
|
||||||
return (
|
return <MediaView initialStatus={status} />;
|
||||||
<PageTransition>
|
|
||||||
<MediaView initialStatus={status} />
|
|
||||||
</PageTransition>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { Disc, Play, Repeat, SkipForward, Square } from "lucide-react";
|
||||||
ListMusic,
|
|
||||||
Play,
|
|
||||||
Radio,
|
|
||||||
Repeat,
|
|
||||||
SkipForward,
|
|
||||||
Square,
|
|
||||||
Volume2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
|
import { Equalizer } from "@/components/charts";
|
||||||
import { Button, GlassPanel, Input, toast } from "@/components/primitives";
|
import { Button, GlassPanel, Input, toast } from "@/components/primitives";
|
||||||
import {
|
import {
|
||||||
ErrorState,
|
ErrorState,
|
||||||
|
PageTransition,
|
||||||
SectionHeader,
|
SectionHeader,
|
||||||
SkeletonHero,
|
SkeletonHero,
|
||||||
SkeletonPanel,
|
SkeletonPanel,
|
||||||
@@ -26,9 +20,9 @@ import {
|
|||||||
useMediaStop,
|
useMediaStop,
|
||||||
useMediaWsSync,
|
useMediaWsSync,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
|
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
|
||||||
import { formatDuration } from "@/lib/format";
|
import { formatDuration } from "@/lib/format";
|
||||||
import type { MediaState } from "@/lib/types";
|
import type { MediaState } from "@/lib/types";
|
||||||
import { staggerDelay } from "@/lib/utils";
|
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
|
||||||
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||||
@@ -51,30 +45,32 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
const playing = media?.playing ?? false;
|
const playing = media?.playing ?? false;
|
||||||
const current = media?.current ?? null;
|
const current = media?.current ?? null;
|
||||||
const queueList = media?.queue ?? [];
|
const queueList = media?.queue ?? [];
|
||||||
const queueTotal = queueList.reduce(
|
|
||||||
(acc, it) => acc + (it.durationMs ?? 0),
|
const mediaRef = useStaggerReveal<HTMLDivElement>(".media-tile", {
|
||||||
0,
|
stagger: 0.04,
|
||||||
);
|
y: 8,
|
||||||
|
dependencies: [playing, queueList.length],
|
||||||
|
});
|
||||||
|
|
||||||
const tone = playing ? "signal" : queueList.length ? "amber" : "signal";
|
const tone = playing ? "signal" : queueList.length ? "amber" : "signal";
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ambient.set(
|
ambient.set(
|
||||||
tone,
|
tone,
|
||||||
playing ? 0.5 : 0.25,
|
playing ? 0.45 : 0.2,
|
||||||
playing ? "now playing" : "media idle",
|
playing ? "playing stream" : "media ready",
|
||||||
);
|
);
|
||||||
}, [tone, playing, ambient]);
|
}, [tone, playing, ambient]);
|
||||||
|
|
||||||
const onPlay = async () => {
|
const onPlay = async () => {
|
||||||
const u = url.trim();
|
const u = url.trim();
|
||||||
if (!u) {
|
if (!u) {
|
||||||
toast({ title: "Enter a media URL", tone: "vermilion" });
|
toast({ title: "Enter media URL", tone: "vermilion" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await queue.mutateAsync({ url: u, mode: "music" });
|
await queue.mutateAsync({ url: u, mode: "music" });
|
||||||
setUrl("");
|
setUrl("");
|
||||||
toast({ title: "Queued", tone: "signal" });
|
toast({ title: "Track queued", tone: "signal" });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({
|
toast({
|
||||||
title: "Queue failed",
|
title: "Queue failed",
|
||||||
@@ -88,212 +84,153 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
return <ErrorState error={error} onRetry={() => void mutate()} />;
|
return <ErrorState error={error} onRetry={() => void mutate()} />;
|
||||||
if (!media && isLoading)
|
if (!media && isLoading)
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-4">
|
||||||
<SkeletonHero />
|
<SkeletonHero />
|
||||||
<SkeletonPanel rows={4} />
|
<SkeletonPanel rows={3} />
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="space-y-5">
|
<PageTransition>
|
||||||
<GlassPanel glow className="relative overflow-hidden">
|
<div ref={mediaRef} className="space-y-4">
|
||||||
<div className="scan-line absolute inset-x-0 top-0" />
|
{/* Precision Sub-Header Bar */}
|
||||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-center">
|
<div className="media-tile flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
|
||||||
<div
|
<div className="flex items-center gap-2.5">
|
||||||
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"}`}
|
<span
|
||||||
>
|
className={`h-2 w-2 rounded-full ${
|
||||||
<div className="flex size-28 items-center justify-center overflow-hidden rounded-full bg-canvas/60">
|
playing
|
||||||
{current?.thumbnailUrl ? (
|
? "bg-signal shadow-[0_0_8px_var(--color-signal-glow)]"
|
||||||
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
|
: "bg-ink-muted"
|
||||||
<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()}
|
|
||||||
/>
|
/>
|
||||||
|
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
|
||||||
|
Audio Engine · Media Gateway
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5">
|
<div className="flex items-center gap-2 font-mono text-[11px]">
|
||||||
<Volume2 className="size-4 text-ink-faint" />
|
<span className="text-ink-muted">ENGINE:</span>
|
||||||
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-white/10">
|
<span
|
||||||
<div
|
className={`rounded px-1.5 py-0.5 font-medium border ${
|
||||||
className="h-full rounded-full bg-signal/70"
|
playing
|
||||||
style={{
|
? "border-signal/30 bg-signal/10 text-signal"
|
||||||
width: `${Math.round((media?.musicVolume ?? 0) * 100)}%`,
|
: "border-hairline bg-surface-2 text-ink-muted"
|
||||||
}}
|
}`}
|
||||||
/>
|
>
|
||||||
</div>
|
{playing ? "STREAMING" : "IDLE"}
|
||||||
<span className="mono w-9 text-right text-[0.65rem] text-ink-faint">
|
|
||||||
{Math.round((media?.musicVolume ?? 0) * 100)}%
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GlassPanel>
|
|
||||||
|
|
||||||
<GlassPanel>
|
{/* Media Playback Deck */}
|
||||||
<SectionHeader
|
<div className="media-tile">
|
||||||
eyebrow="up next"
|
<GlassPanel className="p-5">
|
||||||
title="Queue"
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
action={
|
<div className="flex items-center gap-3.5">
|
||||||
<span className="mono text-xs text-ink-faint">
|
<div className="flex size-11 items-center justify-center rounded-[8px] border border-hairline bg-surface-2 text-signal">
|
||||||
{queueList.length} track{queueList.length === 1 ? "" : "s"}
|
<Disc
|
||||||
{queueTotal && ` · ${formatDuration(queueTotal)}`}
|
className={`size-6 ${playing ? "animate-spin-disc text-signal" : "text-ink-muted"}`}
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<div>
|
||||||
})}
|
<div className="text-sm font-semibold tracking-tight text-ink">
|
||||||
</div>
|
{current?.title ?? "No Active Media Stream"}
|
||||||
)}
|
</div>
|
||||||
</GlassPanel>
|
<div className="font-mono text-[11px] text-ink-muted">
|
||||||
</div>
|
{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 {
|
import {
|
||||||
getConfig,
|
getConfig,
|
||||||
getGuilds,
|
getGuilds,
|
||||||
@@ -28,13 +27,11 @@ export default async function MessagesPage() {
|
|||||||
/* client hooks surface errors */
|
/* client hooks surface errors */
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<PageTransition>
|
<MessagesView
|
||||||
<MessagesView
|
initialGuilds={guilds}
|
||||||
initialGuilds={guilds}
|
initialGuildId={config?.monitorGuildId ?? null}
|
||||||
initialGuildId={config?.monitorGuildId ?? null}
|
initialMessages={initialMessages}
|
||||||
initialMessages={initialMessages}
|
initialEdits={initialEdits}
|
||||||
initialEdits={initialEdits}
|
/>
|
||||||
/>
|
|
||||||
</PageTransition>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,12 +4,14 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Calendar,
|
Calendar,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
History,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Search,
|
Search,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ActivityHeatmap } from "@/components/ActivityHeatmap";
|
import { ActivityHeatmap } from "@/components/ActivityHeatmap";
|
||||||
@@ -42,6 +44,7 @@ import {
|
|||||||
useReviewWsSync,
|
useReviewWsSync,
|
||||||
useSemanticSearch,
|
useSemanticSearch,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
|
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
|
||||||
import { aiTone } from "@/lib/ai-status";
|
import { aiTone } from "@/lib/ai-status";
|
||||||
import {
|
import {
|
||||||
formatBytes,
|
formatBytes,
|
||||||
@@ -55,9 +58,9 @@ import type {
|
|||||||
AiStatus,
|
AiStatus,
|
||||||
EditHistoryRow,
|
EditHistoryRow,
|
||||||
Guild,
|
Guild,
|
||||||
|
MessageMetadata,
|
||||||
MessageRecord,
|
MessageRecord,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { staggerDelay } from "@/lib/utils";
|
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
|
||||||
export function MessagesView({
|
export function MessagesView({
|
||||||
@@ -101,10 +104,14 @@ export function MessagesView({
|
|||||||
channelId ?? undefined,
|
channelId ?? undefined,
|
||||||
initialMessages ?? undefined,
|
initialMessages ?? undefined,
|
||||||
);
|
);
|
||||||
// Stream history one message per WS frame (replaces the 50-row batched fetch).
|
// Stream history over WS.
|
||||||
// Drives snapshots into the SWR list above as they arrive; falls back to the
|
// Drives snapshots into the SWR list above buffered by rAF; falls back to the
|
||||||
// SSR `initialMessages` seed if WS is unavailable.
|
// 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.
|
// Cursor to the next (older) page + whether more history exists.
|
||||||
const { data: pageInfo } = useMessagesHasMore(
|
const { data: pageInfo } = useMessagesHasMore(
|
||||||
guildId ?? "",
|
guildId ?? "",
|
||||||
@@ -217,9 +224,42 @@ export function MessagesView({
|
|||||||
prevLen.current = list.length;
|
prevLen.current = list.length;
|
||||||
}, [list.length, searching]);
|
}, [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 (
|
return (
|
||||||
<div className="space-y-4">
|
<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
|
<GuildChannelPicker
|
||||||
mode="text"
|
mode="text"
|
||||||
guildsInitial={initialGuilds}
|
guildsInitial={initialGuilds}
|
||||||
@@ -236,48 +276,72 @@ export function MessagesView({
|
|||||||
<div className="relative ml-auto w-full sm:w-64">
|
<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" />
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-ink-faint" />
|
||||||
<Input
|
<Input
|
||||||
className="pl-9"
|
className="pl-9 text-xs"
|
||||||
placeholder="Search messages…"
|
placeholder="Search captured logs..."
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-1.5 rounded-[6px] border border-hairline bg-surface-2 p-0.5">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setSemanticMode((v) => !v)}
|
type="button"
|
||||||
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${
|
onClick={() => setSemanticMode(false)}
|
||||||
semanticMode
|
className={`rounded-[4px] px-2.5 py-1 font-mono text-[10px] font-medium transition-all ${
|
||||||
? "border-signal/40 bg-signal/10 text-signal"
|
!semanticMode
|
||||||
: "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]"
|
? "bg-surface text-ink border border-hairline-focus shadow-xs"
|
||||||
}`}
|
: "text-ink-muted hover:text-ink"
|
||||||
title="Toggle semantic (vector) search over the message archive"
|
}`}
|
||||||
>
|
>
|
||||||
{semanticMode ? "Semantic" : "Exact"}
|
EXACT
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => setSemanticMode(true)}
|
||||||
setViewMode((v) => (v === "feed" ? "timeline" : "feed"))
|
className={`flex items-center gap-1 rounded-[4px] px-2.5 py-1 font-mono text-[10px] font-medium transition-all ${
|
||||||
}
|
semanticMode
|
||||||
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${
|
? "bg-signal/20 text-signal border border-signal/40 shadow-xs"
|
||||||
viewMode === "timeline"
|
: "text-ink-muted hover:text-ink"
|
||||||
? "border-signal/40 bg-signal/10 text-signal"
|
}`}
|
||||||
: "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]"
|
>
|
||||||
}`}
|
<Sparkles className="size-3" />
|
||||||
title="Toggle timeline (date-grouped) view"
|
SEMANTIC
|
||||||
>
|
</button>
|
||||||
{viewMode === "timeline" ? "Timeline" : "Feed"}
|
</div>
|
||||||
</button>
|
|
||||||
|
<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>
|
</GlassPanel>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-5">
|
<div className="grid gap-3 lg:grid-cols-5">
|
||||||
{semanticSearching && (
|
{semanticSearching && (
|
||||||
<GlassPanel className="lg:col-span-5">
|
<GlassPanel className="lg:col-span-5">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
eyebrow="semantic"
|
eyebrow="semantic archive"
|
||||||
title={`“${query}”`}
|
title={`Vector Matches for “${query}”`}
|
||||||
action={
|
action={
|
||||||
<span className="mono text-xs text-ink-faint">
|
<span className="mono text-xs text-[#8a8f98]">
|
||||||
{semantic.data?.length ?? 0} matches
|
{semantic.data?.length ?? 0} matches
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -289,19 +353,18 @@ export function MessagesView({
|
|||||||
{semantic.data.map((r, i) => (
|
{semantic.data.map((r, i) => (
|
||||||
<div
|
<div
|
||||||
key={r.message_id ?? i}
|
key={r.message_id ?? i}
|
||||||
className="animate-stagger flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3"
|
className="hud-card flex items-start gap-3 p-3"
|
||||||
style={staggerDelay(i)}
|
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="mono text-[0.6rem] text-signal">
|
<span className="font-mono text-[10px] font-semibold text-signal">
|
||||||
{(r.score * 100).toFixed(0)}%
|
{(r.score * 100).toFixed(0)}% RELEVANCE
|
||||||
</span>
|
</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)}
|
{formatRelativeTime(r.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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}
|
{r.content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -312,19 +375,20 @@ export function MessagesView({
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<Search className="size-7" />}
|
icon={<Search className="size-7" />}
|
||||||
title="No semantic matches"
|
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>
|
</GlassPanel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Message Stream Deck */}
|
||||||
<GlassPanel className="lg:col-span-3">
|
<GlassPanel className="lg:col-span-3">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
eyebrow={searching ? "results" : "live feed"}
|
eyebrow={searching ? "query results" : "realtime log"}
|
||||||
title={searching ? `“${query}”` : "Messages"}
|
title={searching ? `Matches for “${query}”` : "Message Stream"}
|
||||||
action={
|
action={
|
||||||
<span className="mono text-xs text-ink-faint">
|
<span className="mono text-xs text-[#8a8f98]">
|
||||||
{list.length} shown
|
{list.length} messages
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -335,38 +399,37 @@ export function MessagesView({
|
|||||||
) : list.length === 0 ? (
|
) : list.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<MessageSquare className="size-7" />}
|
icon={<MessageSquare className="size-7" />}
|
||||||
title="No messages"
|
title="No messages captured"
|
||||||
description="Pick a guild to begin, or run a search."
|
description="Select a channel or verify discord bridge is active."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
{!searching && (
|
{!searching && (
|
||||||
<div className="mb-2 flex items-center justify-center gap-2">
|
<div className="mb-2 flex items-center justify-center gap-2">
|
||||||
{loadMore.isPending ? (
|
{loadMore.isPending ? (
|
||||||
<span className="flex items-center gap-1.5 text-xs text-ink-soft">
|
<span className="flex items-center gap-1.5 font-mono text-xs text-ink-muted">
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
<Loader2 className="size-3.5 animate-spin text-signal" />
|
||||||
Loading older…
|
FETCHING EARLIER PACKETS...
|
||||||
|
<span className="typing-dots">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
) : hasMore && loadedPages < MAX_OLDER_PAGES ? (
|
) : hasMore && loadedPages < MAX_OLDER_PAGES ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={loadOlder}
|
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>
|
</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 &&
|
<span className="font-mono text-[10px] text-ink-faint">
|
||||||
messages.length > 0 && (
|
{loadedPages >= MAX_OLDER_PAGES
|
||||||
<span className="mono text-[0.65rem] text-ink-faint">
|
? `CAPPED AT ${MAX_OLDER_PAGES} PAGES`
|
||||||
beginning of history
|
: "STREAM ROOT REACHED"}
|
||||||
</span>
|
</span>
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -375,8 +438,6 @@ export function MessagesView({
|
|||||||
className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1"
|
className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1"
|
||||||
onScroll={(e) => {
|
onScroll={(e) => {
|
||||||
const el = e.currentTarget;
|
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 =
|
nearBottomRef.current =
|
||||||
el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
||||||
if (searching || !hasMore || loadMore.isPending) return;
|
if (searching || !hasMore || loadMore.isPending) return;
|
||||||
@@ -386,44 +447,47 @@ export function MessagesView({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{viewMode === "timeline" && timelineNodes
|
<div ref={streamRef} className="space-y-1.5">
|
||||||
? timelineNodes.map((node, _i) =>
|
{viewMode === "timeline" && timelineNodes
|
||||||
node.type === "date" ? (
|
? timelineNodes.map((node) =>
|
||||||
<div
|
node.type === "date" ? (
|
||||||
key={`date-${node.iso}`}
|
<div
|
||||||
className="flex items-center gap-2 px-1 text-[0.65rem] text-ink-faint"
|
key={`date-${node.iso}`}
|
||||||
>
|
className="flex items-center gap-2 py-1 font-mono text-[10px] text-ink-muted"
|
||||||
<Calendar className="size-3" />
|
>
|
||||||
{node.label}
|
<Calendar className="size-3 text-signal" />
|
||||||
</div>
|
{node.label}
|
||||||
) : (
|
</div>
|
||||||
|
) : (
|
||||||
|
<MessageRow
|
||||||
|
key={node.m.id}
|
||||||
|
m={node.m}
|
||||||
|
selected={selected}
|
||||||
|
onSelect={setSelected}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: display.map((m) => (
|
||||||
<MessageRow
|
<MessageRow
|
||||||
key={node.m.id}
|
key={m.id}
|
||||||
m={node.m}
|
m={m}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
onSelect={setSelected}
|
onSelect={setSelected}
|
||||||
/>
|
/>
|
||||||
),
|
))}
|
||||||
)
|
</div>
|
||||||
: display.map((m, _i) => (
|
|
||||||
<MessageRow
|
|
||||||
key={m.id}
|
|
||||||
m={m}
|
|
||||||
selected={selected}
|
|
||||||
onSelect={setSelected}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</GlassPanel>
|
</GlassPanel>
|
||||||
|
|
||||||
|
{/* Message Inspector Detail Panel */}
|
||||||
<GlassPanel className="lg:col-span-2">
|
<GlassPanel className="lg:col-span-2">
|
||||||
<SectionHeader eyebrow="inspect" title="Detail" />
|
<SectionHeader eyebrow="telemetry analysis" title="Inspector Deck" />
|
||||||
{!selected ? (
|
{!selected ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Select a message"
|
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 ? (
|
) : detail.loading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -436,7 +500,7 @@ export function MessagesView({
|
|||||||
attachments={detail.attachments}
|
attachments={detail.attachments}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState title="Not found" />
|
<EmptyState title="Packet not found" />
|
||||||
)}
|
)}
|
||||||
</GlassPanel>
|
</GlassPanel>
|
||||||
</div>
|
</div>
|
||||||
@@ -493,12 +557,25 @@ function MessageDetail({
|
|||||||
}) {
|
}) {
|
||||||
const flags = safeParseJsonArray(m.ai_moderation_flags);
|
const flags = safeParseJsonArray(m.ai_moderation_flags);
|
||||||
const cats = safeParseJsonArray(m.ai_categories);
|
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 (
|
return (
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<div className="flex items-center gap-3">
|
<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>
|
||||||
<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">
|
<div className="mono text-[0.65rem] text-ink-faint">
|
||||||
{getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)}
|
{getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)}
|
||||||
</div>
|
</div>
|
||||||
@@ -511,15 +588,15 @@ function MessageDetail({
|
|||||||
</div>
|
</div>
|
||||||
</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) ||
|
{renderMessageContent(m.edited_content ?? m.content, m.metadata) ||
|
||||||
"(no text)"}
|
"(no text content)"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{m.ai_analysis && (
|
{m.ai_analysis && (
|
||||||
<div>
|
<div>
|
||||||
<div className="eyebrow mb-1">AI analysis</div>
|
<div className="eyebrow mb-1">AI heuristic reasoning</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-muted leading-relaxed">
|
||||||
{m.ai_analysis}
|
{m.ai_analysis}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -540,6 +617,44 @@ function MessageDetail({
|
|||||||
</div>
|
</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 && (
|
{attachments.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="eyebrow mb-1 flex items-center gap-1.5">
|
<div className="eyebrow mb-1 flex items-center gap-1.5">
|
||||||
@@ -552,11 +667,11 @@ function MessageDetail({
|
|||||||
href={a.discord_url ?? a.uploaded_url ?? "#"}
|
href={a.discord_url ?? a.uploaded_url ?? "#"}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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" />
|
<ImageIcon className="size-3.5 text-signal" />
|
||||||
<span className="flex-1 truncate">{a.filename}</span>
|
<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)}
|
{formatBytes(a.size)}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</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. */
|
/** Single message card used by both the live feed and the date-grouped timeline. */
|
||||||
function MessageRow({
|
function MessageRow({
|
||||||
m,
|
m,
|
||||||
@@ -578,35 +709,158 @@ function MessageRow({
|
|||||||
selected: string | null;
|
selected: string | null;
|
||||||
onSelect: (id: string) => void;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
key={m.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(m.id)}
|
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
|
selected === m.id
|
||||||
? "border-signal/40 bg-signal/8"
|
? "border-signal/50 bg-signal/10 shadow-xs"
|
||||||
: "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
|
: "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="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate text-sm font-semibold text-ink">
|
<span className="truncate text-xs font-semibold text-ink">
|
||||||
{m.username}
|
{m.server_nick ?? m.username}
|
||||||
</span>
|
</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)}
|
{getMessageChannelLabel(m)}
|
||||||
</span>
|
</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)}
|
{formatRelativeTime(m.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 line-clamp-2 text-sm text-ink-soft">
|
|
||||||
{renderMessageContent(m.content, m.metadata) || (
|
{/* Text content */}
|
||||||
<span className="italic text-ink-faint">(empty / embed)</span>
|
{m.content && (
|
||||||
)}
|
<div className="mt-0.5 line-clamp-2 text-xs text-ink-soft">
|
||||||
</div>
|
{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>
|
</div>
|
||||||
<AiBadge status={m.ai_status} durationMs={m.ai_analysis_duration_ms} />
|
<AiBadge status={m.ai_status} durationMs={m.ai_analysis_duration_ms} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { PageTransition } from "@/components/shared";
|
|
||||||
import { getModerationActions, getModerationStats } from "@/lib/api/server";
|
import { getModerationActions, getModerationStats } from "@/lib/api/server";
|
||||||
import { ModerationView } from "./view";
|
import { ModerationView } from "./view";
|
||||||
|
|
||||||
@@ -15,9 +14,5 @@ export default async function ModerationPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
/* client hooks surface errors */
|
/* client hooks surface errors */
|
||||||
}
|
}
|
||||||
return (
|
return <ModerationView initialStats={stats} initialActions={actions} />;
|
||||||
<PageTransition>
|
|
||||||
<ModerationView initialStats={stats} initialActions={actions} />
|
|
||||||
</PageTransition>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Loaded 50 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user