diff --git a/app/(pages)/admin/_components/Hackbot/HackbotUsageMetrics.tsx b/app/(pages)/admin/_components/Hackbot/HackbotUsageMetrics.tsx new file mode 100644 index 000000000..3537aa3a6 --- /dev/null +++ b/app/(pages)/admin/_components/Hackbot/HackbotUsageMetrics.tsx @@ -0,0 +1,132 @@ +'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' }, +]; + +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 [metrics, setMetrics] = useState(null); + const [loading, setLoading] = useState(true); + + const load = useCallback(async (p: UsagePeriod) => { + setLoading(true); + try { + setMetrics(await getUsageMetrics(p)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(period); + }, [period, load]); + + const uncachedTokens = metrics + ? metrics.totalPromptTokens - metrics.totalCachedTokens + : 0; + const hitPct = metrics ? Math.round(metrics.cacheHitRate * 100) : 0; + + return ( +
+ {/* Header */} +
+

Usage Metrics

+
+ {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 */} +
+ +