diff --git a/app/(api)/_actions/hackbot/getUsageMetrics.ts b/app/(api)/_actions/hackbot/getUsageMetrics.ts index 4715caf34..d653db55a 100644 --- a/app/(api)/_actions/hackbot/getUsageMetrics.ts +++ b/app/(api)/_actions/hackbot/getUsageMetrics.ts @@ -2,7 +2,7 @@ import { getDatabase } from '@utils/mongodb/mongoClient.mjs'; -export type UsagePeriod = '24h' | '7d' | '30d'; +export type UsagePeriod = '24h' | '7d' | '30d' | 'all'; export interface UsageMetrics { totalRequests: number; @@ -11,19 +11,50 @@ export interface UsageMetrics { totalCachedTokens: number; /** 0–1 fraction of prompt tokens that were served from cache */ cacheHitRate: number; + modelsCount: { model: string; count: number }[]; } export async function getUsageMetrics( - period: UsagePeriod = '24h' + period: UsagePeriod = '24h', + modelFilter: string | null = null ): Promise { - const hours = period === '24h' ? 24 : period === '7d' ? 168 : 720; - const since = new Date(Date.now() - hours * 60 * 60 * 1000); + const timeMatch: any = {}; + if (period !== 'all') { + const hours = period === '24h' ? 24 : period === '7d' ? 168 : 720; + timeMatch.timestamp = { + $gte: new Date(Date.now() - hours * 60 * 60 * 1000), + }; + } const db = await getDatabase(); + + const modelsStats = await db + .collection('hackbot_usage') + .aggregate([ + { + $group: { + _id: '$model', + count: { $sum: 1 }, + }, + }, + { $sort: { count: -1 } }, + ]) + .toArray(); + + const modelsCount = modelsStats.map((s: any) => ({ + model: s._id || 'unknown', + count: s.count, + })); + + const matchStage: any = { ...timeMatch }; + if (modelFilter && modelFilter !== 'all') { + matchStage.model = modelFilter; + } + const [result] = await db .collection('hackbot_usage') .aggregate([ - { $match: { timestamp: { $gte: since } } }, + { $match: matchStage }, { $group: { _id: null, @@ -43,6 +74,7 @@ export async function getUsageMetrics( totalCompletionTokens: 0, totalCachedTokens: 0, cacheHitRate: 0, + modelsCount, }; } @@ -55,5 +87,6 @@ export async function getUsageMetrics( result.totalPromptTokens > 0 ? result.totalCachedTokens / result.totalPromptTokens : 0, + modelsCount, }; } diff --git a/app/(api)/_utils/hackbot/stream/model.ts b/app/(api)/_utils/hackbot/stream/model.ts index ae12e00ac..473c68cc1 100644 --- a/app/(api)/_utils/hackbot/stream/model.ts +++ b/app/(api)/_utils/hackbot/stream/model.ts @@ -1,7 +1,7 @@ import { stepCountIs } from 'ai'; export function getModelConfig() { - const model = process.env.OPENAI_MODEL || 'gpt-4o-mini'; + const model = process.env.OPENAI_MODEL || 'gpt-4.1-mini'; const configuredMaxTokens = parseInt( process.env.OPENAI_MAX_TOKENS || '600', 10 diff --git a/app/(pages)/admin/_components/Hackbot/HackbotUsageMetrics.tsx b/app/(pages)/admin/_components/Hackbot/HackbotUsageMetrics.tsx new file mode 100644 index 000000000..5fdcdb704 --- /dev/null +++ b/app/(pages)/admin/_components/Hackbot/HackbotUsageMetrics.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { + getUsageMetrics, + type UsagePeriod, + type UsageMetrics, +} from '@actions/hackbot/getUsageMetrics'; + +const PERIODS: { value: UsagePeriod; label: string }[] = [ + { value: '24h', label: 'Last 24 h' }, + { value: '7d', label: 'Last 7 days' }, + { value: '30d', label: 'Last 30 days' }, + { value: 'all', label: 'All time' }, +]; + +function fmt(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(n); +} + +export default function HackbotUsageMetrics() { + const [period, setPeriod] = useState('24h'); + const [modelFilter, setModelFilter] = useState('all'); + const [metrics, setMetrics] = useState(null); + const [loading, setLoading] = useState(true); + + const load = useCallback(async (p: UsagePeriod, m: string) => { + setLoading(true); + try { + setMetrics(await getUsageMetrics(p, m)); + } catch (err) { + console.error('Failed to load metrics:', err); + setMetrics(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(period, modelFilter); + }, [period, modelFilter, load]); + + const uncachedTokens = metrics + ? metrics.totalPromptTokens - metrics.totalCachedTokens + : 0; + const hitPct = metrics ? Math.round(metrics.cacheHitRate * 100) : 0; + + return ( +
+ {/* Header */} +
+
+

Usage Metrics

+ {metrics && metrics.modelsCount.length > 0 && ( + + )} +
+
+ {PERIODS.map(({ value, label }) => ( + + ))} +
+
+ + {/* Metric cards */} +
+ {/* Requests */} +
+

Requests

+

+ {metrics ? fmt(metrics.totalRequests) : '—'} +

+
+ + {/* Cache hit rate */} +
+

Cache hit rate

+

+ {metrics ? `${hitPct}%` : '—'} +

+ {metrics && metrics.totalPromptTokens > 0 && ( +
+
+
+ )} +
+ + {/* Prompt tokens breakdown */} +
+

Prompt tokens

+ {metrics ? ( + <> +

+ {fmt(metrics.totalPromptTokens)} +

+
+ + + {fmt(metrics.totalCachedTokens)} cached + + + + {fmt(uncachedTokens)} uncached + +
+ + ) : ( +

+ )} +
+ + {/* Completion tokens */} +
+

Completion tokens

+

+ {metrics ? fmt(metrics.totalCompletionTokens) : '—'} +

+
+
+
+ ); +} diff --git a/app/(pages)/admin/_components/Hackbot/KnowledgeBanners.tsx b/app/(pages)/admin/_components/Hackbot/KnowledgeBanners.tsx new file mode 100644 index 000000000..d0cdbaa38 --- /dev/null +++ b/app/(pages)/admin/_components/Hackbot/KnowledgeBanners.tsx @@ -0,0 +1,111 @@ +'use client'; + +import useHackbotKnowledge from '../../_hooks/useHackbotKnowledge'; + +export default function KnowledgeBanners() { + const { + banner, + setBanner, + reseedResult, + setReseedResult, + clearResult, + setClearResult, + importResult, + setImportResult, + importError, + } = useHackbotKnowledge(); + + return ( + <> + {/* Global banner */} + {banner && ( +
+ {banner.message} + +
+ )} + + {/* Reseed result */} + {reseedResult && ( +
+ + {reseedResult.ok + ? `Reseeded ${reseedResult.successCount} doc${ + reseedResult.successCount !== 1 ? 's' : '' + } successfully.` + : reseedResult.error ?? + `${reseedResult.failureCount} doc(s) failed to reseed.`} + + +
+ )} + + {/* Clear result — error only (success goes via banner) */} + {clearResult && !clearResult.ok && ( +
+ Failed to clear: {clearResult.error} + +
+ )} + + {/* Import result — error only (success goes via banner) */} + {importResult && !importResult.ok && ( +
+
+ + {importResult.successCount} imported, {importResult.failureCount}{' '} + failed. + + +
+ {importResult.failures.length > 0 && ( +
    + {importResult.failures.map((f, i) => ( +
  • {f}
  • + ))} +
+ )} +
+ )} + + {/* Import parse error */} + {importError && ( +

+ {importError} +

+ )} + + ); +} diff --git a/app/(pages)/admin/_components/Hackbot/KnowledgeDocModal.tsx b/app/(pages)/admin/_components/Hackbot/KnowledgeDocModal.tsx new file mode 100644 index 000000000..3c378a638 --- /dev/null +++ b/app/(pages)/admin/_components/Hackbot/KnowledgeDocModal.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { RxCross1 } from 'react-icons/rx'; +import useHackbotKnowledge from '../../_hooks/useHackbotKnowledge'; +import { DOC_TYPES, TYPE_LABELS } from '../../_constants/hackbotKnowledge'; +import type { HackDocType } from '@typeDefs/hackbot'; + +export default function KnowledgeDocModal() { + const { + modalOpen, + editingDoc, + form, + setForm, + formError, + isSaving, + closeModal, + handleSave, + } = useHackbotKnowledge(); + + if (!modalOpen) return null; + + return ( +
+
+
+

+ {editingDoc ? 'Edit Document' : 'Add Document'} +

+ +
+ +
+ {/* Type */} +
+ + +
+ + {/* Title */} +
+ + + setForm((f) => ({ ...f, title: e.target.value })) + } + placeholder="e.g. Judging Process Overview" + className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#005271]" + /> +
+ + {/* URL */} +
+ + + setForm((f) => ({ ...f, url: e.target.value || null })) + } + placeholder="e.g. /project-info#judging" + className="border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#005271]" + /> +
+ + {/* Content */} +
+ +