mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 05:32:19 +03:00
feat(radar): persist local catalog state
This commit is contained in:
361
src/app/(dashboard)/dashboard/radar/RadarCatalogTable.tsx
Normal file
361
src/app/(dashboard)/dashboard/radar/RadarCatalogTable.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
export interface RadarMergedEntry {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
monthlyTokens: number;
|
||||
creditTokens: number;
|
||||
freeType: string;
|
||||
poolKey: string | null;
|
||||
tos: string;
|
||||
trainsOnPrompts?: boolean;
|
||||
enabled?: boolean;
|
||||
origin: "baseline" | "radar" | "local";
|
||||
disabledBy?: "radar";
|
||||
contextWindow?: number | null;
|
||||
capabilities?: { tools: boolean; vision: boolean; thinking: boolean };
|
||||
budget?: { kind: string; tokensPerMonth?: number; poolId?: string };
|
||||
limits?: { rpm: number | null; rpd: number | null; tpm: number | null; tpd: number | null };
|
||||
setup?: { keyUrl: string | null; steps: string[] } | null;
|
||||
}
|
||||
|
||||
interface RadarLocalModelState {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
displayName: string | null;
|
||||
enabled: boolean | null;
|
||||
tombstoned: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface RadarCatalogTableProps {
|
||||
entries: RadarMergedEntry[];
|
||||
refreshCatalog: () => Promise<void>;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
function formatTokens(value: number): string {
|
||||
if (value === 0) return "rate-only";
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(0)}K`;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function budgetLabel(entry: RadarMergedEntry): string {
|
||||
if (entry.budget?.kind === "shared_pool") {
|
||||
return `shared (${formatTokens(entry.budget.tokensPerMonth ?? entry.monthlyTokens)}/mo)`;
|
||||
}
|
||||
if (entry.budget?.kind === "rate_only" || entry.monthlyTokens === 0) return "rate-only";
|
||||
return `${formatTokens(entry.monthlyTokens)}/mo`;
|
||||
}
|
||||
|
||||
export function RadarCatalogTable({ entries, refreshCatalog, onError }: RadarCatalogTableProps) {
|
||||
const t = useTranslations("radarPage");
|
||||
const [states, setStates] = useState<RadarLocalModelState[]>([]);
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const loadState = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/radar/local-model-state");
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json();
|
||||
setStates(Array.isArray(payload.states) ? payload.states : []);
|
||||
} catch {
|
||||
onError(t("errorLoading"));
|
||||
}
|
||||
}, [onError, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadState();
|
||||
}, [loadState]);
|
||||
|
||||
const stateByKey = useMemo(
|
||||
() => new Map(states.map((state) => [`${state.provider}:${state.modelId}`, state])),
|
||||
[states]
|
||||
);
|
||||
const hiddenModels = useMemo(() => states.filter((state) => state.tombstoned), [states]);
|
||||
|
||||
const applyResponse = useCallback(async (response: Response) => {
|
||||
if (!response.ok) throw new Error("save_failed");
|
||||
const payload = await response.json();
|
||||
setStates(Array.isArray(payload.states) ? payload.states : []);
|
||||
}, []);
|
||||
|
||||
const mutate = useCallback(
|
||||
async (operation: () => Promise<Response>) => {
|
||||
setSaving(true);
|
||||
onError("");
|
||||
try {
|
||||
await applyResponse(await operation());
|
||||
setEditingKey(null);
|
||||
await refreshCatalog();
|
||||
} catch {
|
||||
onError(t("localStateSaveFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[applyResponse, onError, refreshCatalog, t]
|
||||
);
|
||||
|
||||
const beginEdit = useCallback((entry: RadarMergedEntry) => {
|
||||
setEditingKey(`${entry.provider}:${entry.modelId}`);
|
||||
setDisplayName(entry.displayName);
|
||||
setEnabled(entry.enabled !== false);
|
||||
}, []);
|
||||
|
||||
const saveOverride = useCallback(
|
||||
(entry: RadarMergedEntry) =>
|
||||
mutate(() =>
|
||||
fetch("/api/radar/local-model-state", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: entry.provider,
|
||||
modelId: entry.modelId,
|
||||
displayName,
|
||||
enabled,
|
||||
}),
|
||||
})
|
||||
),
|
||||
[displayName, enabled, mutate]
|
||||
);
|
||||
|
||||
const resetOverride = useCallback(
|
||||
(entry: Pick<RadarMergedEntry, "provider" | "modelId">) => {
|
||||
const query = new URLSearchParams({ provider: entry.provider, modelId: entry.modelId });
|
||||
return mutate(() =>
|
||||
fetch(`/api/radar/local-model-state?${query.toString()}`, { method: "DELETE" })
|
||||
);
|
||||
},
|
||||
[mutate]
|
||||
);
|
||||
|
||||
const setTombstone = useCallback(
|
||||
(provider: string, modelId: string, tombstoned: boolean) =>
|
||||
mutate(() =>
|
||||
fetch("/api/radar/local-model-state", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, modelId, tombstoned }),
|
||||
})
|
||||
),
|
||||
[mutate]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-sm text-text-muted border-b border-border">
|
||||
<th className="pb-3 font-medium">{t("colProvider")}</th>
|
||||
<th className="pb-3 font-medium">{t("colModel")}</th>
|
||||
<th className="pb-3 font-medium">{t("colQuota")}</th>
|
||||
<th className="pb-3 font-medium">{t("colContext")}</th>
|
||||
<th className="pb-3 font-medium">{t("colCapabilities")}</th>
|
||||
<th className="pb-3 font-medium">{t("colTos")}</th>
|
||||
<th className="pb-3 font-medium text-right">{t("colActions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => {
|
||||
const key = `${entry.provider}:${entry.modelId}`;
|
||||
const localState = stateByKey.get(key);
|
||||
const hasOverride =
|
||||
localState && (localState.displayName !== null || localState.enabled !== null);
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
className={`border-b border-border/50 last:border-b-0 ${
|
||||
entry.enabled === false ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<td className="py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{entry.provider}</span>
|
||||
{entry.origin === "radar" && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-400 font-medium">
|
||||
{t("newBadge")}
|
||||
</span>
|
||||
)}
|
||||
{entry.origin === "local" && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 font-medium">
|
||||
{t("localBadge")}
|
||||
</span>
|
||||
)}
|
||||
{entry.setup?.keyUrl && (
|
||||
<Link
|
||||
href={`/dashboard/radar/setup?provider=${encodeURIComponent(entry.provider)}`}
|
||||
className="text-xs text-violet-400 hover:underline"
|
||||
title={t("setupGuide")}
|
||||
>
|
||||
⚙
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{entry.enabled === false && entry.disabledBy === "radar" && (
|
||||
<p className="text-xs text-red-400 mt-0.5">{t("disabledByFeed")}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 text-text-muted text-sm font-mono max-w-[240px]">
|
||||
{editingKey === key ? (
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
aria-label={t("modelDisplayName")}
|
||||
maxLength={160}
|
||||
className="w-full min-w-[180px] px-2 py-1 rounded border border-border bg-transparent text-text-main focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
/>
|
||||
) : (
|
||||
<span className="block truncate">{entry.displayName}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 text-sm">{budgetLabel(entry)}</td>
|
||||
<td className="py-3 text-sm text-text-muted">
|
||||
{entry.contextWindow ? `${(entry.contextWindow / 1000).toFixed(0)}K` : "—"}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<div className="flex gap-1">
|
||||
{entry.capabilities?.tools && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">
|
||||
{t("capTools")}
|
||||
</span>
|
||||
)}
|
||||
{entry.capabilities?.vision && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-400">
|
||||
{t("capVision")}
|
||||
</span>
|
||||
)}
|
||||
{entry.capabilities?.thinking && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400">
|
||||
{t("capThinking")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
entry.tos === "ok"
|
||||
? "bg-green-500/10 text-green-400"
|
||||
: entry.tos === "caution"
|
||||
? "bg-yellow-500/10 text-yellow-400"
|
||||
: entry.tos === "avoid"
|
||||
? "bg-red-500/10 text-red-400"
|
||||
: "bg-gray-500/10 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{entry.tos}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 pl-3">
|
||||
{editingKey === key ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<label className="inline-flex items-center gap-1 text-xs text-text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
disabled={entry.disabledBy === "radar"}
|
||||
onChange={(event) => setEnabled(event.target.checked)}
|
||||
/>
|
||||
{t("modelEnabled")}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || displayName.trim().length === 0}
|
||||
onClick={() => void saveOverride(entry)}
|
||||
className="text-xs text-violet-400 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{t("saveModel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => setEditingKey(null)}
|
||||
className="text-xs text-text-muted hover:text-text-main disabled:opacity-50"
|
||||
>
|
||||
{t("cancelEdit")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => beginEdit(entry)}
|
||||
className="text-xs text-violet-400 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{t("editModel")}
|
||||
</button>
|
||||
{hasOverride && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => void resetOverride(entry)}
|
||||
className="text-xs text-text-muted hover:text-text-main disabled:opacity-50"
|
||||
>
|
||||
{t("resetModel")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => void setTombstone(entry.provider, entry.modelId, true)}
|
||||
className="text-xs text-red-400 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{t("hideModel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{hiddenModels.length > 0 && (
|
||||
<Card>
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-semibold">{t("hiddenModelsTitle")}</h3>
|
||||
{hiddenModels.map((state) => (
|
||||
<div
|
||||
key={`${state.provider}:${state.modelId}:hidden`}
|
||||
className="flex flex-wrap items-center justify-between gap-3 border-b border-border/50 pb-3 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium">{state.provider}</span>
|
||||
<span className="ml-2 text-sm font-mono text-text-muted">
|
||||
{state.displayName ?? state.modelId}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => void setTombstone(state.provider, state.modelId, false)}
|
||||
className="text-sm text-violet-400 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{t("restoreModel")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,10 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Card } from "@/shared/components";
|
||||
import { shouldAutoSyncOnOpen } from "@/lib/radar/autoSync";
|
||||
import { isValidSupporterKeyFormat } from "@/lib/radar/supporterKey";
|
||||
import { RadarCatalogTable, type RadarMergedEntry } from "./RadarCatalogTable";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -18,27 +18,6 @@ interface RadarMeta {
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
interface RadarMergedEntry {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
monthlyTokens: number;
|
||||
creditTokens: number;
|
||||
freeType: string;
|
||||
poolKey: string | null;
|
||||
tos: string;
|
||||
trainsOnPrompts?: boolean;
|
||||
enabled?: boolean;
|
||||
origin: "baseline" | "radar" | "local";
|
||||
disabledBy?: "radar";
|
||||
// Extended feed fields (present when origin=radar)
|
||||
contextWindow?: number | null;
|
||||
capabilities?: { tools: boolean; vision: boolean; thinking: boolean };
|
||||
budget?: { kind: string; tokensPerMonth?: number; poolId?: string };
|
||||
limits?: { rpm: number | null; rpd: number | null; tpm: number | null; tpd: number | null };
|
||||
setup?: { keyUrl: string | null; steps: string[] } | null;
|
||||
}
|
||||
|
||||
type PageState = "flag_off" | "optin_pending" | "empty" | "populated";
|
||||
|
||||
/** D28 — referral links / free credits. Client-side mirror of RadarReferral. */
|
||||
@@ -67,7 +46,7 @@ type RadarTabId = "catalog" | "referrals";
|
||||
export function resolveRadarPageState(
|
||||
flagOn: boolean,
|
||||
optedIn: boolean,
|
||||
hasEntries: boolean,
|
||||
hasEntries: boolean
|
||||
): PageState {
|
||||
if (!flagOn) return "flag_off";
|
||||
if (!optedIn) return "optin_pending";
|
||||
@@ -90,23 +69,6 @@ function relativeTime(isoDate: string): string {
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
/** Format token count as human-readable. */
|
||||
function formatTokens(n: number): string {
|
||||
if (n === 0) return "rate-only";
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/** Budget display string. */
|
||||
function budgetLabel(entry: RadarMergedEntry): string {
|
||||
if (entry.budget?.kind === "shared_pool") {
|
||||
return `shared (${formatTokens(entry.budget.tokensPerMonth ?? entry.monthlyTokens)}/mo)`;
|
||||
}
|
||||
if (entry.budget?.kind === "rate_only" || entry.monthlyTokens === 0) return "rate-only";
|
||||
return `${formatTokens(entry.monthlyTokens)}/mo`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page Component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -144,31 +106,34 @@ export default function RadarPage() {
|
||||
const [hasSupporterKey, setHasSupporterKey] = useState(false);
|
||||
const [supporterKeyMasked, setSupporterKeyMasked] = useState<string | null>(null);
|
||||
const [showKeyForm, setShowKeyForm] = useState(false);
|
||||
|
||||
// Fetch catalog
|
||||
const fetchCatalog = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/radar/catalog");
|
||||
if (res.status === 404) {
|
||||
// Flag off — treat as not found
|
||||
setOptIn(false);
|
||||
setEntries([]);
|
||||
setMeta(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
const fetchCatalog = useCallback(
|
||||
async (showLoading = true) => {
|
||||
if (showLoading) setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/radar/catalog");
|
||||
if (res.status === 404) {
|
||||
// Flag off — treat as not found
|
||||
setOptIn(false);
|
||||
setEntries([]);
|
||||
setMeta(null);
|
||||
if (showLoading) setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
setEntries(data.entries || []);
|
||||
setMeta(data.meta || null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("errorLoading"));
|
||||
} finally {
|
||||
if (showLoading) setLoading(false);
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
setEntries(data.entries || []);
|
||||
setMeta(data.meta || null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("errorLoading"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
},
|
||||
[t]
|
||||
);
|
||||
const refreshCatalogSilently = useCallback(() => fetchCatalog(false), [fetchCatalog]);
|
||||
|
||||
// D28 — fetch the referral links section ("Pegue seus créditos grátis").
|
||||
// Best-effort: flag off => 404, no cache => empty shape; either way this
|
||||
@@ -204,7 +169,7 @@ export default function RadarPage() {
|
||||
setOptIn(settingsData.optIn === true);
|
||||
setHasSupporterKey(settingsData.hasSupporterKey === true);
|
||||
setSupporterKeyMasked(
|
||||
typeof settingsData.supporterKeyMasked === "string" ? settingsData.supporterKeyMasked : null,
|
||||
typeof settingsData.supporterKeyMasked === "string" ? settingsData.supporterKeyMasked : null
|
||||
);
|
||||
// F4/T7 — best-effort: keep whatever we already had if the field is
|
||||
// absent (older cached response shape), never fall back to a literal.
|
||||
@@ -334,7 +299,7 @@ export default function RadarPage() {
|
||||
const pageState = resolveRadarPageState(
|
||||
optIn !== false, // if we got a 404, optIn=false => flag off
|
||||
optIn === true,
|
||||
entries.length > 0 && meta !== null,
|
||||
meta !== null
|
||||
);
|
||||
|
||||
// Flag off — render not-found
|
||||
@@ -379,9 +344,7 @@ export default function RadarPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm">{error}</div>
|
||||
)}
|
||||
{error && <div className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center min-h-[200px]">
|
||||
@@ -625,98 +588,11 @@ export default function RadarPage() {
|
||||
|
||||
{/* Populated catalog table */}
|
||||
{pageState === "populated" && activeTab === "catalog" && (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-sm text-text-muted border-b border-border">
|
||||
<th className="pb-3 font-medium">{t("colProvider")}</th>
|
||||
<th className="pb-3 font-medium">{t("colModel")}</th>
|
||||
<th className="pb-3 font-medium">{t("colQuota")}</th>
|
||||
<th className="pb-3 font-medium">{t("colContext")}</th>
|
||||
<th className="pb-3 font-medium">{t("colCapabilities")}</th>
|
||||
<th className="pb-3 font-medium">{t("colTos")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr
|
||||
key={`${entry.provider}:${entry.modelId}`}
|
||||
className={`border-b border-border/50 last:border-b-0 ${
|
||||
entry.enabled === false ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<td className="py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{entry.provider}</span>
|
||||
{entry.origin === "radar" && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-400 font-medium">
|
||||
{t("newBadge")}
|
||||
</span>
|
||||
)}
|
||||
{entry.setup?.keyUrl && (
|
||||
<Link
|
||||
href={`/dashboard/radar/setup?provider=${encodeURIComponent(entry.provider)}`}
|
||||
className="text-xs text-violet-400 hover:underline"
|
||||
title={t("setupGuide")}
|
||||
>
|
||||
⚙
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{entry.enabled === false && entry.disabledBy === "radar" && (
|
||||
<p className="text-xs text-red-400 mt-0.5">{t("disabledByFeed")}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 text-text-muted text-sm font-mono truncate max-w-[200px]">
|
||||
{entry.displayName}
|
||||
</td>
|
||||
<td className="py-3 text-sm">{budgetLabel(entry)}</td>
|
||||
<td className="py-3 text-sm text-text-muted">
|
||||
{entry.contextWindow
|
||||
? `${(entry.contextWindow / 1000).toFixed(0)}K`
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<div className="flex gap-1">
|
||||
{entry.capabilities?.tools && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">
|
||||
{t("capTools")}
|
||||
</span>
|
||||
)}
|
||||
{entry.capabilities?.vision && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-400">
|
||||
{t("capVision")}
|
||||
</span>
|
||||
)}
|
||||
{entry.capabilities?.thinking && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400">
|
||||
{t("capThinking")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
entry.tos === "ok"
|
||||
? "bg-green-500/10 text-green-400"
|
||||
: entry.tos === "caution"
|
||||
? "bg-yellow-500/10 text-yellow-400"
|
||||
: entry.tos === "avoid"
|
||||
? "bg-red-500/10 text-red-400"
|
||||
: "bg-gray-500/10 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{entry.tos}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<RadarCatalogTable
|
||||
entries={entries}
|
||||
refreshCatalog={refreshCatalogSilently}
|
||||
onError={setError}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
165
src/app/api/radar/local-model-state/route.ts
Normal file
165
src/app/api/radar/local-model-state/route.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Local-only Radar model overrides and tombstones.
|
||||
*
|
||||
* The browser never sends these settings to the private Radar server. The
|
||||
* route is management-authenticated and exists only while RADAR_ENABLED is on.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import {
|
||||
clearRadarLocalModelOverride,
|
||||
listRadarLocalModelState,
|
||||
setRadarLocalModelOverride,
|
||||
setRadarModelTombstone,
|
||||
} from "@/lib/db/radar";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
|
||||
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
const providerSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[a-z0-9][a-z0-9._-]{0,99}$/i);
|
||||
const modelIdSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.refine((value) => !/[\u0000-\u001f\u007f]/.test(value));
|
||||
const identityShape = { provider: providerSchema, modelId: modelIdSchema };
|
||||
|
||||
const overrideSchema = z
|
||||
.object({
|
||||
...identityShape,
|
||||
displayName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(160)
|
||||
.refine((value) => !/[\u0000-\u001f\u007f]/.test(value))
|
||||
.nullable()
|
||||
.optional(),
|
||||
enabled: z.boolean().nullable().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(value) =>
|
||||
Object.prototype.hasOwnProperty.call(value, "displayName") ||
|
||||
Object.prototype.hasOwnProperty.call(value, "enabled")
|
||||
);
|
||||
|
||||
const tombstoneSchema = z.object({ ...identityShape, tombstoned: z.boolean() }).strict();
|
||||
const identitySchema = z.object(identityShape).strict();
|
||||
|
||||
function json(body: unknown, status = 200): NextResponse {
|
||||
return NextResponse.json(body, {
|
||||
status,
|
||||
headers: { ...CORS_HEADERS, "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
function error(status: number, message: string): NextResponse {
|
||||
return json(buildErrorBody(status, message), status);
|
||||
}
|
||||
|
||||
async function authorize(request: Request): Promise<NextResponse | null> {
|
||||
if (!isFeatureFlagEnabled("RADAR_ENABLED")) return error(404, "Not found");
|
||||
if (!(await isAuthenticated(request))) return error(401, "Unauthorized");
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readJson(request: Request): Promise<unknown | null> {
|
||||
try {
|
||||
return await request.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stateResponse(): NextResponse {
|
||||
return json({ states: listRadarLocalModelState() });
|
||||
}
|
||||
|
||||
function internalError(cause: unknown): NextResponse {
|
||||
return error(500, sanitizeErrorMessage(cause) || "Failed to update Radar local state");
|
||||
}
|
||||
|
||||
export async function OPTIONS(): Promise<Response> {
|
||||
return handleCorsOptions();
|
||||
}
|
||||
|
||||
export async function GET(request: Request): Promise<NextResponse> {
|
||||
const authError = await authorize(request);
|
||||
if (authError) return authError;
|
||||
try {
|
||||
return stateResponse();
|
||||
} catch (cause: unknown) {
|
||||
return internalError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request): Promise<NextResponse> {
|
||||
const authError = await authorize(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const parsed = overrideSchema.safeParse(await readJson(request));
|
||||
if (!parsed.success) return error(400, "Invalid Radar local override");
|
||||
|
||||
try {
|
||||
const { provider, modelId, displayName, enabled } = parsed.data;
|
||||
const patch: { displayName?: string | null; enabled?: boolean | null } = {};
|
||||
if (Object.prototype.hasOwnProperty.call(parsed.data, "displayName")) {
|
||||
patch.displayName = displayName;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(parsed.data, "enabled")) patch.enabled = enabled;
|
||||
if (!setRadarLocalModelOverride(provider, modelId, patch)) {
|
||||
return error(400, "Invalid Radar local override");
|
||||
}
|
||||
return stateResponse();
|
||||
} catch (cause: unknown) {
|
||||
return internalError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request): Promise<NextResponse> {
|
||||
const authError = await authorize(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const parsed = tombstoneSchema.safeParse(await readJson(request));
|
||||
if (!parsed.success) return error(400, "Invalid Radar tombstone");
|
||||
|
||||
try {
|
||||
const { provider, modelId, tombstoned } = parsed.data;
|
||||
if (!setRadarModelTombstone(provider, modelId, tombstoned)) {
|
||||
return error(400, "Invalid Radar tombstone");
|
||||
}
|
||||
return stateResponse();
|
||||
} catch (cause: unknown) {
|
||||
return internalError(cause);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request): Promise<NextResponse> {
|
||||
const authError = await authorize(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(request.url);
|
||||
const parsed = identitySchema.safeParse({
|
||||
provider: url.searchParams.get("provider"),
|
||||
modelId: url.searchParams.get("modelId"),
|
||||
});
|
||||
if (!parsed.success) return error(400, "provider and modelId are required");
|
||||
|
||||
try {
|
||||
if (!clearRadarLocalModelOverride(parsed.data.provider, parsed.data.modelId)) {
|
||||
return error(400, "Invalid Radar local override");
|
||||
}
|
||||
return stateResponse();
|
||||
} catch (cause: unknown) {
|
||||
return internalError(cause);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user