fix settings persistence and cache analytics layout (#952)
This commit is contained in:
@@ -99,6 +99,11 @@ import { generateRequestId } from "@/shared/utils/requestId";
|
||||
import { normalizePayloadForLog } from "@/lib/logPayloads";
|
||||
import { injectMemory, shouldInjectMemory } from "@/lib/memory/injection";
|
||||
import { retrieveMemories } from "@/lib/memory/retrieval";
|
||||
import {
|
||||
DEFAULT_MEMORY_SETTINGS,
|
||||
getMemorySettings,
|
||||
toMemoryRetrievalConfig,
|
||||
} from "@/lib/memory/settings";
|
||||
import {
|
||||
buildClaudeCodeCompatibleRequest,
|
||||
isClaudeCodeCompatibleProvider,
|
||||
@@ -714,9 +719,22 @@ export async function handleChatCore({
|
||||
});
|
||||
}
|
||||
|
||||
if (apiKeyInfo?.id && shouldInjectMemory(body as Parameters<typeof shouldInjectMemory>[0])) {
|
||||
const memorySettings = apiKeyInfo?.id
|
||||
? await getMemorySettings().catch(() => DEFAULT_MEMORY_SETTINGS)
|
||||
: null;
|
||||
|
||||
if (
|
||||
apiKeyInfo?.id &&
|
||||
memorySettings &&
|
||||
shouldInjectMemory(body as Parameters<typeof shouldInjectMemory>[0], {
|
||||
enabled: memorySettings.enabled && memorySettings.maxTokens > 0,
|
||||
})
|
||||
) {
|
||||
try {
|
||||
const memories = await retrieveMemories(apiKeyInfo.id);
|
||||
const memories = await retrieveMemories(
|
||||
apiKeyInfo.id,
|
||||
toMemoryRetrievalConfig(memorySettings)
|
||||
);
|
||||
if (memories.length > 0) {
|
||||
const injected = injectMemory(
|
||||
body as Parameters<typeof injectMemory>[0],
|
||||
|
||||
@@ -2,29 +2,55 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DiversityProviderStat {
|
||||
share: number;
|
||||
}
|
||||
|
||||
interface DiversityReport {
|
||||
score: number;
|
||||
providers: Record<string, DiversityProviderStat>;
|
||||
windowSize: number;
|
||||
ttlMs: number;
|
||||
}
|
||||
|
||||
export default function DiversityScoreCard() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [data, setData] = useState<DiversityReport | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const t = useTranslations("analytics");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/analytics/diversity")
|
||||
.then((res) => res.json())
|
||||
.then((json) => {
|
||||
setData(json);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setLoading(false);
|
||||
});
|
||||
let cancelled = false;
|
||||
|
||||
async function loadDiversity() {
|
||||
try {
|
||||
const res = await fetch("/api/analytics/diversity");
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch diversity analytics: ${res.status}`);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as DiversityReport;
|
||||
if (!cancelled) {
|
||||
setData(json);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadDiversity();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<Card className="p-5 flex flex-col justify-center items-center h-full min-h-[200px]">
|
||||
<Card className="p-5 flex flex-col justify-center items-center min-h-[220px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</Card>
|
||||
);
|
||||
@@ -47,40 +73,43 @@ export default function DiversityScoreCard() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-5 flex flex-col h-full bg-[var(--card-bg,#1e1e2e)] relative overflow-hidden group">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="material-symbols-outlined text-[20px] text-cyan-400">pie_chart</span>
|
||||
<h3 className="font-semibold text-[var(--text-primary,#fff)] flex-1">
|
||||
Provider Diversity Score
|
||||
</h3>
|
||||
<Card className="p-5 flex flex-col gap-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary">pie_chart</span>
|
||||
<h3 className="font-semibold text-text-main">Provider Diversity</h3>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Provider concentration snapshot for the recent traffic window.
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-md border ${gaugeColor.replace("bg-", "border-").replace("500", "500/20")} ${gaugeColor.replace("500", "500/10")} ${riskColor}`}
|
||||
className={`shrink-0 rounded-full px-2.5 py-1 text-xs font-medium ${
|
||||
scorePercentage < 40
|
||||
? "bg-red-500/10 text-red-500"
|
||||
: scorePercentage < 70
|
||||
? "bg-amber-500/10 text-amber-500"
|
||||
: "bg-green-500/10 text-green-500"
|
||||
}`}
|
||||
>
|
||||
Shannon Entropy
|
||||
Shannon entropy
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-2 mb-6">
|
||||
<div className="flex flex-col">
|
||||
<span className={`text-4xl font-bold tabular-nums tracking-tight ${riskColor}`}>
|
||||
{scorePercentage}%
|
||||
</span>
|
||||
<span className="text-sm text-[var(--text-muted,#aaaaaa)] mt-1">{riskLabel}</span>
|
||||
</div>
|
||||
|
||||
{/* Simple CSS Donut */}
|
||||
<div className="relative w-20 h-20 flex-shrink-0">
|
||||
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 36 36">
|
||||
<div className="grid gap-4 sm:grid-cols-[112px_minmax(0,1fr)] sm:items-center">
|
||||
<div className="relative mx-auto h-28 w-28">
|
||||
<svg className="h-full w-full -rotate-90" viewBox="0 0 36 36">
|
||||
<path
|
||||
className="text-[var(--border,#333)]"
|
||||
strokeWidth="4"
|
||||
className="text-border/70"
|
||||
strokeWidth="3.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
/>
|
||||
<path
|
||||
className={riskColor}
|
||||
strokeWidth="4"
|
||||
strokeWidth="3.5"
|
||||
strokeDasharray={`${scorePercentage}, 100`}
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
@@ -88,48 +117,58 @@ export default function DiversityScoreCard() {
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className={`text-2xl font-semibold tabular-nums ${riskColor}`}>
|
||||
{scorePercentage}%
|
||||
</span>
|
||||
<span className="text-[11px] uppercase tracking-[0.18em] text-text-muted">score</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-border/30 bg-surface/20 px-4 py-3">
|
||||
<div className={`text-sm font-medium ${riskColor}`}>{riskLabel}</div>
|
||||
<div className="text-xs text-text-muted mt-1">
|
||||
Higher values indicate traffic is spread across multiple providers instead of
|
||||
clustering on one vendor.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Object.keys(data.providers || {}).length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border/50 px-4 py-5 text-sm text-text-muted">
|
||||
No recent usage data available.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(data.providers)
|
||||
.sort(([, a], [, b]) => b.share - a.share)
|
||||
.slice(0, 4)
|
||||
.map(([provider, stat]) => (
|
||||
<div key={provider} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="font-medium text-text-main capitalize">{provider}</span>
|
||||
<span className="tabular-nums text-text-muted">
|
||||
{Math.round(stat.share * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-surface/50 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${gaugeColor}`}
|
||||
style={{ width: `${Math.round(stat.share * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 flex-1">
|
||||
<p className="text-xs uppercase tracking-wider font-semibold text-[var(--text-muted,#888)]">
|
||||
Provider Share
|
||||
</p>
|
||||
|
||||
{Object.keys(data.providers || {}).length === 0 ? (
|
||||
<div className="text-sm text-[var(--text-secondary,#666)] py-2">
|
||||
No recent usage data available.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(data.providers)
|
||||
.sort(([, a]: any, [, b]: any) => b.share - a.share)
|
||||
.slice(0, 4) // Top 4 providers
|
||||
.map(([provider, stat]: [string, any]) => (
|
||||
<div key={provider} className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-[var(--text-primary,#ddd)] capitalize">
|
||||
{provider}
|
||||
</span>
|
||||
<span className="font-mono text-[var(--text-muted,#aaa)]">
|
||||
{Math.round(stat.share * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-[var(--surface,#333)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${gaugeColor} rounded-full`}
|
||||
style={{ width: `${Math.round(stat.share * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-[var(--border,#333)] flex justify-between text-[11px] text-[var(--text-muted,#777)]">
|
||||
<span>Window: {data.windowSize} reqs</span>
|
||||
<span>Based on Last {Math.round(data.ttlMs / 60000)} mins</span>
|
||||
<div className="grid grid-cols-2 gap-3 border-t border-border/30 pt-4 text-xs text-text-muted">
|
||||
<div className="rounded-lg bg-surface/20 px-3 py-2">Window: {data.windowSize} reqs</div>
|
||||
<div className="rounded-lg bg-surface/20 px-3 py-2 text-right">
|
||||
Based on last {Math.round(data.ttlMs / 60000)} mins
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -45,13 +45,11 @@ export default function AnalyticsPage() {
|
||||
/>
|
||||
|
||||
{activeTab === "overview" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<DiversityScoreCard />
|
||||
</div>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_340px] xl:items-start">
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<UsageAnalytics />
|
||||
</Suspense>
|
||||
<DiversityScoreCard />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "evals" && <EvalsTab />}
|
||||
|
||||
+137
-13
@@ -5,7 +5,6 @@ import { Card, Button, EmptyState } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { useTranslations } from "next-intl";
|
||||
import CacheEntriesTab from "./components/CacheEntriesTab";
|
||||
import CacheStatsCard from "../settings/components/CacheStatsCard";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -192,7 +191,18 @@ export default function CachePage() {
|
||||
|
||||
const promptCacheHitRate =
|
||||
pc && pc.totalRequests > 0 ? (pc.requestsWithCacheControl / pc.totalRequests) * 100 : 0;
|
||||
const providerEntries = pc ? Object.entries(pc.byProvider) : [];
|
||||
const promptCacheReuseRatio =
|
||||
pc && pc.totalInputTokens > 0 ? (pc.totalCachedTokens / pc.totalInputTokens) * 100 : 0;
|
||||
const providerEntries = pc
|
||||
? Object.entries(pc.byProvider).sort(
|
||||
([, left], [, right]) => right.cachedTokens - left.cachedTokens
|
||||
)
|
||||
: [];
|
||||
const strategyEntries = pc
|
||||
? Object.entries(pc.byStrategy).sort(
|
||||
([, left], [, right]) => right.cachedTokens - left.cachedTokens
|
||||
)
|
||||
: [];
|
||||
|
||||
const maxTrendRequests = Math.max(1, ...trend.map((p) => p.requests));
|
||||
|
||||
@@ -351,30 +361,47 @@ export default function CachePage() {
|
||||
{pc && (
|
||||
<Card>
|
||||
<div className="p-5 flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="material-symbols-outlined text-base text-text-muted"
|
||||
aria-hidden="true"
|
||||
>
|
||||
bolt
|
||||
<div className="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="material-symbols-outlined text-base text-text-muted"
|
||||
aria-hidden="true"
|
||||
>
|
||||
bolt
|
||||
</span>
|
||||
<h2 className="font-medium text-sm">{t("promptCache")}</h2>
|
||||
</div>
|
||||
<span className="text-xs text-text-muted">
|
||||
Last updated {new Date(pc.lastUpdated).toLocaleString()}
|
||||
</span>
|
||||
<h2 className="font-medium text-sm">{t("promptCache")}</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4">
|
||||
<div className="p-3 rounded-lg bg-surface/50">
|
||||
<div className="text-lg font-semibold tabular-nums">
|
||||
{pc.totalRequests.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">{t("total")}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-surface/50">
|
||||
<div className="text-lg font-semibold tabular-nums">
|
||||
{pc.requestsWithCacheControl.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">{t("cachedRequests")}</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">
|
||||
{t("withCacheControl")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-surface/50">
|
||||
<div className="text-lg font-semibold tabular-nums text-green-500">
|
||||
{promptCacheHitRate.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">
|
||||
{t("cacheHitRate")} ({pc.requestsWithCacheControl}/{pc.totalRequests})
|
||||
<div className="text-xs text-text-muted mt-0.5">{t("cacheHitRate")}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-surface/50">
|
||||
<div className="text-lg font-semibold tabular-nums text-emerald-500">
|
||||
{promptCacheReuseRatio.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">{t("cacheReuseRatio")}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-surface/50">
|
||||
<div className="text-lg font-semibold tabular-nums text-blue-400">
|
||||
@@ -390,6 +417,103 @@ export default function CachePage() {
|
||||
{t("cacheCreationTokens")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-surface/50">
|
||||
<div className="text-lg font-semibold tabular-nums text-green-500">
|
||||
${pc.estimatedCostSaved.toFixed(4)}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">{t("estCostSaved")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||
<div className="rounded-xl border border-border/30 bg-surface/20 p-4">
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-text-main">
|
||||
{t("cacheMetrics")}
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted">{t("cacheReuseRatioDesc")}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-semibold tabular-nums text-emerald-500">
|
||||
{promptCacheReuseRatio.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
{pc.totalCachedTokens.toLocaleString()} /{" "}
|
||||
{pc.totalInputTokens.toLocaleString()}{" "}
|
||||
{t("inputTokens").toLowerCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HitRateBar hitRate={promptCacheReuseRatio} label={t("cacheReuseRatio")} />
|
||||
<div className="mt-3">
|
||||
<HitRateBar
|
||||
hitRate={promptCacheHitRate}
|
||||
label={`${t("withCacheControl")} (${pc.requestsWithCacheControl}/${pc.totalRequests})`}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mt-4">
|
||||
<div className="rounded-lg bg-surface/40 px-3 py-2">
|
||||
<div className="text-xs text-text-muted">{t("tokensSaved")}</div>
|
||||
<div className="text-sm font-semibold tabular-nums text-green-500 mt-1">
|
||||
{pc.tokensSaved.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-surface/40 px-3 py-2">
|
||||
<div className="text-xs text-text-muted">{t("cachedTokensRead")}</div>
|
||||
<div className="text-sm font-semibold tabular-nums text-blue-400 mt-1">
|
||||
{pc.totalCachedTokens.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-surface/40 px-3 py-2">
|
||||
<div className="text-xs text-text-muted">{t("cacheCreationWrite")}</div>
|
||||
<div className="text-sm font-semibold tabular-nums text-purple-400 mt-1">
|
||||
{pc.totalCacheCreationTokens.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{strategyEntries.length > 0 && (
|
||||
<div className="rounded-xl border border-border/30 bg-surface/20 p-4">
|
||||
<h3 className="text-sm font-medium text-text-main">Strategy Breakdown</h3>
|
||||
<div className="mt-3 space-y-3">
|
||||
{strategyEntries.map(([strategy, data]) => {
|
||||
const ratio =
|
||||
data.inputTokens > 0
|
||||
? (data.cachedTokens / data.inputTokens) * 100
|
||||
: 0;
|
||||
return (
|
||||
<div key={strategy} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="font-medium text-text-main capitalize">
|
||||
{strategy}
|
||||
</span>
|
||||
<span className="tabular-nums text-text-muted">
|
||||
{ratio.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-surface/50 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-400"
|
||||
style={{ width: `${Math.min(ratio, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-text-muted">
|
||||
<span>
|
||||
{data.requests.toLocaleString()} {t("requestsShort")}
|
||||
</span>
|
||||
<span>
|
||||
{data.cachedTokens.toLocaleString()}{" "}
|
||||
{t("cachedTokensCol").toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{providerEntries.length > 0 && (
|
||||
|
||||
@@ -33,15 +33,16 @@ export default function MemorySkillsTab() {
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings/memory")
|
||||
.then((res) => res.json())
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
setConfig(data);
|
||||
setLoading(false);
|
||||
if (data) setConfig(data);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const save = async (updates: Partial<MemoryConfig>) => {
|
||||
const previousConfig = config;
|
||||
const newConfig = { ...config, ...updates };
|
||||
setConfig(newConfig);
|
||||
setSaving(true);
|
||||
@@ -53,12 +54,16 @@ export default function MemorySkillsTab() {
|
||||
body: JSON.stringify(newConfig),
|
||||
});
|
||||
if (res.ok) {
|
||||
const savedConfig = await res.json().catch(() => newConfig);
|
||||
setConfig(savedConfig);
|
||||
setStatus("saved");
|
||||
setTimeout(() => setStatus(""), 2000);
|
||||
} else {
|
||||
setConfig(previousConfig);
|
||||
setStatus("error");
|
||||
}
|
||||
} catch {
|
||||
setConfig(previousConfig);
|
||||
setStatus("error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -243,8 +248,6 @@ export default function MemorySkillsTab() {
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-muted mt-3">{t("skillsComingSoon")}</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,8 +16,6 @@ import CodexServiceTierTab from "./components/CodexServiceTierTab";
|
||||
import SystemPromptTab from "./components/SystemPromptTab";
|
||||
import ModelAliasesTab from "./components/ModelAliasesTab";
|
||||
import BackgroundDegradationTab from "./components/BackgroundDegradationTab";
|
||||
|
||||
import CacheStatsCard from "./components/CacheStatsCard";
|
||||
import CacheSettingsTab from "./components/CacheSettingsTab";
|
||||
import MemorySkillsTab from "./components/MemorySkillsTab";
|
||||
import ResilienceTab from "./components/ResilienceTab";
|
||||
@@ -95,7 +93,6 @@ export default function SettingsPage() {
|
||||
<ThinkingBudgetTab />
|
||||
<CodexServiceTierTab />
|
||||
<SystemPromptTab />
|
||||
<CacheStatsCard />
|
||||
<CacheSettingsTab />
|
||||
<MemorySkillsTab />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../../shared/utils/apiAuth", () => ({
|
||||
isAuthenticated: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../lib/localDb", () => ({
|
||||
getSettings: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../lib/memory/settings", async () => {
|
||||
const actual = await vi.importActual("../../../../lib/memory/settings");
|
||||
return {
|
||||
...actual,
|
||||
invalidateMemorySettingsCache: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { GET, PUT } from "../memory/route";
|
||||
import { isAuthenticated } from "../../../../shared/utils/apiAuth";
|
||||
import { getSettings, updateSettings } from "../../../../lib/localDb";
|
||||
import { invalidateMemorySettingsCache } from "../../../../lib/memory/settings";
|
||||
|
||||
function createRequest(method: "GET" | "PUT", body?: unknown) {
|
||||
return new Request("http://localhost/api/settings/memory", {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("/api/settings/memory", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
(isAuthenticated as any).mockResolvedValue(true);
|
||||
(getSettings as any).mockResolvedValue({
|
||||
memoryEnabled: true,
|
||||
memoryMaxTokens: 2000,
|
||||
memoryRetentionDays: 30,
|
||||
memoryStrategy: "hybrid",
|
||||
skillsEnabled: false,
|
||||
});
|
||||
(updateSettings as any).mockImplementation(async (updates: Record<string, unknown>) => ({
|
||||
memoryEnabled: true,
|
||||
memoryMaxTokens: 2000,
|
||||
memoryRetentionDays: 30,
|
||||
memoryStrategy: "hybrid",
|
||||
skillsEnabled: false,
|
||||
...updates,
|
||||
}));
|
||||
});
|
||||
|
||||
it("returns normalized memory and skills settings", async () => {
|
||||
(getSettings as any).mockResolvedValue({
|
||||
memoryEnabled: false,
|
||||
memoryMaxTokens: 3200,
|
||||
memoryRetentionDays: 999,
|
||||
memoryStrategy: "recent",
|
||||
skillsEnabled: true,
|
||||
});
|
||||
|
||||
const res = await GET(createRequest("GET") as any);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await expect(res.json()).resolves.toEqual({
|
||||
enabled: false,
|
||||
maxTokens: 3200,
|
||||
retentionDays: 365,
|
||||
strategy: "recent",
|
||||
skillsEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("persists updates and clears the cached settings snapshot", async () => {
|
||||
const res = await PUT(
|
||||
createRequest("PUT", {
|
||||
enabled: false,
|
||||
maxTokens: 0,
|
||||
retentionDays: 14,
|
||||
strategy: "semantic",
|
||||
skillsEnabled: true,
|
||||
}) as any
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(updateSettings).toHaveBeenCalledOnce();
|
||||
expect(updateSettings).toHaveBeenCalledWith({
|
||||
memoryEnabled: false,
|
||||
memoryMaxTokens: 0,
|
||||
memoryRetentionDays: 14,
|
||||
memoryStrategy: "semantic",
|
||||
skillsEnabled: true,
|
||||
});
|
||||
expect(invalidateMemorySettingsCache).toHaveBeenCalledOnce();
|
||||
await expect(res.json()).resolves.toEqual({
|
||||
enabled: false,
|
||||
maxTokens: 0,
|
||||
retentionDays: 14,
|
||||
strategy: "semantic",
|
||||
skillsEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import {
|
||||
invalidateMemorySettingsCache,
|
||||
normalizeMemorySettings,
|
||||
toMemorySettingsUpdates,
|
||||
} from "@/lib/memory/settings";
|
||||
|
||||
const memorySettingsUpdateSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
maxTokens: z.number().int().min(0).max(16000).optional(),
|
||||
retentionDays: z.number().int().min(1).max(365).optional(),
|
||||
strategy: z.enum(["recent", "semantic", "hybrid"]).optional(),
|
||||
skillsEnabled: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = (await getSettings()) as Record<string, unknown>;
|
||||
return NextResponse.json(normalizeMemorySettings(settings));
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const validation = validateBody(memorySettingsUpdateSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return validation.response;
|
||||
}
|
||||
|
||||
const updates = toMemorySettingsUpdates(validation.data);
|
||||
const settings = (await updateSettings(updates)) as Record<string, unknown>;
|
||||
invalidateMemorySettingsCache();
|
||||
|
||||
return NextResponse.json(normalizeMemorySettings(settings));
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export async function retrieveMemories(
|
||||
const normalizedConfig = MemoryConfigSchema.parse({
|
||||
enabled: true,
|
||||
maxTokens: 2000,
|
||||
retrievalStrategy: "recent",
|
||||
retrievalStrategy: "exact",
|
||||
autoSummarize: false,
|
||||
persistAcrossModels: false,
|
||||
retentionDays: 30,
|
||||
@@ -29,6 +29,10 @@ export async function retrieveMemories(
|
||||
...config,
|
||||
});
|
||||
|
||||
if (!normalizedConfig.enabled || normalizedConfig.maxTokens <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const maxTokens = Math.min(Math.max(normalizedConfig.maxTokens, 100), 8000);
|
||||
const strategy = normalizedConfig.retrievalStrategy;
|
||||
|
||||
@@ -37,9 +41,18 @@ export async function retrieveMemories(
|
||||
let totalTokens = 0;
|
||||
|
||||
// Build base query
|
||||
let query = "SELECT * FROM memory WHERE apiKeyId = ?";
|
||||
let query =
|
||||
"SELECT * FROM memory WHERE apiKeyId = ? AND (expiresAt IS NULL OR datetime(expiresAt) > datetime('now'))";
|
||||
const params: any[] = [apiKeyId];
|
||||
|
||||
if (normalizedConfig.retentionDays > 0) {
|
||||
const cutoff = new Date(
|
||||
Date.now() - normalizedConfig.retentionDays * 24 * 60 * 60 * 1000
|
||||
).toISOString();
|
||||
query += " AND datetime(createdAt) >= datetime(?)";
|
||||
params.push(cutoff);
|
||||
}
|
||||
|
||||
// Add ordering based on strategy
|
||||
switch (strategy) {
|
||||
case "semantic":
|
||||
|
||||
@@ -6,7 +6,7 @@ import { MemoryType } from "./types";
|
||||
*/
|
||||
export const MemoryConfigSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
maxTokens: z.number().int().positive(),
|
||||
maxTokens: z.number().int().nonnegative(),
|
||||
retrievalStrategy: z.enum(["exact", "semantic", "hybrid"]).optional(),
|
||||
autoSummarize: z.boolean(),
|
||||
persistAcrossModels: z.boolean(),
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { getSettings } from "@/lib/db/settings";
|
||||
import type { MemoryConfig } from "./types";
|
||||
|
||||
export interface MemorySettings {
|
||||
enabled: boolean;
|
||||
maxTokens: number;
|
||||
retentionDays: number;
|
||||
strategy: "recent" | "semantic" | "hybrid";
|
||||
skillsEnabled: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_MEMORY_SETTINGS: MemorySettings = {
|
||||
enabled: true,
|
||||
maxTokens: 2000,
|
||||
retentionDays: 30,
|
||||
strategy: "hybrid",
|
||||
skillsEnabled: false,
|
||||
};
|
||||
|
||||
let cachedMemorySettings: MemorySettings | null = null;
|
||||
|
||||
function toBoolean(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
function clampInteger(value: unknown, fallback: number, min: number, max: number): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
||||
return Math.min(Math.max(Math.round(value), min), max);
|
||||
}
|
||||
|
||||
function normalizeStrategy(value: unknown): MemorySettings["strategy"] {
|
||||
return value === "recent" || value === "semantic" || value === "hybrid"
|
||||
? value
|
||||
: DEFAULT_MEMORY_SETTINGS.strategy;
|
||||
}
|
||||
|
||||
export function normalizeMemorySettings(rawSettings: Record<string, unknown> = {}): MemorySettings {
|
||||
return {
|
||||
enabled: toBoolean(rawSettings.memoryEnabled, DEFAULT_MEMORY_SETTINGS.enabled),
|
||||
maxTokens: clampInteger(
|
||||
rawSettings.memoryMaxTokens,
|
||||
DEFAULT_MEMORY_SETTINGS.maxTokens,
|
||||
0,
|
||||
16000
|
||||
),
|
||||
retentionDays: clampInteger(
|
||||
rawSettings.memoryRetentionDays,
|
||||
DEFAULT_MEMORY_SETTINGS.retentionDays,
|
||||
1,
|
||||
365
|
||||
),
|
||||
strategy: normalizeStrategy(rawSettings.memoryStrategy),
|
||||
skillsEnabled: toBoolean(rawSettings.skillsEnabled, DEFAULT_MEMORY_SETTINGS.skillsEnabled),
|
||||
};
|
||||
}
|
||||
|
||||
export function toMemorySettingsUpdates(
|
||||
settings: Partial<MemorySettings>
|
||||
): Record<string, unknown> {
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
if (settings.enabled !== undefined) updates.memoryEnabled = settings.enabled;
|
||||
if (settings.maxTokens !== undefined) updates.memoryMaxTokens = settings.maxTokens;
|
||||
if (settings.retentionDays !== undefined) updates.memoryRetentionDays = settings.retentionDays;
|
||||
if (settings.strategy !== undefined) updates.memoryStrategy = settings.strategy;
|
||||
if (settings.skillsEnabled !== undefined) updates.skillsEnabled = settings.skillsEnabled;
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
export function toMemoryRetrievalConfig(settings: MemorySettings): Partial<MemoryConfig> {
|
||||
const enabled = settings.enabled && settings.maxTokens > 0;
|
||||
|
||||
return {
|
||||
enabled,
|
||||
maxTokens: enabled ? settings.maxTokens : 0,
|
||||
retrievalStrategy: settings.strategy === "recent" ? "exact" : settings.strategy,
|
||||
autoSummarize: false,
|
||||
persistAcrossModels: false,
|
||||
retentionDays: settings.retentionDays,
|
||||
scope: "apiKey",
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMemorySettings(): Promise<MemorySettings> {
|
||||
if (cachedMemorySettings !== null) {
|
||||
return cachedMemorySettings;
|
||||
}
|
||||
|
||||
const settings = (await getSettings()) as Record<string, unknown>;
|
||||
cachedMemorySettings = normalizeMemorySettings(settings);
|
||||
return cachedMemorySettings;
|
||||
}
|
||||
|
||||
export function invalidateMemorySettingsCache(): void {
|
||||
cachedMemorySettings = null;
|
||||
}
|
||||
@@ -279,9 +279,11 @@ describe("Page Integration — settings page wiring", () => {
|
||||
describe("Page Integration — cache page wiring", () => {
|
||||
const src = readProjectFile("src/app/(dashboard)/dashboard/cache/page.tsx");
|
||||
|
||||
it("should include cache stats card for prompt cache metrics", () => {
|
||||
it("should consolidate prompt cache metrics directly into cache management", () => {
|
||||
assert.ok(src, "src/app/(dashboard)/dashboard/cache/page.tsx should exist");
|
||||
assert.match(src, /CacheStatsCard/);
|
||||
assert.doesNotMatch(src, /CacheStatsCard/);
|
||||
assert.match(src, /promptCacheReuseRatio/);
|
||||
assert.match(src, /strategyEntries/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, test } from "node:test";
|
||||
import {
|
||||
DEFAULT_MEMORY_SETTINGS,
|
||||
normalizeMemorySettings,
|
||||
toMemoryRetrievalConfig,
|
||||
toMemorySettingsUpdates,
|
||||
} from "../../src/lib/memory/settings.ts";
|
||||
|
||||
describe("memory settings helpers", () => {
|
||||
test("normalizeMemorySettings applies defaults and clamps persisted values", () => {
|
||||
const settings = normalizeMemorySettings({
|
||||
memoryEnabled: "yes",
|
||||
memoryMaxTokens: 20001,
|
||||
memoryRetentionDays: 0,
|
||||
memoryStrategy: "unsupported",
|
||||
skillsEnabled: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(settings, {
|
||||
enabled: DEFAULT_MEMORY_SETTINGS.enabled,
|
||||
maxTokens: 16000,
|
||||
retentionDays: 1,
|
||||
strategy: DEFAULT_MEMORY_SETTINGS.strategy,
|
||||
skillsEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("toMemorySettingsUpdates maps UI fields to persisted keys", () => {
|
||||
assert.deepEqual(
|
||||
toMemorySettingsUpdates({
|
||||
enabled: false,
|
||||
maxTokens: 4096,
|
||||
retentionDays: 21,
|
||||
strategy: "hybrid",
|
||||
skillsEnabled: true,
|
||||
}),
|
||||
{
|
||||
memoryEnabled: false,
|
||||
memoryMaxTokens: 4096,
|
||||
memoryRetentionDays: 21,
|
||||
memoryStrategy: "hybrid",
|
||||
skillsEnabled: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("toMemoryRetrievalConfig disables injection when memory is off and remaps recent strategy", () => {
|
||||
assert.deepEqual(
|
||||
toMemoryRetrievalConfig({
|
||||
enabled: true,
|
||||
maxTokens: 0,
|
||||
retentionDays: 10,
|
||||
strategy: "recent",
|
||||
skillsEnabled: false,
|
||||
}),
|
||||
{
|
||||
enabled: false,
|
||||
maxTokens: 0,
|
||||
retrievalStrategy: "exact",
|
||||
autoSummarize: false,
|
||||
persistAcrossModels: false,
|
||||
retentionDays: 10,
|
||||
scope: "apiKey",
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user