feat: update dependencies and improve dashboard functionality
Deploy to VPS / deploy (push) Failing after 1m43s
Deploy to VPS / deploy (push) Failing after 1m43s
- Added new dependencies for Next.js and lucide-react in pnpm-workspace.yaml. - Refactored DashboardPage component to improve readability and error handling. - Enhanced Header component to display error status with an alert icon. - Updated MobileTabBar and Sidebar components to use a centralized tabs definition. - Improved ChannelsView in dashboard-panel to handle channel fetching more cleanly. - Fixed ActiveSpeaker type to use camelCase for userId. - Updated MessagesPanel to handle guildId checks more gracefully. - Adjusted API calls in dashboard and messages to align with backend expectations. - Refined type definitions across various interfaces for consistency and clarity.
This commit is contained in:
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.5.5/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"preset": "recommended",
|
||||||
"style": {
|
"style": {
|
||||||
"noNonNullAssertion": "warn",
|
"noNonNullAssertion": "warn",
|
||||||
"useNodejsImportProtocol": "warn"
|
"useNodejsImportProtocol": "warn"
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@
|
|||||||
"install:yt-dlp": "sh scripts/install-yt-dlp.sh"
|
"install:yt-dlp": "sh scripts/install-yt-dlp.sh"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "^2.5.5",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"tsx": "^4.22.2",
|
"tsx": "^4.22.2",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
|||||||
Generated
+2564
-91
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,18 @@ minimumReleaseAgeExclude:
|
|||||||
- pg-protocol@1.14.0
|
- pg-protocol@1.14.0
|
||||||
- pg@8.21.0
|
- pg@8.21.0
|
||||||
- tsx@4.22.2
|
- tsx@4.22.2
|
||||||
|
- '@next/env@16.2.12'
|
||||||
|
- '@next/swc-darwin-arm64@16.2.12'
|
||||||
|
- '@next/swc-darwin-x64@16.2.12'
|
||||||
|
- '@next/swc-linux-arm64-gnu@16.2.12'
|
||||||
|
- '@next/swc-linux-arm64-musl@16.2.12'
|
||||||
|
- '@next/swc-linux-x64-gnu@16.2.12'
|
||||||
|
- '@next/swc-linux-x64-musl@16.2.12'
|
||||||
|
- '@next/swc-win32-arm64-msvc@16.2.12'
|
||||||
|
- '@next/swc-win32-x64-msvc@16.2.12'
|
||||||
|
- lucide-react@1.27.0
|
||||||
|
- next@16.2.12
|
||||||
|
- shadcn@4.15.0
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- '@discordjs/opus'
|
- '@discordjs/opus'
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ async function shutdown(signal: string) {
|
|||||||
// 1. Stop accepting new HTTP connections
|
// 1. Stop accepting new HTTP connections
|
||||||
if (httpServer) {
|
if (httpServer) {
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
httpServer!.close(() => {
|
httpServer?.close(() => {
|
||||||
logger.info("HTTP server closed");
|
logger.info("HTTP server closed");
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createChildLogger } from "@bete/shared/logger";
|
|||||||
import { getPool } from "../../shared/database/index.js";
|
import { getPool } from "../../shared/database/index.js";
|
||||||
import type { ListUsersQuery } from "./dashboard.service.js";
|
import type { ListUsersQuery } from "./dashboard.service.js";
|
||||||
|
|
||||||
const logger = createChildLogger("dashboard.repository");
|
const _logger = createChildLogger("dashboard.repository");
|
||||||
|
|
||||||
export class DashboardRepository {
|
export class DashboardRepository {
|
||||||
async getStats() {
|
async getStats() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Router } from "express";
|
import type { Router } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { collectDefaultMetrics, register } from "prom-client";
|
import { collectDefaultMetrics } from "prom-client";
|
||||||
import { handleHealthCheck, handleMetrics } from "./health.controller.js";
|
import { handleHealthCheck, handleMetrics } from "./health.controller.js";
|
||||||
|
|
||||||
// Initialize default Node.js runtime metrics (event loop lag, memory, GC, etc.)
|
// Initialize default Node.js runtime metrics (event loop lag, memory, GC, etc.)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createChildLogger } from "@bete/shared/logger";
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
import { healthRepository } from "./health.repository.js";
|
import { healthRepository } from "./health.repository.js";
|
||||||
|
|
||||||
const logger = createChildLogger("health.service");
|
const _logger = createChildLogger("health.service");
|
||||||
|
|
||||||
export class HealthService {
|
export class HealthService {
|
||||||
async getHealth(verbose = false) {
|
async getHealth(verbose = false) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Prometheus metrics for AI moderation pipeline.
|
* Prometheus metrics for AI moderation pipeline.
|
||||||
* Defined in backend (where prom-client is installed + /api/metrics endpoint).
|
* Defined in backend (where prom-client is installed + /api/metrics endpoint).
|
||||||
*/
|
*/
|
||||||
import { Counter, Histogram, register } from "prom-client";
|
import { Counter, Histogram } from "prom-client";
|
||||||
|
|
||||||
// ── LLM Call Metrics ──
|
// ── LLM Call Metrics ──
|
||||||
export const llmCallsTotal = new Counter({
|
export const llmCallsTotal = new Counter({
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export const handleMascotChat = asyncHandler(
|
|||||||
export const getMascotChatHistory = asyncHandler(
|
export const getMascotChatHistory = asyncHandler(
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId || "anonymous";
|
const userId = (req as AuthenticatedRequest).userId || "anonymous";
|
||||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100);
|
||||||
|
|
||||||
const history = await mascotChatService.getChatHistory(userId, limit);
|
const history = await mascotChatService.getChatHistory(userId, limit);
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ function normalizeMediaState(raw: Record<string, unknown>): MediaState {
|
|||||||
|
|
||||||
type MediaReplyData = Record<string, unknown> | MediaState;
|
type MediaReplyData = Record<string, unknown> | MediaState;
|
||||||
|
|
||||||
function fromReply(data: MediaReplyData): MediaState {
|
function _fromReply(data: MediaReplyData): MediaState {
|
||||||
return normalizeMediaState(data as Record<string, unknown>);
|
return normalizeMediaState(data as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export class RecordingsService {
|
|||||||
const items = rows.slice(0, limit) as unknown as RecordingRow[];
|
const items = rows.slice(0, limit) as unknown as RecordingRow[];
|
||||||
const hasMore = rows.length > limit;
|
const hasMore = rows.length > limit;
|
||||||
const nextCursor = hasMore
|
const nextCursor = hasMore
|
||||||
? String(items[items.length - 1]!.created_at)
|
? String(items[items.length - 1]?.created_at)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return { items, nextCursor, hasMore };
|
return { items, nextCursor, hasMore };
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { CommandReply } from "@bete/shared";
|
import type { CommandReply } from "@bete/shared";
|
||||||
import { createChildLogger } from "@bete/shared/logger";
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
import { publishCommand, readRedisStatus } from "./redis/index.js";
|
|
||||||
|
|
||||||
export { createChildLogger };
|
export { createChildLogger };
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
pageResult,
|
pageResult,
|
||||||
retryWithBackoff,
|
retryWithBackoff,
|
||||||
} from "@bete/shared/utils";
|
} from "@bete/shared/utils";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
// ─── Backend middleware ──────────────────────────────────────────────────────
|
// ─── Backend middleware ──────────────────────────────────────────────────────
|
||||||
import { asyncHandler, requireParam } from "../src/shared/middlewares/index.js";
|
import { asyncHandler, requireParam } from "../src/shared/middlewares/index.js";
|
||||||
|
|||||||
@@ -24,10 +24,7 @@ import { registerReactionCapture } from "../modules/reaction-tracking/index.js";
|
|||||||
import { registerThreadCapture } from "../modules/thread-tracking/index.js";
|
import { registerThreadCapture } from "../modules/thread-tracking/index.js";
|
||||||
import { registerPresenceCapture } from "../modules/user-presence/index.js";
|
import { registerPresenceCapture } from "../modules/user-presence/index.js";
|
||||||
import { VoicePcmWsClient } from "../modules/voice-pcm-ws/index.js";
|
import { VoicePcmWsClient } from "../modules/voice-pcm-ws/index.js";
|
||||||
import {
|
import { startMuxerWorker } from "../modules/voice-recording/muxer.js";
|
||||||
startMuxerWorker,
|
|
||||||
stopMuxerWorker,
|
|
||||||
} from "../modules/voice-recording/muxer.js";
|
|
||||||
import {
|
import {
|
||||||
setPcmWsClient,
|
setPcmWsClient,
|
||||||
setEventBroadcaster as setRecorderEventBroadcaster,
|
setEventBroadcaster as setRecorderEventBroadcaster,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
} from "./batchProcessor.js";
|
} from "./batchProcessor.js";
|
||||||
import { scheduleConversationAnalysis } from "./batchScheduler.js";
|
import { scheduleConversationAnalysis } from "./batchScheduler.js";
|
||||||
import {
|
import {
|
||||||
_redisEventBroadcaster,
|
|
||||||
broadcastAnalysisCompleted,
|
broadcastAnalysisCompleted,
|
||||||
conversationConsecutiveErrors,
|
conversationConsecutiveErrors,
|
||||||
conversationDebounceTimers,
|
conversationDebounceTimers,
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ let activeCount = 0;
|
|||||||
let pendingCount = 0;
|
let pendingCount = 0;
|
||||||
|
|
||||||
// Track queue state changes for logging
|
// Track queue state changes for logging
|
||||||
function updateCounts(): void {
|
function _updateCounts(): void {
|
||||||
// p-limit exposes queueSize and activeCount via constructor internals,
|
// p-limit exposes queueSize and activeCount via constructor internals,
|
||||||
// but we track via our wrapper to avoid depending on internals.
|
// but we track via our wrapper to avoid depending on internals.
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function withLlmConcurrency<T>(fn: () => Promise<T>): Promise<T> {
|
export async function withLlmConcurrency<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
const queuedAt = activeCount + pendingCount;
|
const _queuedAt = activeCount + pendingCount;
|
||||||
pendingCount++;
|
pendingCount++;
|
||||||
logger.debug(
|
logger.debug(
|
||||||
{ activeCount, pendingCount, maxConcurrent: config.AI_LLM_MAX_CONCURRENT },
|
{ activeCount, pendingCount, maxConcurrent: config.AI_LLM_MAX_CONCURRENT },
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import { createChildLogger } from "@bete/shared/logger";
|
|||||||
import { and, desc, eq, sql } from "drizzle-orm";
|
import { and, desc, eq, sql } from "drizzle-orm";
|
||||||
import { config } from "../../shared/config/config.js";
|
import { config } from "../../shared/config/config.js";
|
||||||
import { getDatabase } from "../../shared/database/drizzle.js";
|
import { getDatabase } from "../../shared/database/drizzle.js";
|
||||||
import {
|
import { messagesTable } from "../../shared/database/schema.js";
|
||||||
channelCulturesTable,
|
|
||||||
messagesTable,
|
|
||||||
} from "../../shared/database/schema.js";
|
|
||||||
import { updateChannelCulture } from "./channelCultureStore.js";
|
import { updateChannelCulture } from "./channelCultureStore.js";
|
||||||
import { llmChat } from "./llmClient.js";
|
import { llmChat } from "./llmClient.js";
|
||||||
|
|
||||||
|
|||||||
@@ -550,7 +550,7 @@ async function downloadMediaCandidate(
|
|||||||
imageMap: Map<string, MessageImagePart[]>,
|
imageMap: Map<string, MessageImagePart[]>,
|
||||||
mediaAnalysisMap: Map<string, string[]>,
|
mediaAnalysisMap: Map<string, string[]>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const log = createChildLogger("mediaAnalysis");
|
const _log = createChildLogger("mediaAnalysis");
|
||||||
if ((imageMap.get(targetId)?.length ?? 0) >= 8) return;
|
if ((imageMap.get(targetId)?.length ?? 0) >= 8) return;
|
||||||
|
|
||||||
if (candidate.customEmojiId || candidate.stickerName) {
|
if (candidate.customEmojiId || candidate.stickerName) {
|
||||||
@@ -646,7 +646,7 @@ export async function prepareMediaMessage(
|
|||||||
target: MessageRecord,
|
target: MessageRecord,
|
||||||
allAttachments: AttachmentRecord[] | undefined,
|
allAttachments: AttachmentRecord[] | undefined,
|
||||||
): Promise<PreparedMediaMessage> {
|
): Promise<PreparedMediaMessage> {
|
||||||
const log = createChildLogger("mediaAnalysis");
|
const _log = createChildLogger("mediaAnalysis");
|
||||||
const targetId = target.id;
|
const targetId = target.id;
|
||||||
const imageMap = new Map<string, MessageImagePart[]>();
|
const imageMap = new Map<string, MessageImagePart[]>();
|
||||||
const webTextMap = new Map<string, string[]>();
|
const webTextMap = new Map<string, string[]>();
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { delay, retryWithBackoff } from "@bete/shared/utils";
|
|||||||
import type { ChatCompletion } from "openai/resources/chat/completions";
|
import type { ChatCompletion } from "openai/resources/chat/completions";
|
||||||
import { config } from "../../shared/config/config.js";
|
import { config } from "../../shared/config/config.js";
|
||||||
import { extractMessageMediaEvidence } from "../message-capture/messageMetadata.js";
|
import { extractMessageMediaEvidence } from "../message-capture/messageMetadata.js";
|
||||||
import { getMessageById } from "../message-capture/messageStore.js";
|
|
||||||
import type {
|
import type {
|
||||||
AnalysisResult,
|
AnalysisResult,
|
||||||
AttachmentRecord,
|
AttachmentRecord,
|
||||||
@@ -18,15 +17,7 @@ import type {
|
|||||||
} from "../message-capture/types.js";
|
} from "../message-capture/types.js";
|
||||||
import { getChannelCulture } from "./channelCultureStore.js";
|
import { getChannelCulture } from "./channelCultureStore.js";
|
||||||
import { llmChat } from "./llmClient.js";
|
import { llmChat } from "./llmClient.js";
|
||||||
import type {
|
import { hasMediaContent, prepareMediaMessage } from "./mediaAnalysisClient.js";
|
||||||
MessageImagePart,
|
|
||||||
PreparedMediaMessage,
|
|
||||||
} from "./mediaAnalysisClient.js";
|
|
||||||
import {
|
|
||||||
analyzeSingleMediaImage,
|
|
||||||
hasMediaContent,
|
|
||||||
prepareMediaMessage,
|
|
||||||
} from "./mediaAnalysisClient.js";
|
|
||||||
import {
|
import {
|
||||||
buildReferenceXml,
|
buildReferenceXml,
|
||||||
escapeXml,
|
escapeXml,
|
||||||
@@ -816,7 +807,7 @@ export async function runSimpleTextFallback(
|
|||||||
const MAX_CONTENT_CHARS = 500;
|
const MAX_CONTENT_CHARS = 500;
|
||||||
const truncatedContent =
|
const truncatedContent =
|
||||||
content.length > MAX_CONTENT_CHARS
|
content.length > MAX_CONTENT_CHARS
|
||||||
? content.slice(0, MAX_CONTENT_CHARS) + "..."
|
? `${content.slice(0, MAX_CONTENT_CHARS)}...`
|
||||||
: content;
|
: content;
|
||||||
|
|
||||||
let userProfileCtx = "";
|
let userProfileCtx = "";
|
||||||
@@ -876,7 +867,7 @@ Jawab HANYA dengan satu kata: clean, warn, atau flagged`;
|
|||||||
let category = "";
|
let category = "";
|
||||||
|
|
||||||
if (status === "clean") {
|
if (status === "clean") {
|
||||||
analysis = `${message.username ?? "user"}: ${content.length > 200 ? content.slice(0, 200) + "..." : content}. Percakapan normal, tidak ada pelanggaran.`;
|
analysis = `${message.username ?? "user"}: ${content.length > 200 ? `${content.slice(0, 200)}...` : content}. Percakapan normal, tidak ada pelanggaran.`;
|
||||||
} else {
|
} else {
|
||||||
category = status === "flagged" ? "harassment" : "spam";
|
category = status === "flagged" ? "harassment" : "spam";
|
||||||
const categoryOptions =
|
const categoryOptions =
|
||||||
@@ -958,7 +949,7 @@ Kategori: spam`;
|
|||||||
policyVersion: "default-simple-2026-06",
|
policyVersion: "default-simple-2026-06",
|
||||||
evidence:
|
evidence:
|
||||||
status !== "clean"
|
status !== "clean"
|
||||||
? [content.length > 120 ? content.slice(0, 120) + "..." : content]
|
? [content.length > 120 ? `${content.slice(0, 120)}...` : content]
|
||||||
: [],
|
: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -795,7 +795,7 @@ export function sanitizeAiContent(
|
|||||||
// 3. Cap length
|
// 3. Cap length
|
||||||
const capped =
|
const capped =
|
||||||
escaped.length > maxLen
|
escaped.length > maxLen
|
||||||
? escaped.slice(0, maxLen) + "…[truncated]"
|
? `${escaped.slice(0, maxLen)}…[truncated]`
|
||||||
: escaped;
|
: escaped;
|
||||||
|
|
||||||
// 4. Wrap in CDATA unless the caller opts out (e.g. plain-text contexts)
|
// 4. Wrap in CDATA unless the caller opts out (e.g. plain-text contexts)
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { extractJson } from "./jsonExtractor.js";
|
|||||||
import { ModerationResponseSchema } from "./moderationSchemas.js";
|
import { ModerationResponseSchema } from "./moderationSchemas.js";
|
||||||
import {
|
import {
|
||||||
clampScore,
|
clampScore,
|
||||||
DEFERRAL_ANALYSIS_PATTERN,
|
|
||||||
DEFERRAL_EXCEPTION_PATTERN,
|
|
||||||
deriveRecommendedAction,
|
deriveRecommendedAction,
|
||||||
deriveSeverity,
|
deriveSeverity,
|
||||||
hasDeferralAnalysis,
|
hasDeferralAnalysis,
|
||||||
@@ -46,7 +44,7 @@ export function parseModerationResponse(
|
|||||||
let parsed: any;
|
let parsed: any;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(content);
|
parsed = JSON.parse(content);
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
parsed = extractJson(content);
|
parsed = extractJson(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function logModerationAnalysis(
|
|||||||
},
|
},
|
||||||
parseErrors: string[] = [],
|
parseErrors: string[] = [],
|
||||||
): void {
|
): void {
|
||||||
const response: ModerationAnalysisResponse = {
|
const _response: ModerationAnalysisResponse = {
|
||||||
messageIds,
|
messageIds,
|
||||||
batchSize: messageIds.length,
|
batchSize: messageIds.length,
|
||||||
model,
|
model,
|
||||||
@@ -158,7 +158,7 @@ export function logCacheEvent(
|
|||||||
cacheKey: string,
|
cacheKey: string,
|
||||||
source: "text" | "media" | "sticker",
|
source: "text" | "media" | "sticker",
|
||||||
): void {
|
): void {
|
||||||
const event: CacheHitEvent = {
|
const _event: CacheHitEvent = {
|
||||||
type,
|
type,
|
||||||
cacheKey,
|
cacheKey,
|
||||||
source,
|
source,
|
||||||
@@ -260,7 +260,7 @@ export function logAnalysisSummary(
|
|||||||
duration_ms: durationMs,
|
duration_ms: durationMs,
|
||||||
per_message_avg_ms: Math.round(durationMs / totalMessages),
|
per_message_avg_ms: Math.round(durationMs / totalMessages),
|
||||||
summary,
|
summary,
|
||||||
success_rate: ((successCount / totalMessages) * 100).toFixed(1) + "%",
|
success_rate: `${((successCount / totalMessages) * 100).toFixed(1)}%`,
|
||||||
},
|
},
|
||||||
`Analysis batch complete: ${successCount}/${totalMessages} successful in ${durationMs}ms`,
|
`Analysis batch complete: ${successCount}/${totalMessages} successful in ${durationMs}ms`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export async function incrementTextCacheHit(text: string): Promise<void> {
|
|||||||
`UPDATE text_analysis_cache SET hit_count = hit_count + 1 WHERE text = $1`,
|
`UPDATE text_analysis_cache SET hit_count = hit_count + 1 WHERE text = $1`,
|
||||||
[text],
|
[text],
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
// Silent fail — this is just a counter, not critical
|
// Silent fail — this is just a counter, not critical
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { isIP } from "node:net";
|
|||||||
import { createChildLogger } from "@bete/shared/logger";
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
import { createAbortControllerWithTimeout } from "@bete/shared/utils";
|
import { createAbortControllerWithTimeout } from "@bete/shared/utils";
|
||||||
|
|
||||||
const log = createChildLogger("urlFetcher");
|
const _log = createChildLogger("urlFetcher");
|
||||||
|
|
||||||
export interface FetchedUrlContext {
|
export interface FetchedUrlContext {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -53,14 +53,14 @@ async function isSafeUrl(urlStr: string): Promise<boolean> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
// If DNS fails, we can't fetch it anyway
|
// If DNS fails, we can't fetch it anyway
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,7 @@ function extractOgImage(html: string): string | null {
|
|||||||
const ogRegex =
|
const ogRegex =
|
||||||
/<meta[^>]*(?:property|name)=["'](?:og:image|twitter:image)["'][^>]*content=["']([^"']+)["']/i;
|
/<meta[^>]*(?:property|name)=["'](?:og:image|twitter:image)["'][^>]*content=["']([^"']+)["']/i;
|
||||||
const match = html.match(ogRegex);
|
const match = html.match(ogRegex);
|
||||||
if (match && match[1]) {
|
if (match?.[1]) {
|
||||||
// Unescape basic HTML entities
|
// Unescape basic HTML entities
|
||||||
return match[1].replace(/&/g, "&").replace(/"/g, '"');
|
return match[1].replace(/&/g, "&").replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@ function extractOgImage(html: string): string | null {
|
|||||||
const ogRegexRev =
|
const ogRegexRev =
|
||||||
/<meta[^>]*content=["']([^"']+)["'][^>]*(?:property|name)=["'](?:og:image|twitter:image)["']/i;
|
/<meta[^>]*content=["']([^"']+)["'][^>]*(?:property|name)=["'](?:og:image|twitter:image)["']/i;
|
||||||
const matchRev = html.match(ogRegexRev);
|
const matchRev = html.match(ogRegexRev);
|
||||||
if (matchRev && matchRev[1]) {
|
if (matchRev?.[1]) {
|
||||||
return matchRev[1].replace(/&/g, "&").replace(/"/g, '"');
|
return matchRev[1].replace(/&/g, "&").replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ export async function fetchUrlSafely(
|
|||||||
// If it's HTML, try to find an og:image first (for Tenor/Giphy etc)
|
// If it's HTML, try to find an og:image first (for Tenor/Giphy etc)
|
||||||
if (contentType.startsWith("text/html")) {
|
if (contentType.startsWith("text/html")) {
|
||||||
const ogImage = extractOgImage(text);
|
const ogImage = extractOgImage(text);
|
||||||
if (ogImage && ogImage.startsWith("http")) {
|
if (ogImage?.startsWith("http")) {
|
||||||
// Fetch the og:image instead
|
// Fetch the og:image instead
|
||||||
return fetchUrlSafely(ogImage, depth + 1);
|
return fetchUrlSafely(ogImage, depth + 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import { createChildLogger } from "@bete/shared/logger";
|
|||||||
import { and, desc, eq, sql } from "drizzle-orm";
|
import { and, desc, eq, sql } from "drizzle-orm";
|
||||||
import { config } from "../../shared/config/config.js";
|
import { config } from "../../shared/config/config.js";
|
||||||
import { getDatabase } from "../../shared/database/drizzle.js";
|
import { getDatabase } from "../../shared/database/drizzle.js";
|
||||||
import {
|
import { messagesTable } from "../../shared/database/schema.js";
|
||||||
messagesTable,
|
|
||||||
userProfilesTable,
|
|
||||||
} from "../../shared/database/schema.js";
|
|
||||||
import { llmChat } from "./llmClient.js";
|
import { llmChat } from "./llmClient.js";
|
||||||
import { updateUserProfile } from "./userProfileStore.js";
|
import { updateUserProfile } from "./userProfileStore.js";
|
||||||
|
|
||||||
@@ -48,7 +45,7 @@ async function learnUserProfile(
|
|||||||
for (const msg of recentMessages) {
|
for (const msg of recentMessages) {
|
||||||
const ch = msg.channelId ?? "unknown";
|
const ch = msg.channelId ?? "unknown";
|
||||||
if (!channelGroups.has(ch)) channelGroups.set(ch, []);
|
if (!channelGroups.has(ch)) channelGroups.set(ch, []);
|
||||||
channelGroups.get(ch)!.push(msg);
|
channelGroups.get(ch)?.push(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build messages text with channel context
|
// Build messages text with channel context
|
||||||
|
|||||||
@@ -9,10 +9,7 @@ import { createChildLogger } from "@bete/shared/logger";
|
|||||||
import type { Client } from "discord.js-selfbot-v13";
|
import type { Client } from "discord.js-selfbot-v13";
|
||||||
import Redis from "ioredis";
|
import Redis from "ioredis";
|
||||||
import { config } from "../../shared/config/config.js";
|
import { config } from "../../shared/config/config.js";
|
||||||
import type {
|
import type { VoiceController } from "../voice-recording/voiceController.js";
|
||||||
VoiceController,
|
|
||||||
VoiceStatus,
|
|
||||||
} from "../voice-recording/voiceController.js";
|
|
||||||
import { GuildHandler } from "./guild.handler.js";
|
import { GuildHandler } from "./guild.handler.js";
|
||||||
import {
|
import {
|
||||||
type CommandHandlerFn,
|
type CommandHandlerFn,
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import type { CommandMessage, CommandReply } from "@bete/shared";
|
||||||
COMMAND_VOICE_DISCONNECT_GUILD,
|
|
||||||
type CommandMessage,
|
|
||||||
type CommandReply,
|
|
||||||
} from "@bete/shared";
|
|
||||||
import { createChildLogger } from "@bete/shared/logger";
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
import type { Client } from "discord.js-selfbot-v13";
|
import type { Client } from "discord.js-selfbot-v13";
|
||||||
import { config } from "../../shared/config/config.js";
|
import { config } from "../../shared/config/config.js";
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import { createChildLogger } from "@bete/shared/logger";
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
import type { AppConfig as GatewayConfig } from "../../shared/config/config.js";
|
|
||||||
import { config } from "../../shared/config/config.js";
|
import { config } from "../../shared/config/config.js";
|
||||||
|
|
||||||
const logger = createChildLogger("gateway-metrics");
|
const logger = createChildLogger("gateway-metrics");
|
||||||
@@ -82,7 +81,7 @@ function formatMetrics(): string {
|
|||||||
lines.push(`${fullName} ${metric.value}`);
|
lines.push(`${fullName} ${metric.value}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join("\n") + "\n";
|
return `${lines.join("\n")}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startMetricsServer(): void {
|
export function startMetricsServer(): void {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { decodeCursor, encodeCursor } from "@bete/shared";
|
|
||||||
import { createChildLogger, type Logger } from "@bete/shared/logger";
|
import { createChildLogger, type Logger } from "@bete/shared/logger";
|
||||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
import { getDatabase } from "../../shared/database/drizzle.js";
|
import { getDatabase } from "../../shared/database/drizzle.js";
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import { createChildLogger, type Logger } from "@bete/shared/logger";
|
import { createChildLogger, type Logger } from "@bete/shared/logger";
|
||||||
import {
|
import { and, asc, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||||
and,
|
|
||||||
asc,
|
|
||||||
desc,
|
|
||||||
eq,
|
|
||||||
inArray,
|
|
||||||
isNull,
|
|
||||||
or,
|
|
||||||
type SQL,
|
|
||||||
sql,
|
|
||||||
} from "drizzle-orm";
|
|
||||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
import type * as schema from "../../shared/database/schema.js";
|
import type * as schema from "../../shared/database/schema.js";
|
||||||
import { messagesTable } from "../../shared/database/schema.js";
|
import { messagesTable } from "../../shared/database/schema.js";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { decodeCursor, encodeCursor, pageResult } from "@bete/shared";
|
import { decodeCursor, pageResult } from "@bete/shared";
|
||||||
import { createChildLogger, type Logger } from "@bete/shared/logger";
|
import { createChildLogger, type Logger } from "@bete/shared/logger";
|
||||||
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
|
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
|
||||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { decodeCursor, encodeCursor, pageResult } from "@bete/shared";
|
import { decodeCursor, pageResult } from "@bete/shared";
|
||||||
import { createChildLogger, type Logger } from "@bete/shared/logger";
|
import { createChildLogger, type Logger } from "@bete/shared/logger";
|
||||||
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
|
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
|
||||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { decodeCursor, encodeCursor, pageResult } from "@bete/shared";
|
import { decodeCursor, pageResult } from "@bete/shared";
|
||||||
import { createChildLogger, type Logger } from "@bete/shared/logger";
|
import { createChildLogger, type Logger } from "@bete/shared/logger";
|
||||||
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
|
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
|
||||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import type fs from "node:fs";
|
import type fs from "node:fs";
|
||||||
import type {
|
import type {
|
||||||
AIRecommendedAction,
|
|
||||||
AISeverity,
|
|
||||||
AIStatus,
|
|
||||||
AnalysisQueueStatus,
|
AnalysisQueueStatus,
|
||||||
AttachmentRecord,
|
AttachmentRecord,
|
||||||
BroadcasterClient,
|
|
||||||
MessageRecord,
|
MessageRecord,
|
||||||
ModerationBroadcaster,
|
|
||||||
RoleMetadata,
|
|
||||||
UserMetadata,
|
UserMetadata,
|
||||||
VoiceRecordingUploadData,
|
VoiceRecordingUploadData,
|
||||||
} from "@bete/shared";
|
} from "@bete/shared";
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ export function resolveMediaUrl(
|
|||||||
|
|
||||||
// -- stderr (capture for diagnostics, capped at 4KB) ----------------------------------
|
// -- stderr (capture for diagnostics, capped at 4KB) ----------------------------------
|
||||||
|
|
||||||
const MAX_STDERR = 4096;
|
const _MAX_STDERR = 4096;
|
||||||
if (proc.stderr) {
|
if (proc.stderr) {
|
||||||
proc.stderr.on("data", (chunk: Buffer) => {
|
proc.stderr.on("data", (chunk: Buffer) => {
|
||||||
stderrBuf += chunk.toString("utf8");
|
stderrBuf += chunk.toString("utf8");
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createChildLogger } from "@bete/shared/logger";
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
import { config } from "../../shared/config/config.js";
|
|
||||||
import { getDatabase } from "../../shared/database/drizzle.js";
|
import { getDatabase } from "../../shared/database/drizzle.js";
|
||||||
import type * as schema from "../../shared/database/schema.js";
|
import type * as schema from "../../shared/database/schema.js";
|
||||||
import { muxerJobsTable } from "../../shared/database/schema.js";
|
import { muxerJobsTable } from "../../shared/database/schema.js";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Transform, type TransformCallback } from "node:stream";
|
import { Transform, type TransformCallback } from "node:stream";
|
||||||
import { createChildLogger } from "@bete/shared/logger";
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
|
|
||||||
const logger = createChildLogger("packet-filter");
|
const _logger = createChildLogger("packet-filter");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform stream to filter out audio packets that are too small.
|
* Transform stream to filter out audio packets that are too small.
|
||||||
@@ -19,7 +19,7 @@ export class PacketFilter extends Transform {
|
|||||||
|
|
||||||
_transform(
|
_transform(
|
||||||
chunk: Buffer,
|
chunk: Buffer,
|
||||||
encoding: string,
|
_encoding: string,
|
||||||
callback: TransformCallback,
|
callback: TransformCallback,
|
||||||
): void {
|
): void {
|
||||||
this.totalCount++;
|
this.totalCount++;
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import type { VoicePcmWsClient } from "../voice-pcm-ws/index.js";
|
|||||||
import {
|
import {
|
||||||
createRecordingSession,
|
createRecordingSession,
|
||||||
type RecordingSession,
|
type RecordingSession,
|
||||||
type SessionRecordingMetadata,
|
|
||||||
} from "./recorder/sessionRecording.js";
|
} from "./recorder/sessionRecording.js";
|
||||||
import { createSpeakingHandler } from "./recorder/speakingHandler.js";
|
import { createSpeakingHandler } from "./recorder/speakingHandler.js";
|
||||||
|
|
||||||
@@ -141,7 +140,7 @@ export async function startRecording(
|
|||||||
activeSessions,
|
activeSessions,
|
||||||
recordingsDir,
|
recordingsDir,
|
||||||
pcmSender: _pcmWsClient
|
pcmSender: _pcmWsClient
|
||||||
? (pcm, userId) => _pcmWsClient!.sendPcm(userId, pcm)
|
? (pcm, userId) => _pcmWsClient?.sendPcm(userId, pcm)
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ 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[] = [];
|
||||||
private draining = false;
|
|
||||||
/** 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 */
|
||||||
@@ -176,7 +175,7 @@ export class VoiceTransmitter {
|
|||||||
|
|
||||||
logger.info("Voice transmitter started");
|
logger.info("Voice transmitter started");
|
||||||
} finally {
|
} finally {
|
||||||
release!();
|
release?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +226,7 @@ export class VoiceTransmitter {
|
|||||||
discordPlayer.stop("browser-bridge");
|
discordPlayer.stop("browser-bridge");
|
||||||
logger.info("Voice transmitter stopped");
|
logger.info("Voice transmitter stopped");
|
||||||
} finally {
|
} finally {
|
||||||
release!();
|
release?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AppError } from "@bete/shared/errors";
|
import { AppError } from "@bete/shared/errors";
|
||||||
import { createChildLogger } from "@bete/shared/logger";
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
import { getVoiceConnection, type VoiceConnection } from "@discordjs/voice";
|
import type { VoiceConnection } from "@discordjs/voice";
|
||||||
import type { Client, Guild, VoiceChannel } from "discord.js-selfbot-v13";
|
import type { Client, Guild, VoiceChannel } from "discord.js-selfbot-v13";
|
||||||
import { discordPlayer } from "./player.js";
|
import { discordPlayer } from "./player.js";
|
||||||
import { startRecording, stopRecording } from "./recorder.js";
|
import { startRecording, stopRecording } from "./recorder.js";
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createChildLogger } from "@bete/shared/logger";
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
import type { AppConfig as GatewayConfig } from "../../shared/config/config.js";
|
|
||||||
import { config } from "../../shared/config/config.js";
|
import { config } from "../../shared/config/config.js";
|
||||||
|
|
||||||
const logger = createChildLogger("webhook-notifier");
|
const logger = createChildLogger("webhook-notifier");
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { pgAttachmentsTable, pgMessagesTable } from "@bete/shared";
|
|||||||
import {
|
import {
|
||||||
bigint as pgBigint,
|
bigint as pgBigint,
|
||||||
boolean as pgBoolean,
|
boolean as pgBoolean,
|
||||||
foreignKey as pgForeignKey,
|
|
||||||
index as pgIndex,
|
index as pgIndex,
|
||||||
integer as pgInteger,
|
integer as pgInteger,
|
||||||
jsonb as pgJsonb,
|
jsonb as pgJsonb,
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Loader2, RefreshCw } from "lucide-react";
|
import { Loader2, RefreshCw } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { voiceApi } from "@/lib/api";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useAppConfig } from "@/lib/hooks/use-config";
|
|
||||||
import type { Guild } from "@/lib/types";
|
|
||||||
import { DashboardPanel } from "@/features/dashboard/dashboard-panel";
|
import { DashboardPanel } from "@/features/dashboard/dashboard-panel";
|
||||||
import { LivePanel } from "@/features/live/live-panel";
|
import { LivePanel } from "@/features/live/live-panel";
|
||||||
import { MessagesPanel } from "@/features/messages/messages-panel";
|
import { MessagesPanel } from "@/features/messages/messages-panel";
|
||||||
|
import { voiceApi } from "@/lib/api";
|
||||||
|
import { useAppConfig } from "@/lib/hooks/use-config";
|
||||||
|
import type { Guild } from "@/lib/types";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -45,12 +45,17 @@ export default function DashboardPage() {
|
|||||||
if (!cancelled) setGuilds(g);
|
if (!cancelled) setGuilds(g);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (!cancelled) setGuildsError(err instanceof Error ? err.message : "Failed to load guilds");
|
if (!cancelled)
|
||||||
|
setGuildsError(
|
||||||
|
err instanceof Error ? err.message : "Failed to load guilds",
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setGuildsLoading(false);
|
if (!cancelled) setGuildsLoading(false);
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Resolve guild ID once config and guilds are loaded
|
// Resolve guild ID once config and guilds are loaded
|
||||||
@@ -80,9 +85,15 @@ export default function DashboardPage() {
|
|||||||
onRetry={() => {
|
onRetry={() => {
|
||||||
setGuildsLoading(true);
|
setGuildsLoading(true);
|
||||||
setGuildsError(null);
|
setGuildsError(null);
|
||||||
voiceApi.getGuilds().then(setGuilds).catch(
|
voiceApi
|
||||||
(err) => setGuildsError(err instanceof Error ? err.message : "Failed to load guilds"),
|
.getGuilds()
|
||||||
).finally(() => setGuildsLoading(false));
|
.then(setGuilds)
|
||||||
|
.catch((err) =>
|
||||||
|
setGuildsError(
|
||||||
|
err instanceof Error ? err.message : "Failed to load guilds",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.finally(() => setGuildsLoading(false));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -90,12 +101,8 @@ export default function DashboardPage() {
|
|||||||
{isReady ? (
|
{isReady ? (
|
||||||
<>
|
<>
|
||||||
{tab === "live" && <LivePanel />}
|
{tab === "live" && <LivePanel />}
|
||||||
{tab === "dashboard" && (
|
{tab === "dashboard" && <DashboardPanel guildId={selectedGuildId} />}
|
||||||
<DashboardPanel guildId={selectedGuildId} />
|
{tab === "messages" && <MessagesPanel guildId={selectedGuildId} />}
|
||||||
)}
|
|
||||||
{tab === "messages" && (
|
|
||||||
<MessagesPanel guildId={selectedGuildId} />
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16">
|
||||||
@@ -165,7 +172,10 @@ function GuildBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 rounded-lg border p-3">
|
<div className="flex items-center gap-2 rounded-lg border p-3">
|
||||||
<label htmlFor="guild-select" className="text-sm font-medium text-muted-foreground whitespace-nowrap">
|
<label
|
||||||
|
htmlFor="guild-select"
|
||||||
|
className="text-sm font-medium text-muted-foreground whitespace-nowrap"
|
||||||
|
>
|
||||||
Guild:
|
Guild:
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import Script from "next/script";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -29,16 +30,9 @@ export default function RootLayout({
|
|||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
<script
|
<Script id="theme-script" strategy="beforeInteractive">
|
||||||
dangerouslySetInnerHTML={{
|
{`try{const t=localStorage.getItem('theme')||'dark';document.documentElement.classList.add(t)}catch(e){}`}
|
||||||
__html: `
|
</Script>
|
||||||
try {
|
|
||||||
const theme = localStorage.getItem('theme') || 'dark';
|
|
||||||
document.documentElement.classList.add(theme);
|
|
||||||
} catch(e) {}
|
|
||||||
`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</head>
|
</head>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<body className="min-h-full flex flex-col">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Moon, Sun, Wifi, WifiOff } from "lucide-react";
|
import { AlertCircle, Moon, Sun, Wifi, WifiOff } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
|
||||||
@@ -37,6 +37,11 @@ export function Header() {
|
|||||||
<Wifi className="size-3 text-yellow-500" />
|
<Wifi className="size-3 text-yellow-500" />
|
||||||
<span className="hidden sm:inline">Connecting</span>
|
<span className="hidden sm:inline">Connecting</span>
|
||||||
</>
|
</>
|
||||||
|
) : status === "error" ? (
|
||||||
|
<>
|
||||||
|
<AlertCircle className="size-3 text-destructive" />
|
||||||
|
<span className="hidden sm:inline">Error</span>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<WifiOff className="size-3 text-destructive" />
|
<WifiOff className="size-3 text-destructive" />
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useRouter } from "next/navigation";
|
import { type TabId, tabs } from "@/lib/tabs";
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: "messages", label: "Messages", icon: MessageSquare },
|
|
||||||
{ id: "live", label: "Live", icon: Radio },
|
|
||||||
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type TabId = (typeof tabs)[number]["id"];
|
|
||||||
|
|
||||||
export function MobileTabBar({ activeTab }: { activeTab: TabId }) {
|
export function MobileTabBar({ activeTab }: { activeTab: TabId }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="md:hidden fixed bottom-0 inset-x-0 z-10 border-t bg-background">
|
<nav className="md:hidden fixed bottom-0 inset-x-0 z-10 border-t bg-background">
|
||||||
@@ -21,7 +14,11 @@ export function MobileTabBar({ activeTab }: { activeTab: TabId }) {
|
|||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.push(`/dashboard?tab=${id}`)}
|
onClick={() => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set("tab", id);
|
||||||
|
router.push(`/dashboard?${params}`);
|
||||||
|
}}
|
||||||
data-active={activeTab === id ? "" : undefined}
|
data-active={activeTab === id ? "" : undefined}
|
||||||
className="flex-1 flex flex-col items-center gap-0.5 py-2 text-xs font-medium text-muted-foreground data-[active]:text-primary transition-colors"
|
className="flex-1 flex flex-col items-center gap-0.5 py-2 text-xs font-medium text-muted-foreground data-[active]:text-primary transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react";
|
import { Radio } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { type TabId, tabs } from "@/lib/tabs";
|
||||||
const tabs = [
|
|
||||||
{ id: "messages", label: "Messages", icon: MessageSquare },
|
|
||||||
{ id: "live", label: "Live", icon: Radio },
|
|
||||||
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type TabId = (typeof tabs)[number]["id"];
|
|
||||||
|
|
||||||
export function Sidebar({ activeTab }: { activeTab: TabId }) {
|
export function Sidebar({ activeTab }: { activeTab: TabId }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const handleTabClick = (tabId: TabId) => {
|
const handleTabClick = (tabId: TabId) => {
|
||||||
router.push(`/dashboard?tab=${tabId}`);
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set("tab", tabId);
|
||||||
|
router.push(`/dashboard?${params}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { dashboardApi } from "@/lib/api";
|
import { dashboardApi } from "@/lib/api";
|
||||||
import type {
|
import type {
|
||||||
@@ -123,7 +124,7 @@ export function DashboardPanel({ guildId }: { guildId: string }) {
|
|||||||
|
|
||||||
// ── Stats View ────────────────────────────────────────────
|
// ── Stats View ────────────────────────────────────────────
|
||||||
|
|
||||||
function StatsView({ onNavigate }: { onNavigate: (view: View) => void }) {
|
function StatsView(_props: { onNavigate: (view: View) => void }) {
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -166,8 +167,8 @@ function StatsView({ onNavigate }: { onNavigate: (view: View) => void }) {
|
|||||||
{/* Metric cards */}
|
{/* Metric cards */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
{Array.from({ length: 8 }, (_, i) => `stat-sk-${i}`).map((key) => (
|
||||||
<div key={i} className="rounded-lg border p-4 space-y-2">
|
<div key={key} className="rounded-lg border p-4 space-y-2">
|
||||||
<div className="h-3 w-16 bg-muted rounded animate-pulse" />
|
<div className="h-3 w-16 bg-muted rounded animate-pulse" />
|
||||||
<div className="h-8 w-20 bg-muted rounded animate-pulse" />
|
<div className="h-8 w-20 bg-muted rounded animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
@@ -339,8 +340,8 @@ function UsersView({
|
|||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }, (_, i) => `user-sk-${i}`).map((key) => (
|
||||||
<div key={i} className="rounded-lg border p-4 space-y-2">
|
<div key={key} className="rounded-lg border p-4 space-y-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="size-10 rounded-full bg-muted animate-pulse" />
|
<div className="size-10 rounded-full bg-muted animate-pulse" />
|
||||||
<div className="flex-1 space-y-1">
|
<div className="flex-1 space-y-1">
|
||||||
@@ -362,9 +363,11 @@ function UsersView({
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="size-10 shrink-0 rounded-full bg-muted flex items-center justify-center text-sm font-medium overflow-hidden">
|
<div className="size-10 shrink-0 rounded-full bg-muted flex items-center justify-center text-sm font-medium overflow-hidden">
|
||||||
{user.avatar_url ? (
|
{user.avatar_url ? (
|
||||||
<img
|
<Image
|
||||||
src={user.avatar_url}
|
src={user.avatar_url}
|
||||||
alt=""
|
alt=""
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
className="size-full object-cover"
|
className="size-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -407,21 +410,24 @@ function ChannelsView({
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
const fetchChannels = useCallback(async (searchQuery?: string) => {
|
const fetchChannels = useCallback(
|
||||||
setLoading(true);
|
async (searchQuery?: string) => {
|
||||||
try {
|
setLoading(true);
|
||||||
const result = await dashboardApi.listChannels(
|
try {
|
||||||
20,
|
const result = await dashboardApi.listChannels(
|
||||||
searchQuery,
|
20,
|
||||||
guildId || undefined,
|
searchQuery,
|
||||||
);
|
guildId || undefined,
|
||||||
setChannels(result.data);
|
);
|
||||||
} catch {
|
setChannels(result.data);
|
||||||
// ignore
|
} catch {
|
||||||
} finally {
|
// ignore
|
||||||
setLoading(false);
|
} finally {
|
||||||
}
|
setLoading(false);
|
||||||
}, [guildId]);
|
}
|
||||||
|
},
|
||||||
|
[guildId],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchChannels();
|
fetchChannels();
|
||||||
@@ -450,8 +456,8 @@ function ChannelsView({
|
|||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }, (_, i) => `ch-sk-${i}`).map((key) => (
|
||||||
<div key={i} className="rounded-lg border p-4 space-y-2">
|
<div key={key} className="rounded-lg border p-4 space-y-2">
|
||||||
<div className="h-4 w-32 bg-muted rounded animate-pulse" />
|
<div className="h-4 w-32 bg-muted rounded animate-pulse" />
|
||||||
<div className="h-3 w-24 bg-muted rounded animate-pulse" />
|
<div className="h-3 w-24 bg-muted rounded animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
@@ -517,9 +523,11 @@ function UserDetailView({
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="size-16 rounded-full bg-muted flex items-center justify-center text-xl font-medium overflow-hidden">
|
<div className="size-16 rounded-full bg-muted flex items-center justify-center text-xl font-medium overflow-hidden">
|
||||||
{user.avatar_url ? (
|
{user.avatar_url ? (
|
||||||
<img
|
<Image
|
||||||
src={user.avatar_url}
|
src={user.avatar_url}
|
||||||
alt=""
|
alt=""
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
className="size-full object-cover"
|
className="size-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -541,7 +549,10 @@ function UserDetailView({
|
|||||||
value={user.flagged_count}
|
value={user.flagged_count}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
/>
|
/>
|
||||||
<DetailStat label="Clean Streak" value={user.clean_message_streak} />
|
<DetailStat
|
||||||
|
label="Clean Streak"
|
||||||
|
value={user.clean_message_streak ?? 0}
|
||||||
|
/>
|
||||||
<DetailStat
|
<DetailStat
|
||||||
label="Trust Score"
|
label="Trust Score"
|
||||||
value={user.trust_score ?? 0}
|
value={user.trust_score ?? 0}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Volume2,
|
Volume2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { recordingsApi, voiceApi } from "@/lib/api";
|
import { recordingsApi, voiceApi } from "@/lib/api";
|
||||||
import type {
|
import type {
|
||||||
@@ -104,7 +105,7 @@ export function LivePanel() {
|
|||||||
const unsubSpeaker = ws.on("voice_active_user", (user) => {
|
const unsubSpeaker = ws.on("voice_active_user", (user) => {
|
||||||
const speaker = user as ActiveSpeaker;
|
const speaker = user as ActiveSpeaker;
|
||||||
setSpeakers((prev) => {
|
setSpeakers((prev) => {
|
||||||
const existing = prev.findIndex((s) => s.user_id === speaker.user_id);
|
const existing = prev.findIndex((s) => s.userId === speaker.userId);
|
||||||
if (existing >= 0) {
|
if (existing >= 0) {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
next[existing] = speaker;
|
next[existing] = speaker;
|
||||||
@@ -307,7 +308,7 @@ export function LivePanel() {
|
|||||||
.filter((s) => s.speaking)
|
.filter((s) => s.speaking)
|
||||||
.map((s) => (
|
.map((s) => (
|
||||||
<div
|
<div
|
||||||
key={s.user_id}
|
key={s.userId}
|
||||||
className="flex items-center gap-2 rounded-full border bg-muted/50 px-3 py-1.5"
|
className="flex items-center gap-2 rounded-full border bg-muted/50 px-3 py-1.5"
|
||||||
>
|
>
|
||||||
<span className="relative flex size-2">
|
<span className="relative flex size-2">
|
||||||
@@ -354,9 +355,11 @@ export function LivePanel() {
|
|||||||
<p className="text-xs text-muted-foreground">Now Playing</p>
|
<p className="text-xs text-muted-foreground">Now Playing</p>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{mediaState.current.thumbnailUrl && (
|
{mediaState.current.thumbnailUrl && (
|
||||||
<img
|
<Image
|
||||||
src={mediaState.current.thumbnailUrl}
|
src={mediaState.current.thumbnailUrl}
|
||||||
alt=""
|
alt=""
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
className="size-12 rounded object-cover"
|
className="size-12 rounded object-cover"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -120,9 +120,9 @@ export function MascotChatbot() {
|
|||||||
Ask me anything about the server!
|
Ask me anything about the server!
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{messages.map((msg, i) => (
|
{messages.map((msg, _i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={msg.timestamp + msg.role}
|
||||||
className={`flex items-start gap-2 ${
|
className={`flex items-start gap-2 ${
|
||||||
msg.role === "user" ? "flex-row-reverse" : ""
|
msg.role === "user" ? "flex-row-reverse" : ""
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { messagesApi, voiceApi } from "@/lib/api";
|
import { messagesApi, voiceApi } from "@/lib/api";
|
||||||
import type { AttachmentRecord, Channel, MessageRecord } from "@/lib/types";
|
import type { AttachmentRecord, Channel, MessageRecord } from "@/lib/types";
|
||||||
@@ -26,7 +27,7 @@ export function MessagesPanel({ guildId }: { guildId: string }) {
|
|||||||
const [searchResults, setSearchResults] = useState<MessageRecord[] | null>(
|
const [searchResults, setSearchResults] = useState<MessageRecord[] | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [searching, setSearching] = useState(false);
|
const [_searching, setSearching] = useState(false);
|
||||||
const [viewTab, setViewTab] = useState<"all" | "images" | "review">("all");
|
const [viewTab, setViewTab] = useState<"all" | "images" | "review">("all");
|
||||||
const [imageMessages, setImageMessages] = useState<MessageRecord[]>([]);
|
const [imageMessages, setImageMessages] = useState<MessageRecord[]>([]);
|
||||||
const [reviewMessages, setReviewMessages] = useState<MessageRecord[]>([]);
|
const [reviewMessages, setReviewMessages] = useState<MessageRecord[]>([]);
|
||||||
@@ -42,22 +43,11 @@ export function MessagesPanel({ guildId }: { guildId: string }) {
|
|||||||
|
|
||||||
const ws = useWebSocket();
|
const ws = useWebSocket();
|
||||||
|
|
||||||
// ── Guild placeholder (after all hooks) ───────────────────
|
// ── Data-fetching side effects (all hooks before any early return) ──
|
||||||
if (!guildId) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
||||||
<AlertCircle className="size-8 text-muted-foreground mb-2" />
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No guild selected. Select a guild above to view messages.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Data-fetching side effects (guildId guaranteed non-empty) ──
|
|
||||||
|
|
||||||
// Fetch available text channels for filtering
|
// Fetch available text channels for filtering
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!guildId) return;
|
||||||
voiceApi
|
voiceApi
|
||||||
.getTextChannels(guildId)
|
.getTextChannels(guildId)
|
||||||
.then(setChannels)
|
.then(setChannels)
|
||||||
@@ -66,6 +56,7 @@ export function MessagesPanel({ guildId }: { guildId: string }) {
|
|||||||
|
|
||||||
// Fetch initial messages
|
// Fetch initial messages
|
||||||
const fetchMessages = useCallback(async () => {
|
const fetchMessages = useCallback(async () => {
|
||||||
|
if (!guildId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -86,6 +77,7 @@ export function MessagesPanel({ guildId }: { guildId: string }) {
|
|||||||
|
|
||||||
// Fetch image messages
|
// Fetch image messages
|
||||||
const fetchImages = useCallback(async () => {
|
const fetchImages = useCallback(async () => {
|
||||||
|
if (!guildId) return;
|
||||||
try {
|
try {
|
||||||
const result = await messagesApi.getImages(guildId, 50);
|
const result = await messagesApi.getImages(guildId, 50);
|
||||||
setImageMessages(result.data);
|
setImageMessages(result.data);
|
||||||
@@ -118,6 +110,7 @@ export function MessagesPanel({ guildId }: { guildId: string }) {
|
|||||||
|
|
||||||
// WS subscription for real-time message updates
|
// WS subscription for real-time message updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!guildId) return;
|
||||||
const unsubCreated = ws.on("message_created", (msg) => {
|
const unsubCreated = ws.on("message_created", (msg) => {
|
||||||
setMessages((prev) => [msg as MessageRecord, ...prev]);
|
setMessages((prev) => [msg as MessageRecord, ...prev]);
|
||||||
});
|
});
|
||||||
@@ -147,7 +140,7 @@ export function MessagesPanel({ guildId }: { guildId: string }) {
|
|||||||
unsubDeleted();
|
unsubDeleted();
|
||||||
unsubAnalyzed();
|
unsubAnalyzed();
|
||||||
};
|
};
|
||||||
}, [ws]);
|
}, [ws, guildId]);
|
||||||
|
|
||||||
// Search handler
|
// Search handler
|
||||||
const handleSearch = useCallback(async () => {
|
const handleSearch = useCallback(async () => {
|
||||||
@@ -326,8 +319,8 @@ export function MessagesPanel({ guildId }: { guildId: string }) {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
{Array.from({ length: 8 }, (_, i) => `msg-sk-${i}`).map((key) => (
|
||||||
<div key={i} className="flex gap-3 rounded-lg border p-4">
|
<div key={key} className="flex gap-3 rounded-lg border p-4">
|
||||||
<div className="size-8 shrink-0 rounded-full bg-muted animate-pulse" />
|
<div className="size-8 shrink-0 rounded-full bg-muted animate-pulse" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<div className="h-4 w-32 bg-muted rounded animate-pulse" />
|
<div className="h-4 w-32 bg-muted rounded animate-pulse" />
|
||||||
@@ -441,9 +434,11 @@ export function MessagesPanel({ guildId }: { guildId: string }) {
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="size-10 shrink-0 rounded-full bg-muted flex items-center justify-center text-sm font-medium overflow-hidden">
|
<div className="size-10 shrink-0 rounded-full bg-muted flex items-center justify-center text-sm font-medium overflow-hidden">
|
||||||
{detailMessage.avatar_url ? (
|
{detailMessage.avatar_url ? (
|
||||||
<img
|
<Image
|
||||||
src={detailMessage.avatar_url}
|
src={detailMessage.avatar_url}
|
||||||
alt=""
|
alt=""
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
className="size-full object-cover"
|
className="size-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -649,9 +644,11 @@ function MessageCard({
|
|||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div className="size-8 shrink-0 rounded-full bg-muted flex items-center justify-center text-xs font-medium overflow-hidden">
|
<div className="size-8 shrink-0 rounded-full bg-muted flex items-center justify-center text-xs font-medium overflow-hidden">
|
||||||
{msg.avatar_url ? (
|
{msg.avatar_url ? (
|
||||||
<img
|
<Image
|
||||||
src={msg.avatar_url}
|
src={msg.avatar_url}
|
||||||
alt=""
|
alt=""
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
className="size-full object-cover"
|
className="size-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -735,7 +732,7 @@ function MessageCard({
|
|||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-primary"
|
className="h-full rounded-full bg-primary"
|
||||||
style={{
|
style={{
|
||||||
width: msg.ai_confidence * 100 + "%",
|
width: `${msg.ai_confidence * 100}%`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const dashboardApi = {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (limit) params.set("limit", String(limit));
|
if (limit) params.set("limit", String(limit));
|
||||||
if (search) params.set("search", search);
|
if (search) params.set("search", search);
|
||||||
|
// Backend reads req.query.guild_id (snake_case) — see createDashboardRouter in dashboard.routes.ts
|
||||||
if (guildId) params.set("guild_id", guildId);
|
if (guildId) params.set("guild_id", guildId);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return api.get<PaginatedChannels>(
|
return api.get<PaginatedChannels>(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const messagesApi = {
|
|||||||
channelId?: string,
|
channelId?: string,
|
||||||
cursor?: string,
|
cursor?: string,
|
||||||
) => {
|
) => {
|
||||||
|
// Backend messageQuerySchema expects camelCase guildId (see messages.schema.ts)
|
||||||
const params = new URLSearchParams({ guildId });
|
const params = new URLSearchParams({ guildId });
|
||||||
if (limit) params.set("limit", String(limit));
|
if (limit) params.set("limit", String(limit));
|
||||||
if (channelId) params.set("channelId", channelId);
|
if (channelId) params.set("channelId", channelId);
|
||||||
@@ -31,6 +32,7 @@ export const messagesApi = {
|
|||||||
api.get<MessageRecord>(`/api/messages/detail/${id}`),
|
api.get<MessageRecord>(`/api/messages/detail/${id}`),
|
||||||
|
|
||||||
getImages: (guildId: string, limit?: number) => {
|
getImages: (guildId: string, limit?: number) => {
|
||||||
|
// Backend reads req.query.guildId (camelCase) — see handleGetImageMessages in messages.controller.ts
|
||||||
const params = new URLSearchParams({ guildId });
|
const params = new URLSearchParams({ guildId });
|
||||||
if (limit) params.set("limit", String(limit));
|
if (limit) params.set("limit", String(limit));
|
||||||
return api.get<{ data: MessageRecord[]; nextCursor: string | null }>(
|
return api.get<{ data: MessageRecord[]; nextCursor: string | null }>(
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react";
|
||||||
|
|
||||||
|
export const tabs = [
|
||||||
|
{ id: "messages", label: "Messages", icon: MessageSquare },
|
||||||
|
{ id: "live", label: "Live", icon: Radio },
|
||||||
|
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type TabId = (typeof tabs)[number]["id"];
|
||||||
@@ -37,13 +37,13 @@ export interface DashboardUser {
|
|||||||
flagged_count: number;
|
flagged_count: number;
|
||||||
last_message_at?: number | null;
|
last_message_at?: number | null;
|
||||||
trust_score?: number | null;
|
trust_score?: number | null;
|
||||||
clean_message_streak?: number;
|
clean_message_streak?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardUserDetail extends DashboardUser {
|
export interface DashboardUserDetail extends DashboardUser {
|
||||||
last_analyzed_at?: number | null;
|
last_analyzed_at?: number | null;
|
||||||
clean_message_streak: number;
|
clean_message_streak: number | null;
|
||||||
total_infractions: number;
|
total_infractions: number | null;
|
||||||
clean_count: number;
|
clean_count: number;
|
||||||
recent_messages: MessageRecord[];
|
recent_messages: MessageRecord[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
export interface Guild {
|
export interface Guild {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
icon?: string | null;
|
icon: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Channel {
|
export interface Channel {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type?: string | null; // "voice" | "text"
|
type: "voice" | "text";
|
||||||
parent_id?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shape of the /api/config response (camelCase keys from backend). */
|
/** Shape of the /api/config response (camelCase keys from backend). */
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export type MediaMode = "music" | "screen";
|
|||||||
export interface MediaItem {
|
export interface MediaItem {
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
source: string;
|
source: string;
|
||||||
title?: string | null;
|
title: string;
|
||||||
mode?: MediaMode | null;
|
mode?: MediaMode | null;
|
||||||
durationMs?: number | null;
|
durationMs?: number | null;
|
||||||
thumbnailUrl?: string | null;
|
thumbnailUrl?: string | null;
|
||||||
@@ -12,6 +12,6 @@ export interface MediaItem {
|
|||||||
export interface MediaState {
|
export interface MediaState {
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
musicVolume: number;
|
musicVolume: number;
|
||||||
current?: MediaItem | null;
|
current: MediaItem | null;
|
||||||
queue: MediaItem[];
|
queue: MediaItem[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,12 +82,14 @@ export interface MessageRecord {
|
|||||||
channel_id: string;
|
channel_id: string;
|
||||||
thread_id?: string | null;
|
thread_id?: string | null;
|
||||||
reference_message_id?: string | null;
|
reference_message_id?: string | null;
|
||||||
|
reference_channel_id?: string | null;
|
||||||
|
reference_guild_id?: string | null;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
username: string;
|
username: string;
|
||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
content: string;
|
content: string;
|
||||||
edited_content?: string | null;
|
edited_content?: string | null;
|
||||||
type: string; // "text" | "edited" | "deleted"
|
type: "text" | "edited" | "deleted";
|
||||||
is_reply?: boolean | null;
|
is_reply?: boolean | null;
|
||||||
is_forward?: boolean | null;
|
is_forward?: boolean | null;
|
||||||
is_crosspost?: boolean | null;
|
is_crosspost?: boolean | null;
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ export interface VoiceRecording {
|
|||||||
channel_name?: string | null;
|
channel_name?: string | null;
|
||||||
filename: string;
|
filename: string;
|
||||||
size_bytes: number;
|
size_bytes: number;
|
||||||
|
duration_bytes: number;
|
||||||
download_url?: string | null;
|
download_url?: string | null;
|
||||||
upload_status: string;
|
upload_status: string;
|
||||||
upload_error?: string | null;
|
upload_error?: string | null;
|
||||||
transcription?: string | null;
|
|
||||||
created_at: number;
|
created_at: number;
|
||||||
uploaded_at?: number | null;
|
uploaded_at?: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ export interface VoiceStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveSpeaker {
|
export interface ActiveSpeaker {
|
||||||
id?: string | null;
|
userId: string;
|
||||||
user_id: string;
|
|
||||||
username: string;
|
username: string;
|
||||||
avatar?: string | null;
|
avatar?: string | null;
|
||||||
speaking: boolean;
|
speaking: boolean;
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ export interface WsEventMap {
|
|||||||
voice_recording_stopped: unknown;
|
voice_recording_stopped: unknown;
|
||||||
voice_recording_uploaded: VoiceRecording;
|
voice_recording_uploaded: VoiceRecording;
|
||||||
voice_active_user: ActiveSpeaker;
|
voice_active_user: ActiveSpeaker;
|
||||||
voice_pcm_data: { userId: string; pcm: string };
|
/** NOT delivered as JSON — arrives only via onPcm() binary handler as PcmChunk */
|
||||||
|
voice_pcm_data: never;
|
||||||
voice_analyzed: unknown;
|
voice_analyzed: unknown;
|
||||||
analysis_queue_status: unknown;
|
analysis_queue_status: unknown;
|
||||||
reaction_added: unknown;
|
reaction_added: unknown;
|
||||||
|
|||||||
Reference in New Issue
Block a user