From 2cc3d73edc91c49fb66ea4dc8ed54ed5ce114134 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Fri, 27 Feb 2026 23:03:04 -0500 Subject: [PATCH 01/27] feat(conversations): add flagged_messages migration --- migrations/012_flagged_messages.cjs | 42 +++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 migrations/012_flagged_messages.cjs diff --git a/migrations/012_flagged_messages.cjs b/migrations/012_flagged_messages.cjs new file mode 100644 index 00000000..dbe22377 --- /dev/null +++ b/migrations/012_flagged_messages.cjs @@ -0,0 +1,42 @@ +/** + * Migration 012 — Flagged Messages + * Creates the flagged_messages table for tracking problematic AI responses + * within conversation threads. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/34 + */ + +'use strict'; + +/** + * @param {import('pg').Pool} pool + */ +async function up(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS flagged_messages ( + id SERIAL PRIMARY KEY, + guild_id TEXT NOT NULL, + conversation_first_id INTEGER NOT NULL, + message_id INTEGER NOT NULL REFERENCES conversations(id), + flagged_by TEXT NOT NULL, + reason TEXT NOT NULL, + notes TEXT, + status TEXT DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'dismissed')), + resolved_by TEXT, + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + ); + `); + + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_flagged_messages_guild + ON flagged_messages(guild_id); + `); + + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_flagged_messages_status + ON flagged_messages(guild_id, status); + `); +} + +module.exports = { up }; From 85d97b2d56a3b0cfacabd6b800780e3f948d63f9 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Fri, 27 Feb 2026 23:04:15 -0500 Subject: [PATCH 02/27] feat(conversations): add API endpoints for list, detail, search, flag, stats --- src/api/routes/conversations.js | 597 ++++++++++++++++++++++++++++++++ 1 file changed, 597 insertions(+) create mode 100644 src/api/routes/conversations.js diff --git a/src/api/routes/conversations.js b/src/api/routes/conversations.js new file mode 100644 index 00000000..20ac2cb4 --- /dev/null +++ b/src/api/routes/conversations.js @@ -0,0 +1,597 @@ +/** + * Conversation Routes + * Endpoints for viewing, searching, and flagging AI conversations. + * + * Mounted at /api/v1/guilds/:id/conversations + */ + +import { Router } from 'express'; +import { error as logError, info, warn } from '../../logger.js'; +import { rateLimit } from '../middleware/rateLimit.js'; +import { requireGuildAdmin, validateGuild } from './guilds.js'; + +const router = Router({ mergeParams: true }); + +/** Rate limiter: 60 requests / 1 min per IP */ +const conversationsRateLimit = rateLimit({ windowMs: 60 * 1000, max: 60 }); + +/** Conversation grouping gap in minutes */ +const CONVERSATION_GAP_MINUTES = 15; + +/** + * Parse pagination query params with defaults and capping. + * + * @param {Object} query - Express req.query + * @returns {{ page: number, limit: number, offset: number }} + */ +function parsePagination(query) { + let page = Number.parseInt(query.page, 10) || 1; + let limit = Number.parseInt(query.limit, 10) || 25; + if (page < 1) page = 1; + if (limit < 1) limit = 1; + if (limit > 100) limit = 100; + const offset = (page - 1) * limit; + return { page, limit, offset }; +} + +/** + * Estimate token count from text content. + * Rough heuristic: ~4 characters per token. + * + * @param {string} content + * @returns {number} + */ +function estimateTokens(content) { + if (!content) return 0; + return Math.ceil(content.length / 4); +} + +/** + * Group flat message rows into conversations based on channel_id + time gap. + * Messages in the same channel within CONVERSATION_GAP_MINUTES are grouped together. + * + * @param {Array} rows - Flat message rows sorted by created_at ASC + * @returns {Array} Grouped conversations + */ +export function groupMessagesIntoConversations(rows) { + if (!rows || rows.length === 0) return []; + + const gapMs = CONVERSATION_GAP_MINUTES * 60 * 1000; + const channelGroups = new Map(); + + for (const row of rows) { + const channelId = row.channel_id; + if (!channelGroups.has(channelId)) { + channelGroups.set(channelId, []); + } + channelGroups.get(channelId).push(row); + } + + const conversations = []; + + for (const [channelId, messages] of channelGroups) { + // Messages should already be sorted by created_at + let currentConvo = null; + + for (const msg of messages) { + const msgTime = new Date(msg.created_at).getTime(); + + if (!currentConvo || msgTime - currentConvo.lastTime > gapMs) { + // Start a new conversation + if (currentConvo) { + conversations.push(currentConvo); + } + currentConvo = { + id: msg.id, + channelId, + messages: [msg], + firstTime: msgTime, + lastTime: msgTime, + }; + } else { + currentConvo.messages.push(msg); + currentConvo.lastTime = msgTime; + } + } + + if (currentConvo) { + conversations.push(currentConvo); + } + } + + // Sort conversations by most recent first + conversations.sort((a, b) => b.lastTime - a.lastTime); + + return conversations; +} + +/** + * Build a conversation summary from grouped messages. + * + * @param {Object} convo - Grouped conversation object + * @param {import('discord.js').Guild} [guild] - Optional guild for channel name resolution + * @returns {Object} Conversation summary + */ +function buildConversationSummary(convo, guild) { + const participantMap = new Map(); + for (const msg of convo.messages) { + const key = `${msg.username || 'unknown'}-${msg.role}`; + if (!participantMap.has(key)) { + participantMap.set(key, { username: msg.username || 'unknown', role: msg.role }); + } + } + + const firstMsg = convo.messages[0]; + const preview = firstMsg?.content + ? firstMsg.content.slice(0, 100) + (firstMsg.content.length > 100 ? '…' : '') + : ''; + + const channelName = guild?.channels?.cache?.get(convo.channelId)?.name || convo.channelId; + + return { + id: convo.id, + channelId: convo.channelId, + channelName, + participants: Array.from(participantMap.values()), + messageCount: convo.messages.length, + firstMessageAt: new Date(convo.firstTime).toISOString(), + lastMessageAt: new Date(convo.lastTime).toISOString(), + preview, + }; +} + +// ─── GET / — List conversations (grouped) ───────────────────────────────────── + +/** + * GET / — List conversations grouped by channel + time proximity + * Query params: ?page=1&limit=25&search=&user=&channel=&from=&to= + */ +router.get( + '/', + conversationsRateLimit, + requireGuildAdmin, + validateGuild, + async (req, res) => { + const { dbPool } = req.app.locals; + if (!dbPool) { + return res.status(503).json({ error: 'Database not available' }); + } + + const { page, limit } = parsePagination(req.query); + const guildId = req.params.id; + + try { + // Build WHERE clauses + const whereParts = ['guild_id = $1']; + const values = [guildId]; + let paramIndex = 1; + + if (req.query.search && typeof req.query.search === 'string') { + paramIndex++; + whereParts.push(`content ILIKE $${paramIndex}`); + values.push(`%${req.query.search}%`); + } + + if (req.query.user && typeof req.query.user === 'string') { + paramIndex++; + whereParts.push(`username = $${paramIndex}`); + values.push(req.query.user); + } + + if (req.query.channel && typeof req.query.channel === 'string') { + paramIndex++; + whereParts.push(`channel_id = $${paramIndex}`); + values.push(req.query.channel); + } + + if (req.query.from && typeof req.query.from === 'string') { + const from = new Date(req.query.from); + if (!Number.isNaN(from.getTime())) { + paramIndex++; + whereParts.push(`created_at >= $${paramIndex}`); + values.push(from.toISOString()); + } + } + + if (req.query.to && typeof req.query.to === 'string') { + const to = new Date(req.query.to); + if (!Number.isNaN(to.getTime())) { + paramIndex++; + whereParts.push(`created_at <= $${paramIndex}`); + values.push(to.toISOString()); + } + } + + const whereClause = whereParts.join(' AND '); + + // Fetch all matching messages for grouping + // We need to fetch all to do proper time-based grouping, then paginate the result + const result = await dbPool.query( + `SELECT id, channel_id, role, content, username, created_at + FROM conversations + WHERE ${whereClause} + ORDER BY created_at ASC`, + values, + ); + + const allConversations = groupMessagesIntoConversations(result.rows); + const total = allConversations.length; + + // Paginate grouped conversations + const startIdx = (page - 1) * limit; + const paginatedConversations = allConversations.slice(startIdx, startIdx + limit); + + const conversations = paginatedConversations.map((convo) => + buildConversationSummary(convo, req.guild), + ); + + res.json({ conversations, total, page }); + } catch (err) { + logError('Failed to fetch conversations', { error: err.message, guild: guildId }); + res.status(500).json({ error: 'Failed to fetch conversations' }); + } + }, +); + +// ─── GET /stats — Conversation analytics ────────────────────────────────────── + +/** + * GET /stats — Conversation analytics + * Returns aggregate stats about conversations for the guild. + */ +router.get( + '/stats', + conversationsRateLimit, + requireGuildAdmin, + validateGuild, + async (req, res) => { + const { dbPool } = req.app.locals; + if (!dbPool) { + return res.status(503).json({ error: 'Database not available' }); + } + + const guildId = req.params.id; + + try { + const [totalResult, topUsersResult, dailyResult, tokenResult] = await Promise.all([ + dbPool.query( + 'SELECT COUNT(*)::int AS total_messages FROM conversations WHERE guild_id = $1', + [guildId], + ), + dbPool.query( + `SELECT username, COUNT(*)::int AS message_count + FROM conversations + WHERE guild_id = $1 AND username IS NOT NULL + GROUP BY username + ORDER BY message_count DESC + LIMIT 10`, + [guildId], + ), + dbPool.query( + `SELECT DATE(created_at) AS date, COUNT(*)::int AS count + FROM conversations + WHERE guild_id = $1 + GROUP BY DATE(created_at) + ORDER BY date DESC + LIMIT 30`, + [guildId], + ), + dbPool.query( + 'SELECT COALESCE(SUM(LENGTH(content)), 0)::bigint AS total_chars FROM conversations WHERE guild_id = $1', + [guildId], + ), + ]); + + const totalMessages = totalResult.rows[0]?.total_messages || 0; + const totalChars = Number(tokenResult.rows[0]?.total_chars || 0); + + // Fetch all messages for conversation counting + const allMsgsResult = await dbPool.query( + `SELECT id, channel_id, created_at + FROM conversations + WHERE guild_id = $1 + ORDER BY created_at ASC`, + [guildId], + ); + + const allConversations = groupMessagesIntoConversations(allMsgsResult.rows); + const totalConversations = allConversations.length; + const avgMessagesPerConversation = + totalConversations > 0 ? Math.round(totalMessages / totalConversations) : 0; + + res.json({ + totalConversations, + totalMessages, + avgMessagesPerConversation, + topUsers: topUsersResult.rows.map((r) => ({ + username: r.username, + messageCount: r.message_count, + })), + dailyActivity: dailyResult.rows.map((r) => ({ + date: r.date, + count: r.count, + })), + estimatedTokens: Math.ceil(totalChars / 4), + }); + } catch (err) { + logError('Failed to fetch conversation stats', { error: err.message, guild: guildId }); + res.status(500).json({ error: 'Failed to fetch conversation stats' }); + } + }, +); + +// ─── GET /flags — List flagged messages ─────────────────────────────────────── + +/** + * GET /flags — List flagged messages + * Query params: ?page=1&limit=25&status=open|resolved|dismissed + */ +router.get( + '/flags', + conversationsRateLimit, + requireGuildAdmin, + validateGuild, + async (req, res) => { + const { dbPool } = req.app.locals; + if (!dbPool) { + return res.status(503).json({ error: 'Database not available' }); + } + + const { page, limit, offset } = parsePagination(req.query); + const guildId = req.params.id; + + try { + const whereParts = ['fm.guild_id = $1']; + const values = [guildId]; + let paramIndex = 1; + + const validStatuses = ['open', 'resolved', 'dismissed']; + if (req.query.status && validStatuses.includes(req.query.status)) { + paramIndex++; + whereParts.push(`fm.status = $${paramIndex}`); + values.push(req.query.status); + } + + const whereClause = whereParts.join(' AND '); + + const [countResult, flagsResult] = await Promise.all([ + dbPool.query( + `SELECT COUNT(*)::int AS count FROM flagged_messages fm WHERE ${whereClause}`, + values, + ), + dbPool.query( + `SELECT fm.id, fm.guild_id, fm.conversation_first_id, fm.message_id, + fm.flagged_by, fm.reason, fm.notes, fm.status, + fm.resolved_by, fm.resolved_at, fm.created_at, + c.content AS message_content, c.role AS message_role, + c.username AS message_username + FROM flagged_messages fm + LEFT JOIN conversations c ON c.id = fm.message_id + WHERE ${whereClause} + ORDER BY fm.created_at DESC + LIMIT $${paramIndex + 1} OFFSET $${paramIndex + 2}`, + [...values, limit, offset], + ), + ]); + + res.json({ + flags: flagsResult.rows.map((r) => ({ + id: r.id, + guildId: r.guild_id, + conversationFirstId: r.conversation_first_id, + messageId: r.message_id, + flaggedBy: r.flagged_by, + reason: r.reason, + notes: r.notes, + status: r.status, + resolvedBy: r.resolved_by, + resolvedAt: r.resolved_at, + createdAt: r.created_at, + messageContent: r.message_content, + messageRole: r.message_role, + messageUsername: r.message_username, + })), + total: countResult.rows[0]?.count || 0, + page, + }); + } catch (err) { + logError('Failed to fetch flagged messages', { error: err.message, guild: guildId }); + res.status(500).json({ error: 'Failed to fetch flagged messages' }); + } + }, +); + +// ─── GET /:conversationId — Single conversation detail ──────────────────────── + +/** + * GET /:conversationId — Fetch all messages in a conversation for replay + * The conversationId is the ID of the first message in the conversation. + */ +router.get( + '/:conversationId', + conversationsRateLimit, + requireGuildAdmin, + validateGuild, + async (req, res) => { + const { dbPool } = req.app.locals; + if (!dbPool) { + return res.status(503).json({ error: 'Database not available' }); + } + + const guildId = req.params.id; + const conversationId = Number.parseInt(req.params.conversationId, 10); + + if (Number.isNaN(conversationId)) { + return res.status(400).json({ error: 'Invalid conversation ID' }); + } + + try { + // First, fetch the anchor message to get channel_id and created_at + const anchorResult = await dbPool.query( + `SELECT id, channel_id, created_at + FROM conversations + WHERE id = $1 AND guild_id = $2`, + [conversationId, guildId], + ); + + if (anchorResult.rows.length === 0) { + return res.status(404).json({ error: 'Conversation not found' }); + } + + const anchor = anchorResult.rows[0]; + const anchorTime = new Date(anchor.created_at).getTime(); + const gapMs = CONVERSATION_GAP_MINUTES * 60 * 1000; + + // Fetch all messages in the same channel around the anchor + const messagesResult = await dbPool.query( + `SELECT id, channel_id, role, content, username, created_at + FROM conversations + WHERE guild_id = $1 AND channel_id = $2 + ORDER BY created_at ASC`, + [guildId, anchor.channel_id], + ); + + // Group into conversations and find the one containing our anchor + const allConvos = groupMessagesIntoConversations(messagesResult.rows); + const targetConvo = allConvos.find((c) => c.id === conversationId); + + if (!targetConvo) { + return res.status(404).json({ error: 'Conversation not found' }); + } + + const messages = targetConvo.messages.map((msg) => ({ + id: msg.id, + role: msg.role, + content: msg.content, + username: msg.username, + createdAt: msg.created_at, + })); + + const totalChars = messages.reduce((sum, m) => sum + (m.content?.length || 0), 0); + const durationMs = targetConvo.lastTime - targetConvo.firstTime; + + // Fetch any flags for messages in this conversation + const messageIds = messages.map((m) => m.id); + const flagsResult = await dbPool.query( + `SELECT message_id, status FROM flagged_messages + WHERE guild_id = $1 AND message_id = ANY($2)`, + [guildId, messageIds], + ); + + const flaggedMessageIds = new Map( + flagsResult.rows.map((r) => [r.message_id, r.status]), + ); + + const enrichedMessages = messages.map((m) => ({ + ...m, + flagStatus: flaggedMessageIds.get(m.id) || null, + })); + + res.json({ + messages: enrichedMessages, + channelId: anchor.channel_id, + duration: Math.round(durationMs / 1000), + tokenEstimate: estimateTokens(messages.map((m) => m.content || '').join('')), + }); + } catch (err) { + logError('Failed to fetch conversation detail', { + error: err.message, + guild: guildId, + conversationId, + }); + res.status(500).json({ error: 'Failed to fetch conversation detail' }); + } + }, +); + +// ─── POST /:conversationId/flag — Flag a message ───────────────────────────── + +/** + * POST /:conversationId/flag — Flag a problematic AI response + * Body: { messageId: number, reason: string, notes?: string } + */ +router.post( + '/:conversationId/flag', + conversationsRateLimit, + requireGuildAdmin, + validateGuild, + async (req, res) => { + const { dbPool } = req.app.locals; + if (!dbPool) { + return res.status(503).json({ error: 'Database not available' }); + } + + const guildId = req.params.id; + const conversationId = Number.parseInt(req.params.conversationId, 10); + + if (Number.isNaN(conversationId)) { + return res.status(400).json({ error: 'Invalid conversation ID' }); + } + + const { messageId, reason, notes } = req.body || {}; + + if (!messageId || typeof messageId !== 'number') { + return res.status(400).json({ error: 'messageId is required and must be a number' }); + } + + if (!reason || typeof reason !== 'string' || reason.trim().length === 0) { + return res.status(400).json({ error: 'reason is required and must be a non-empty string' }); + } + + if (reason.length > 500) { + return res.status(400).json({ error: 'reason must not exceed 500 characters' }); + } + + if (notes && typeof notes !== 'string') { + return res.status(400).json({ error: 'notes must be a string' }); + } + + if (notes && notes.length > 2000) { + return res.status(400).json({ error: 'notes must not exceed 2000 characters' }); + } + + try { + // Verify the message exists and belongs to this guild + const msgCheck = await dbPool.query( + 'SELECT id FROM conversations WHERE id = $1 AND guild_id = $2', + [messageId, guildId], + ); + + if (msgCheck.rows.length === 0) { + return res.status(404).json({ error: 'Message not found' }); + } + + // Determine flagged_by from auth context + const flaggedBy = req.user?.userId || 'api-secret'; + + const insertResult = await dbPool.query( + `INSERT INTO flagged_messages (guild_id, conversation_first_id, message_id, flagged_by, reason, notes) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, status`, + [guildId, conversationId, messageId, flaggedBy, reason.trim(), notes?.trim() || null], + ); + + const flag = insertResult.rows[0]; + + info('Message flagged', { + guildId, + conversationId, + messageId, + flagId: flag.id, + flaggedBy, + }); + + res.status(201).json({ flagId: flag.id, status: flag.status }); + } catch (err) { + logError('Failed to flag message', { + error: err.message, + guild: guildId, + conversationId, + messageId, + }); + res.status(500).json({ error: 'Failed to flag message' }); + } + }, +); + +export default router; From 62dbfcf747d2230eb693c868e0030796944373a9 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Fri, 27 Feb 2026 23:04:28 -0500 Subject: [PATCH 03/27] feat(conversations): mount router in API index --- src/api/index.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/api/index.js b/src/api/index.js index c6f20518..aa549913 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -11,6 +11,7 @@ import guildsRouter from './routes/guilds.js'; import healthRouter from './routes/health.js'; import membersRouter from './routes/members.js'; import moderationRouter from './routes/moderation.js'; +import conversationsRouter from './routes/conversations.js'; import webhooksRouter from './routes/webhooks.js'; const router = Router(); @@ -28,6 +29,10 @@ router.use('/config', requireAuth(), configRouter); // (mounted before guilds to handle /:id/members/* before the basic guilds endpoint) router.use('/guilds', requireAuth(), membersRouter); +// Conversation routes — require API secret or OAuth2 JWT +// (mounted before guilds to handle /:id/conversations/* before the catch-all guild endpoint) +router.use('/guilds/:id/conversations', requireAuth(), conversationsRouter); + // Guild routes — require API secret or OAuth2 JWT router.use('/guilds', requireAuth(), guildsRouter); From 2258dbb986d17c087b7be7c15c9fe81a556b9b91 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Fri, 27 Feb 2026 23:06:19 -0500 Subject: [PATCH 04/27] feat(conversations): add conversation list and replay dashboard pages --- .../conversations/[conversationId]/page.tsx | 129 ++++++ web/src/app/dashboard/conversations/page.tsx | 438 ++++++++++++++++++ .../dashboard/conversation-replay.tsx | 294 ++++++++++++ web/src/components/layout/sidebar.tsx | 5 + 4 files changed, 866 insertions(+) create mode 100644 web/src/app/dashboard/conversations/[conversationId]/page.tsx create mode 100644 web/src/app/dashboard/conversations/page.tsx create mode 100644 web/src/components/dashboard/conversation-replay.tsx diff --git a/web/src/app/dashboard/conversations/[conversationId]/page.tsx b/web/src/app/dashboard/conversations/[conversationId]/page.tsx new file mode 100644 index 00000000..5fe6e8db --- /dev/null +++ b/web/src/app/dashboard/conversations/[conversationId]/page.tsx @@ -0,0 +1,129 @@ +'use client'; + +import { ArrowLeft, MessageSquare } from 'lucide-react'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import { useCallback, useEffect, useState } from 'react'; +import { + type ConversationMessage, + ConversationReplay, +} from '@/components/dashboard/conversation-replay'; +import { Button } from '@/components/ui/button'; + +interface ConversationDetailResponse { + messages: ConversationMessage[]; + channelId: string; + duration: number; + tokenEstimate: number; +} + +/** + * Conversation detail page — shows full chat replay with flag support. + */ +export default function ConversationDetailPage() { + const router = useRouter(); + const params = useParams(); + const searchParams = useSearchParams(); + const conversationId = params.conversationId as string; + const guildId = searchParams.get('guildId'); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchDetail = useCallback(async () => { + if (!guildId || !conversationId) return; + + setLoading(true); + setError(null); + + try { + const res = await fetch( + `/api/guilds/${encodeURIComponent(guildId)}/conversations/${encodeURIComponent(conversationId)}`, + ); + + if (res.status === 401) { + router.replace('/login'); + return; + } + if (res.status === 404) { + setError('Conversation not found'); + return; + } + if (!res.ok) { + throw new Error(`Failed to fetch conversation (${res.status})`); + } + + const result = (await res.json()) as ConversationDetailResponse; + setData(result); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch conversation'); + } finally { + setLoading(false); + } + }, [guildId, conversationId, router]); + + useEffect(() => { + void fetchDetail(); + }, [fetchDetail]); + + return ( +
+ {/* Header */} +
+ +
+

+ + Conversation Detail +

+ {data && ( +

+ Channel: {data.channelId} · {data.messages.length} messages +

+ )} +
+
+ + {/* Loading */} + {loading && ( +
+

Loading conversation...

+
+ )} + + {/* Error */} + {error && ( +
+ Error: {error} +
+ )} + + {/* No guild */} + {!guildId && !loading && ( +
+

+ No guild selected. Please navigate from the conversations list. +

+
+ )} + + {/* Replay */} + {data && guildId && ( + + )} +
+ ); +} diff --git a/web/src/app/dashboard/conversations/page.tsx b/web/src/app/dashboard/conversations/page.tsx new file mode 100644 index 00000000..4d6fa50a --- /dev/null +++ b/web/src/app/dashboard/conversations/page.tsx @@ -0,0 +1,438 @@ +'use client'; + +import { Calendar, Hash, MessageSquare, RefreshCw, Search, Users, X } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { useGuildSelection } from '@/hooks/use-guild-selection'; + +interface Participant { + username: string; + role: string; +} + +interface ConversationSummary { + id: number; + channelId: string; + channelName: string; + participants: Participant[]; + messageCount: number; + firstMessageAt: string; + lastMessageAt: string; + preview: string; +} + +interface ConversationsApiResponse { + conversations: ConversationSummary[]; + total: number; + page: number; +} + +interface Channel { + id: string; + name: string; + type: number; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function formatDuration(first: string, last: string): string { + const ms = new Date(last).getTime() - new Date(first).getTime(); + const seconds = Math.round(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const mins = Math.floor(seconds / 60); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + return `${hours}h ${mins % 60}m`; +} + +/** + * Conversation list page with search, filters, and pagination. + */ +export default function ConversationsPage() { + const router = useRouter(); + + const [conversations, setConversations] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [search, setSearch] = useState(''); + const [channelFilter, setChannelFilter] = useState(''); + const [channels, setChannels] = useState([]); + + // Debounce search + const searchTimerRef = useRef>(undefined); + const [debouncedSearch, setDebouncedSearch] = useState(''); + + const abortControllerRef = useRef(null); + const requestIdRef = useRef(0); + + useEffect(() => { + clearTimeout(searchTimerRef.current); + searchTimerRef.current = setTimeout(() => { + setDebouncedSearch(search); + setPage(1); + }, 300); + return () => clearTimeout(searchTimerRef.current); + }, [search]); + + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + const onGuildChange = useCallback(() => { + setConversations([]); + setTotal(0); + setPage(1); + setError(null); + setChannels([]); + }, []); + + const guildId = useGuildSelection({ onGuildChange }); + + const onUnauthorized = useCallback(() => router.replace('/login'), [router]); + + // Fetch channels for filter dropdown + useEffect(() => { + if (!guildId) return; + void (async () => { + try { + const res = await fetch( + `/api/guilds/${encodeURIComponent(guildId)}/channels`, + ); + if (res.ok) { + const data = (await res.json()) as Channel[]; + // Only show text channels (type 0) + setChannels(data.filter((ch) => ch.type === 0)); + } + } catch { + // Non-critical — channels filter just won't populate + } + })(); + }, [guildId]); + + // Fetch conversations + const fetchConversations = useCallback( + async (opts: { + guildId: string; + search: string; + channel: string; + page: number; + }) => { + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + const requestId = ++requestIdRef.current; + + setLoading(true); + setError(null); + + try { + const params = new URLSearchParams(); + params.set('page', String(opts.page)); + params.set('limit', '25'); + if (opts.search) params.set('search', opts.search); + if (opts.channel) params.set('channel', opts.channel); + + const res = await fetch( + `/api/guilds/${encodeURIComponent(opts.guildId)}/conversations?${params.toString()}`, + { signal: controller.signal }, + ); + + if (requestId !== requestIdRef.current) return; + + if (res.status === 401) { + onUnauthorized(); + return; + } + if (!res.ok) { + throw new Error(`Failed to fetch conversations (${res.status})`); + } + + const data = (await res.json()) as ConversationsApiResponse; + setConversations(data.conversations); + setTotal(data.total); + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return; + if (requestId !== requestIdRef.current) return; + setError(err instanceof Error ? err.message : 'Failed to fetch conversations'); + } finally { + if (requestId === requestIdRef.current) { + setLoading(false); + } + } + }, + [onUnauthorized], + ); + + useEffect(() => { + if (!guildId) return; + void fetchConversations({ + guildId, + search: debouncedSearch, + channel: channelFilter, + page, + }); + }, [guildId, debouncedSearch, channelFilter, page, fetchConversations]); + + const handleRefresh = useCallback(() => { + if (!guildId) return; + void fetchConversations({ + guildId, + search: debouncedSearch, + channel: channelFilter, + page, + }); + }, [guildId, fetchConversations, debouncedSearch, channelFilter, page]); + + const handleRowClick = useCallback( + (conversationId: number) => { + if (!guildId) return; + router.push( + `/dashboard/conversations/${conversationId}?guildId=${encodeURIComponent(guildId)}`, + ); + }, + [router, guildId], + ); + + const handleClearSearch = useCallback(() => { + setSearch(''); + setDebouncedSearch(''); + setPage(1); + }, []); + + const totalPages = Math.ceil(total / 25); + + return ( +
+ {/* Header */} +
+
+

+ + Conversations +

+

+ Browse, search, and replay AI conversations. +

+
+ + +
+ + {/* No guild selected */} + {!guildId && ( +
+

+ Select a server from the sidebar to view conversations. +

+
+ )} + + {/* Content */} + {guildId && ( + <> + {/* Filters */} +
+
+ + setSearch(e.target.value)} + aria-label="Search conversations" + /> + {search && ( + + )} +
+ + + + {total > 0 && ( + + {total.toLocaleString()} {total === 1 ? 'conversation' : 'conversations'} + + )} +
+ + {/* Error */} + {error && ( +
+ Error: {error} +
+ )} + + {/* Table */} + {conversations.length > 0 ? ( +
+ + + + Channel + Participants + Messages + Duration + Preview + Date + + + + {conversations.map((convo) => ( + handleRowClick(convo.id)} + > + +
+ + {convo.channelName} +
+
+ +
+ {convo.participants.slice(0, 3).map((p, i) => ( +
+ {p.username.slice(0, 2).toUpperCase()} +
+ ))} + {convo.participants.length > 3 && ( +
+ +{convo.participants.length - 3} +
+ )} +
+
+ + {convo.messageCount} + + + {formatDuration(convo.firstMessageAt, convo.lastMessageAt)} + + + {convo.preview} + + + {formatDate(convo.firstMessageAt)} + +
+ ))} +
+
+
+ ) : ( + !loading && ( +
+

+ {debouncedSearch || channelFilter + ? 'No conversations match your filters.' + : 'No conversations found.'} +

+
+ ) + )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} + +
+ + +
+
+ )} + + )} +
+ ); +} diff --git a/web/src/components/dashboard/conversation-replay.tsx b/web/src/components/dashboard/conversation-replay.tsx new file mode 100644 index 00000000..1ca0ab7f --- /dev/null +++ b/web/src/components/dashboard/conversation-replay.tsx @@ -0,0 +1,294 @@ +'use client'; + +import { AlertTriangle, Clock, ExternalLink, Flag, Hash, Zap } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { cn } from '@/lib/utils'; + +export interface ConversationMessage { + id: number; + role: 'user' | 'assistant' | 'system'; + content: string; + username: string; + createdAt: string; + flagStatus?: string | null; +} + +interface ConversationReplayProps { + messages: ConversationMessage[]; + channelId: string; + duration: number; + tokenEstimate: number; + guildId: string; + onFlagSubmitted?: () => void; +} + +function formatDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + if (mins < 60) return `${mins}m ${secs}s`; + const hours = Math.floor(mins / 60); + const remainMins = mins % 60; + return `${hours}h ${remainMins}m`; +} + +function formatTimestamp(iso: string): string { + return new Date(iso).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + }); +} + +function shouldShowTimestamp(current: string, previous: string | null): boolean { + if (!previous) return true; + const diff = new Date(current).getTime() - new Date(previous).getTime(); + return diff > 5 * 60 * 1000; // 5 minute gap +} + +/** + * Chat-style conversation replay component. + * User messages on the right (blue), assistant on the left (gray), system messages small/italic. + */ +export function ConversationReplay({ + messages, + channelId, + duration, + tokenEstimate, + guildId, + onFlagSubmitted, +}: ConversationReplayProps) { + const [flagDialogOpen, setFlagDialogOpen] = useState(false); + const [flagMessageId, setFlagMessageId] = useState(null); + const [flagReason, setFlagReason] = useState(''); + const [flagNotes, setFlagNotes] = useState(''); + const [flagSubmitting, setFlagSubmitting] = useState(false); + const [flagError, setFlagError] = useState(null); + + const conversationId = messages[0]?.id; + + const openFlagDialog = useCallback((messageId: number) => { + setFlagMessageId(messageId); + setFlagReason(''); + setFlagNotes(''); + setFlagError(null); + setFlagDialogOpen(true); + }, []); + + const submitFlag = useCallback(async () => { + if (!flagMessageId || !flagReason || !conversationId || !guildId) return; + + setFlagSubmitting(true); + setFlagError(null); + + try { + const res = await fetch( + `/api/guilds/${encodeURIComponent(guildId)}/conversations/${conversationId}/flag`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messageId: flagMessageId, + reason: flagReason, + notes: flagNotes || undefined, + }), + }, + ); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `Failed to flag message (${res.status})`); + } + + setFlagDialogOpen(false); + onFlagSubmitted?.(); + } catch (err) { + setFlagError(err instanceof Error ? err.message : 'Failed to flag message'); + } finally { + setFlagSubmitting(false); + } + }, [flagMessageId, flagReason, flagNotes, conversationId, guildId, onFlagSubmitted]); + + return ( +
+ {/* Header stats */} +
+ + + {channelId} + + + + {formatDuration(duration)} + + + + ~{tokenEstimate.toLocaleString()} tokens + + {messages.length} messages +
+ + {/* Messages */} +
+ {messages.map((msg, idx) => { + const prevTimestamp = idx > 0 ? messages[idx - 1].createdAt : null; + const showTimestamp = shouldShowTimestamp(msg.createdAt, prevTimestamp); + const isFlagged = msg.flagStatus === 'open'; + + return ( +
+ {showTimestamp && ( +
+ + {formatTimestamp(msg.createdAt)} + +
+ )} + + {msg.role === 'system' ? ( +
+

+ {msg.content} +

+
+ ) : ( +
+ {/* Avatar */} +
+ {(msg.username || msg.role).slice(0, 2).toUpperCase()} +
+ + {/* Bubble */} +
+

+ {msg.username || msg.role} +

+

{msg.content}

+ + {/* Flag button for assistant messages */} + {msg.role === 'assistant' && ( +
+ +
+ )} + + {/* Flagged indicator */} + {isFlagged && ( +
+ + Flagged +
+ )} +
+
+ )} +
+ ); + })} +
+ + {/* Flag Dialog */} + + + + Flag AI Response + + Report a problematic AI response for review. + + +
+
+ + +
+
+ +