Spaces:
Runtime error
Runtime error
| "use client"; | |
| import { useState, useEffect, useCallback } from "react"; | |
| import { Button, EmptyState } from "@/shared/components"; | |
| import { useNotificationStore } from "@/store/notificationStore"; | |
| import { useTranslations } from "next-intl"; | |
| // ──────────────── Types ──────────────── | |
| interface ReasoningCacheEntry { | |
| toolCallId: string; | |
| provider: string; | |
| model: string; | |
| reasoning: string; | |
| charCount: number; | |
| createdAt: string; | |
| expiresAt: string; | |
| } | |
| interface ReasoningCacheStats { | |
| memoryEntries: number; | |
| dbEntries: number; | |
| totalEntries: number; | |
| totalChars: number; | |
| hits: number; | |
| misses: number; | |
| replays: number; | |
| replayRate: string; | |
| byProvider: Record<string, { entries: number; chars: number }>; | |
| byModel: Record<string, { entries: number; chars: number }>; | |
| oldestEntry: string | null; | |
| newestEntry: string | null; | |
| } | |
| interface ReasoningCacheData { | |
| stats: ReasoningCacheStats; | |
| entries: ReasoningCacheEntry[]; | |
| } | |
| // ──────────────── Helpers ──────────────── | |
| function formatChars(chars: number): string { | |
| if (chars >= 1_000_000) return `${(chars / 1_000_000).toFixed(1)}M`; | |
| if (chars >= 1_000) return `${(chars / 1_000).toFixed(1)}K`; | |
| return String(chars); | |
| } | |
| // ──────────────── Sub-Components ──────────────── | |
| function StatCard({ | |
| icon, | |
| label, | |
| value, | |
| sub, | |
| accent = "text-text-main", | |
| }: { | |
| icon: string; | |
| label: string; | |
| value: string | number; | |
| sub?: string; | |
| accent?: string; | |
| }) { | |
| return ( | |
| <div className="rounded-2xl border border-border/30 bg-surface-raised/70 p-4"> | |
| <div className="flex items-center gap-1.5 text-xs text-text-muted"> | |
| <span className="material-symbols-outlined text-base leading-none" aria-hidden="true"> | |
| {icon} | |
| </span> | |
| <span>{label}</span> | |
| </div> | |
| <div className={`mt-3 text-2xl font-semibold tabular-nums ${accent}`}>{value}</div> | |
| {sub && <div className="mt-1 text-xs text-text-muted">{sub}</div>} | |
| </div> | |
| ); | |
| } | |
| function SectionBadge({ | |
| icon, | |
| children, | |
| tone = "neutral", | |
| }: { | |
| icon: string; | |
| children: React.ReactNode; | |
| tone?: "neutral" | "green" | "amber" | "blue"; | |
| }) { | |
| const toneClass = | |
| tone === "green" | |
| ? "border-green-500/20 bg-green-500/10 text-green-300" | |
| : tone === "amber" | |
| ? "border-amber-400/20 bg-amber-400/10 text-amber-300" | |
| : tone === "blue" | |
| ? "border-blue-400/20 bg-blue-400/10 text-blue-300" | |
| : "border-border/40 bg-surface/50 text-text-muted"; | |
| return ( | |
| <span | |
| className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium uppercase tracking-[0.12em] ${toneClass}`} | |
| > | |
| <span className="material-symbols-outlined text-sm leading-none" aria-hidden="true"> | |
| {icon} | |
| </span> | |
| {children} | |
| </span> | |
| ); | |
| } | |
| function InfoRow({ icon, children }: { icon: string; children: React.ReactNode }) { | |
| return ( | |
| <div className="flex gap-2 text-sm text-text-muted"> | |
| <span | |
| className="material-symbols-outlined shrink-0 text-base leading-5 text-blue-400" | |
| aria-hidden="true" | |
| > | |
| {icon} | |
| </span> | |
| <span>{children}</span> | |
| </div> | |
| ); | |
| } | |
| // ──────────────── Main Component ──────────────── | |
| const REFRESH_INTERVAL_MS = 10_000; | |
| export default function ReasoningCacheTab() { | |
| const t = useTranslations("cache"); | |
| const notify = useNotificationStore(); | |
| const [data, setData] = useState<ReasoningCacheData | null>(null); | |
| const [loading, setLoading] = useState(true); | |
| const [clearing, setClearing] = useState(false); | |
| const [expandedId, setExpandedId] = useState<string | null>(null); | |
| const timeAgo = (dateStr: string): string => { | |
| const diff = Date.now() - new Date(dateStr).getTime(); | |
| const minutes = Math.floor(diff / 60000); | |
| if (minutes < 1) return t("justNow"); | |
| if (minutes < 60) return t("minutesAgo", { minutes }); | |
| const hours = Math.floor(minutes / 60); | |
| if (hours < 24) return t("hoursAgo", { hours }); | |
| const days = Math.floor(hours / 24); | |
| return t("daysAgo", { days }); | |
| }; | |
| const fetchData = useCallback(async () => { | |
| try { | |
| const res = await fetch("/api/cache/reasoning"); | |
| if (res.ok) { | |
| const json: ReasoningCacheData = await res.json(); | |
| setData(json); | |
| } | |
| } catch (error) { | |
| console.error("[ReasoningCacheTab] Failed to fetch:", error); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, []); | |
| useEffect(() => { | |
| void fetchData(); | |
| const id = setInterval(() => void fetchData(), REFRESH_INTERVAL_MS); | |
| return () => clearInterval(id); | |
| }, [fetchData]); | |
| const handleClear = async () => { | |
| setClearing(true); | |
| try { | |
| const res = await fetch("/api/cache/reasoning", { method: "DELETE" }); | |
| if (res.ok) { | |
| const result = await res.json(); | |
| notify.success(t("reasoningClearSuccess", { count: result.cleared ?? 0 })); | |
| await fetchData(); | |
| } else { | |
| notify.error(t("reasoningClearError")); | |
| } | |
| } catch { | |
| notify.error(t("reasoningClearError")); | |
| } finally { | |
| setClearing(false); | |
| } | |
| }; | |
| if (loading) { | |
| return ( | |
| <div className="space-y-4" aria-busy="true"> | |
| <div className="h-32 rounded-2xl bg-surface-raised animate-pulse" /> | |
| <div className="h-48 rounded-2xl bg-surface-raised animate-pulse" /> | |
| </div> | |
| ); | |
| } | |
| if (!data) { | |
| return ( | |
| <EmptyState | |
| icon="psychology" | |
| title={t("reasoningCache")} | |
| description={t("reasoningNoData")} | |
| actionLabel={t("refresh")} | |
| onAction={() => void fetchData()} | |
| /> | |
| ); | |
| } | |
| const { stats, entries } = data; | |
| const providerEntries = Object.entries(stats.byProvider).sort( | |
| ([, a], [, b]) => b.entries - a.entries | |
| ); | |
| const modelEntries = Object.entries(stats.byModel).sort(([, a], [, b]) => b.entries - a.entries); | |
| const totalLookups = stats.hits + stats.misses; | |
| return ( | |
| <div className="flex flex-col gap-6"> | |
| {/* Header */} | |
| <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"> | |
| <div className="space-y-2"> | |
| <SectionBadge icon="psychology" tone="blue"> | |
| {t("reasoningCache")} | |
| </SectionBadge> | |
| <div> | |
| <h2 className="text-lg font-semibold text-text-main">{t("reasoningCache")}</h2> | |
| <p className="mt-1 max-w-3xl text-sm text-text-muted">{t("reasoningCacheDesc")}</p> | |
| </div> | |
| </div> | |
| <Button | |
| variant="danger" | |
| icon="delete_sweep" | |
| size="sm" | |
| onClick={() => void handleClear()} | |
| disabled={clearing || loading || stats.totalEntries === 0} | |
| loading={clearing} | |
| aria-label={t("reasoningClearAll")} | |
| > | |
| {t("reasoningClearAll")} | |
| </Button> | |
| </div> | |
| {/* Stat Cards */} | |
| <div className="grid grid-cols-2 gap-4 xl:grid-cols-5"> | |
| <StatCard | |
| icon="psychology" | |
| label={t("reasoningEntries")} | |
| value={stats.totalEntries} | |
| sub={`${stats.memoryEntries} memory / ${stats.dbEntries} DB`} | |
| accent="text-blue-400" | |
| /> | |
| <StatCard | |
| icon="speed" | |
| label={t("reasoningReplayRate")} | |
| value={stats.replayRate} | |
| sub={`${totalLookups.toLocaleString()} lookups`} | |
| accent="text-emerald-500" | |
| /> | |
| <StatCard | |
| icon="replay" | |
| label={t("reasoningReplays")} | |
| value={stats.replays.toLocaleString()} | |
| sub={t("reasoningBehaviorReplay")} | |
| accent="text-cyan-400" | |
| /> | |
| <StatCard | |
| icon="text_fields" | |
| label={t("reasoningCharsCached")} | |
| value={formatChars(stats.totalChars)} | |
| sub={`${stats.totalChars.toLocaleString()} chars`} | |
| accent="text-purple-400" | |
| /> | |
| <StatCard | |
| icon="error_outline" | |
| label={t("reasoningMisses")} | |
| value={stats.misses.toLocaleString()} | |
| sub={`${stats.hits.toLocaleString()} hits`} | |
| accent="text-red-400" | |
| /> | |
| </div> | |
| {/* By Provider */} | |
| {providerEntries.length > 0 && ( | |
| <div className="rounded-2xl border border-border/30 bg-surface/20 p-5"> | |
| <h3 className="text-sm font-medium text-text-main">{t("reasoningByProvider")}</h3> | |
| <div className="mt-3 overflow-x-auto rounded-2xl border border-border/20 bg-surface"> | |
| <table className="w-full text-sm"> | |
| <thead> | |
| <tr className="border-b border-border/20 text-left text-[11px] uppercase tracking-[0.12em] text-text-muted"> | |
| <th className="px-4 py-3">{t("tableProvider")}</th> | |
| <th className="px-4 py-3">{t("reasoningEntries")}</th> | |
| <th className="px-4 py-3">{t("reasoningChars")}</th> | |
| <th className="px-4 py-3">{t("tableShare")}</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {providerEntries.map(([prov, d]) => { | |
| const share = | |
| stats.totalEntries > 0 | |
| ? ((d.entries / stats.totalEntries) * 100).toFixed(1) | |
| : "0.0"; | |
| return ( | |
| <tr key={prov} className="border-b border-border/15 last:border-b-0"> | |
| <td className="px-4 py-3 font-medium text-text-main">{prov}</td> | |
| <td className="px-4 py-3 tabular-nums text-text-main"> | |
| {d.entries.toLocaleString()} | |
| </td> | |
| <td className="px-4 py-3 tabular-nums text-purple-400"> | |
| {formatChars(d.chars)} | |
| </td> | |
| <td className="px-4 py-3"> | |
| <div className="flex items-center gap-2"> | |
| <div className="h-2 w-20 overflow-hidden rounded-full bg-surface/60"> | |
| <div | |
| className="h-full rounded-full bg-blue-400" | |
| style={{ | |
| width: `${Math.min(parseFloat(share), 100)}%`, | |
| }} | |
| /> | |
| </div> | |
| <span className="text-xs font-semibold tabular-nums text-text-main"> | |
| {share}% | |
| </span> | |
| </div> | |
| </td> | |
| </tr> | |
| ); | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| )} | |
| {/* By Model */} | |
| {modelEntries.length > 0 && ( | |
| <div className="rounded-2xl border border-border/30 bg-surface/20 p-5"> | |
| <h3 className="text-sm font-medium text-text-main">{t("reasoningByModel")}</h3> | |
| <div className="mt-3 overflow-x-auto rounded-2xl border border-border/20 bg-surface"> | |
| <table className="w-full text-sm"> | |
| <thead> | |
| <tr className="border-b border-border/20 text-left text-[11px] uppercase tracking-[0.12em] text-text-muted"> | |
| <th className="px-4 py-3">{t("tableModel")}</th> | |
| <th className="px-4 py-3">{t("reasoningEntries")}</th> | |
| <th className="px-4 py-3">{t("reasoningAvgChars")}</th> | |
| <th className="px-4 py-3">{t("reasoningChars")}</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {modelEntries.map(([mdl, d]) => { | |
| const avgChars = d.entries > 0 ? Math.round(d.chars / d.entries) : 0; | |
| return ( | |
| <tr key={mdl} className="border-b border-border/15 last:border-b-0"> | |
| <td className="px-4 py-3 font-medium text-text-main">{mdl}</td> | |
| <td className="px-4 py-3 tabular-nums text-text-main"> | |
| {d.entries.toLocaleString()} | |
| </td> | |
| <td className="px-4 py-3 tabular-nums text-cyan-400"> | |
| {avgChars.toLocaleString()} | |
| </td> | |
| <td className="px-4 py-3 tabular-nums text-purple-400"> | |
| {formatChars(d.chars)} | |
| </td> | |
| </tr> | |
| ); | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| )} | |
| {/* Recent Entries */} | |
| <div className="rounded-2xl border border-border/30 bg-surface/20 p-5"> | |
| <div className="mb-4 flex flex-col gap-1"> | |
| <h3 className="text-sm font-medium text-text-main">{t("reasoningRecentEntries")}</h3> | |
| <p className="text-sm text-text-muted">{t("reasoningCacheDesc")}</p> | |
| </div> | |
| {entries.length === 0 ? ( | |
| <div className="rounded-2xl border border-dashed border-border/40 bg-surface/10 px-4 py-6 text-sm text-text-muted"> | |
| {t("reasoningNoData")} | |
| </div> | |
| ) : ( | |
| <div className="overflow-hidden rounded-2xl border border-border/20 bg-surface"> | |
| <div className="grid grid-cols-[minmax(120px,1fr)_100px_minmax(100px,1fr)_80px_80px_60px] gap-3 border-b border-border/20 px-4 py-3 text-[11px] font-medium uppercase tracking-[0.12em] text-text-muted"> | |
| <span>{t("reasoningToolCallId")}</span> | |
| <span>{t("tableProvider")}</span> | |
| <span>{t("tableModel")}</span> | |
| <span>{t("reasoningChars")}</span> | |
| <span>{t("reasoningAge")}</span> | |
| <span /> | |
| </div> | |
| <div className="max-h-96 overflow-y-auto"> | |
| {entries.map((entry) => ( | |
| <div key={entry.toolCallId}> | |
| <div className="grid grid-cols-[minmax(120px,1fr)_100px_minmax(100px,1fr)_80px_80px_60px] gap-3 border-b border-border/15 px-4 py-3 last:border-b-0"> | |
| <div | |
| className="truncate text-sm font-mono text-text-main" | |
| title={entry.toolCallId} | |
| > | |
| {entry.toolCallId} | |
| </div> | |
| <div className="text-sm text-text-muted">{entry.provider}</div> | |
| <div className="truncate text-sm text-text-muted" title={entry.model}> | |
| {entry.model} | |
| </div> | |
| <div className="text-sm tabular-nums text-purple-400"> | |
| {entry.charCount.toLocaleString()} | |
| </div> | |
| <div className="text-sm text-text-muted">{timeAgo(entry.createdAt)}</div> | |
| <button | |
| type="button" | |
| onClick={() => | |
| setExpandedId(expandedId === entry.toolCallId ? null : entry.toolCallId) | |
| } | |
| className="flex items-center justify-center rounded-md p-1 text-text-muted transition-colors hover:bg-surface/60 hover:text-text-main" | |
| aria-label={t("reasoningView")} | |
| > | |
| <span className="material-symbols-outlined text-lg"> | |
| {expandedId === entry.toolCallId ? "expand_less" : "visibility"} | |
| </span> | |
| </button> | |
| </div> | |
| {/* Expanded Detail */} | |
| {expandedId === entry.toolCallId && ( | |
| <div className="border-b border-border/15 bg-surface/15 px-4 py-4"> | |
| <div className="flex items-center gap-2 text-xs text-text-muted mb-2"> | |
| <span className="material-symbols-outlined text-sm text-blue-400"> | |
| psychology | |
| </span> | |
| <span className="font-medium"> | |
| {t("reasoningDetail")} ({entry.toolCallId}) | |
| </span> | |
| </div> | |
| <pre className="max-h-72 overflow-auto rounded-xl bg-black/20 p-4 text-xs leading-relaxed text-text-main font-mono whitespace-pre-wrap break-words"> | |
| {entry.reasoning} | |
| </pre> | |
| <div className="mt-3 flex flex-wrap gap-4 text-xs text-text-muted"> | |
| <span> | |
| {t("tableProvider")}:{" "} | |
| <span className="text-text-main">{entry.provider}</span> | |
| </span> | |
| <span> | |
| {t("tableModel")}: <span className="text-text-main">{entry.model}</span> | |
| </span> | |
| <span> | |
| {t("created")}:{" "} | |
| <span className="text-text-main"> | |
| {new Date(entry.createdAt).toLocaleString()} | |
| </span> | |
| </span> | |
| <span> | |
| {t("expires")}:{" "} | |
| <span className="text-text-main"> | |
| {new Date(entry.expiresAt).toLocaleString()} | |
| </span> | |
| </span> | |
| <span> | |
| {t("reasoningChars")}:{" "} | |
| <span className="text-purple-400"> | |
| {entry.charCount.toLocaleString()} | |
| </span> | |
| </span> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Behavior Info */} | |
| <div className="rounded-2xl border border-border/30 bg-surface/20 p-5"> | |
| <h3 className="text-sm font-medium text-text-main">{t("reasoningBehavior")}</h3> | |
| <div className="mt-4 grid gap-3"> | |
| <InfoRow icon="info">{t("reasoningBehaviorCapture")}</InfoRow> | |
| <InfoRow icon="info">{t("reasoningBehaviorReplay")}</InfoRow> | |
| <InfoRow icon="info">{t("reasoningBehaviorFallback")}</InfoRow> | |
| <InfoRow icon="info">{t("reasoningBehaviorTtl")}</InfoRow> | |
| <InfoRow icon="info">{t("reasoningBehaviorModels")}</InfoRow> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |