fix(ui): restore provider assets and model availability endpoint (#367)

* fix(usage): track lifetime request total beyond history cap

* fix(ui): restore provider assets and model availability endpoint
This commit is contained in:
Andrew Peltekci
2026-03-22 19:16:10 -07:00
committed by GitHub
parent 5fedcad624
commit 9fe4726f34
11 changed files with 707 additions and 258 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

View File

@@ -1,11 +1,24 @@
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
import PropTypes from "prop-types";
import { Card, CardSkeleton, Badge, Button, Input, Modal, Select, Toggle } from "@/shared/components";
import {
Card,
CardSkeleton,
Badge,
Button,
Input,
Modal,
Select,
Toggle,
} from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
import { FREE_PROVIDERS, OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX } from "@/shared/constants/providers";
import {
FREE_PROVIDERS,
OPENAI_COMPATIBLE_PREFIX,
ANTHROPIC_COMPATIBLE_PREFIX,
} from "@/shared/constants/providers";
import Link from "next/link";
import { getErrorCode, getRelativeTime } from "@/shared/utils";
import { useNotificationStore } from "@/store/notificationStore";
@@ -17,15 +30,17 @@ function getStatusDisplay(connected, error, errorCode) {
parts.push(
<Badge key="connected" variant="success" size="sm" dot>
{connected} Connected
</Badge>
</Badge>,
);
}
if (error > 0) {
const errText = errorCode ? `${error} Error (${errorCode})` : `${error} Error`;
const errText = errorCode
? `${error} Error (${errorCode})`
: `${error} Error`;
parts.push(
<Badge key="error" variant="error" size="sm" dot>
{errText}
</Badge>
</Badge>,
);
}
if (parts.length === 0) {
@@ -44,21 +59,34 @@ function getConnectionErrorTag(connection) {
explicitType === "auth_missing" ||
explicitType === "token_refresh_failed" ||
explicitType === "token_expired"
) return "AUTH";
)
return "AUTH";
if (explicitType === "upstream_rate_limited") return "429";
if (explicitType === "upstream_unavailable") return "5XX";
if (explicitType === "network_error") return "NET";
const numericCode = Number(connection.errorCode);
if (Number.isFinite(numericCode) && numericCode >= 400) return String(numericCode);
if (Number.isFinite(numericCode) && numericCode >= 400)
return String(numericCode);
const fromMessage = getErrorCode(connection.lastError);
if (fromMessage === "401" || fromMessage === "403") return "AUTH";
if (fromMessage && fromMessage !== "ERR") return fromMessage;
const msg = (connection.lastError || "").toLowerCase();
if (msg.includes("runtime") || msg.includes("not runnable") || msg.includes("not installed")) return "RUNTIME";
if (msg.includes("invalid api key") || msg.includes("token invalid") || msg.includes("revoked") || msg.includes("unauthorized")) return "AUTH";
if (
msg.includes("runtime") ||
msg.includes("not runnable") ||
msg.includes("not installed")
)
return "RUNTIME";
if (
msg.includes("invalid api key") ||
msg.includes("token invalid") ||
msg.includes("revoked") ||
msg.includes("unauthorized")
)
return "AUTH";
return "ERR";
}
@@ -68,7 +96,8 @@ export default function ProvidersPage() {
const [providerNodes, setProviderNodes] = useState([]);
const [loading, setLoading] = useState(true);
const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false);
const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false);
const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] =
useState(false);
const [testingMode, setTestingMode] = useState(null);
const [testResults, setTestResults] = useState(null);
const notify = useNotificationStore();
@@ -82,7 +111,8 @@ export default function ProvidersPage() {
]);
const connectionsData = await connectionsRes.json();
const nodesData = await nodesRes.json();
if (connectionsRes.ok) setConnections(connectionsData.connections || []);
if (connectionsRes.ok)
setConnections(connectionsData.connections || []);
if (nodesRes.ok) setProviderNodes(nodesData.nodes || []);
} catch (error) {
console.log("Error fetching data:", error);
@@ -95,13 +125,17 @@ export default function ProvidersPage() {
const getProviderStats = (providerId, authType) => {
const providerConnections = connections.filter(
(c) => c.provider === providerId && c.authType === authType
(c) => c.provider === providerId && c.authType === authType,
);
const getEffectiveStatus = (conn) => {
const isCooldown = Object.entries(conn)
.some(([k, v]) => k.startsWith("modelLock_") && v && new Date(v).getTime() > Date.now());
return conn.testStatus === "unavailable" && !isCooldown ? "active" : conn.testStatus;
const isCooldown = Object.entries(conn).some(
([k, v]) =>
k.startsWith("modelLock_") && v && new Date(v).getTime() > Date.now(),
);
return conn.testStatus === "unavailable" && !isCooldown
? "active"
: conn.testStatus;
};
const connected = providerConnections.filter((c) => {
@@ -111,18 +145,23 @@ export default function ProvidersPage() {
const errorConns = providerConnections.filter((c) => {
const status = getEffectiveStatus(c);
return status === "error" || status === "expired" || status === "unavailable";
return (
status === "error" || status === "expired" || status === "unavailable"
);
});
const error = errorConns.length;
const total = providerConnections.length;
const allDisabled = total > 0 && providerConnections.every((c) => c.isActive === false);
const allDisabled =
total > 0 && providerConnections.every((c) => c.isActive === false);
const latestError = errorConns.sort(
(a, b) => new Date(b.lastErrorAt || 0) - new Date(a.lastErrorAt || 0)
(a, b) => new Date(b.lastErrorAt || 0) - new Date(a.lastErrorAt || 0),
)[0];
const errorCode = latestError ? getConnectionErrorTag(latestError) : null;
const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null;
const errorTime = latestError?.lastErrorAt
? getRelativeTime(latestError.lastErrorAt)
: null;
return { connected, error, total, errorCode, errorTime, allDisabled };
};
@@ -130,12 +169,14 @@ export default function ProvidersPage() {
// Toggle all connections for a provider on/off
const handleToggleProvider = async (providerId, authType, newActive) => {
const providerConns = connections.filter(
(c) => c.provider === providerId && c.authType === authType
(c) => c.provider === providerId && c.authType === authType,
);
setConnections((prev) =>
prev.map((c) =>
c.provider === providerId && c.authType === authType ? { ...c, isActive: newActive } : c
)
c.provider === providerId && c.authType === authType
? { ...c, isActive: newActive }
: c,
),
);
await Promise.allSettled(
providerConns.map((c) =>
@@ -143,8 +184,8 @@ export default function ProvidersPage() {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isActive: newActive }),
})
)
}),
),
);
};
@@ -214,14 +255,17 @@ export default function ProvidersPage() {
<button
onClick={() => handleBatchTest("oauth")}
disabled={!!testingMode}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${testingMode === "oauth"
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg border-border text-text-muted hover:text-text-main hover:border-primary/40"
}`}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
testingMode === "oauth"
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg border-border text-text-muted hover:text-text-main hover:border-primary/40"
}`}
title="Test all OAuth connections"
aria-label="Test all OAuth connections"
>
<span className={`material-symbols-outlined text-[14px]${testingMode === "oauth" ? " animate-spin" : ""}`}>
<span
className={`material-symbols-outlined text-[14px]${testingMode === "oauth" ? " animate-spin" : ""}`}
>
{testingMode === "oauth" ? "sync" : "play_arrow"}
</span>
{testingMode === "oauth" ? "Testing..." : "Test All"}
@@ -251,14 +295,17 @@ export default function ProvidersPage() {
<button
onClick={() => handleBatchTest("free")}
disabled={!!testingMode}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${testingMode === "free"
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg border-border text-text-muted hover:text-text-main hover:border-primary/40"
}`}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
testingMode === "free"
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg border-border text-text-muted hover:text-text-main hover:border-primary/40"
}`}
title="Test all Free connections"
aria-label="Test all Free provider connections"
>
<span className={`material-symbols-outlined text-[14px]${testingMode === "free" ? " animate-spin" : ""}`}>
<span
className={`material-symbols-outlined text-[14px]${testingMode === "free" ? " animate-spin" : ""}`}
>
{testingMode === "free" ? "sync" : "play_arrow"}
</span>
{testingMode === "free" ? "Testing..." : "Test All"}
@@ -287,14 +334,17 @@ export default function ProvidersPage() {
<button
onClick={() => handleBatchTest("apikey")}
disabled={!!testingMode}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${testingMode === "apikey"
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg border-border text-text-muted hover:text-text-main hover:border-primary/40"
}`}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
testingMode === "apikey"
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg border-border text-text-muted hover:text-text-main hover:border-primary/40"
}`}
title="Test all API Key connections"
aria-label="Test all API Key connections"
>
<span className={`material-symbols-outlined text-[14px]${testingMode === "apikey" ? " animate-spin" : ""}`}>
<span
className={`material-symbols-outlined text-[14px]${testingMode === "apikey" ? " animate-spin" : ""}`}
>
{testingMode === "apikey" ? "sync" : "play_arrow"}
</span>
{testingMode === "apikey" ? "Testing..." : "Test All"}
@@ -337,7 +387,11 @@ export default function ProvidersPage() {
{testingMode === "compatible" ? "Testing..." : "Test All"}
</button>
)} */}
<Button size="sm" icon="add" onClick={() => setShowAddAnthropicCompatibleModal(true)}>
<Button
size="sm"
icon="add"
onClick={() => setShowAddAnthropicCompatibleModal(true)}
>
Add Anthropic Compatible
</Button>
<Button
@@ -351,26 +405,36 @@ export default function ProvidersPage() {
</Button>
</div>
</div>
{compatibleProviders.length === 0 && anthropicCompatibleProviders.length === 0 ? (
{compatibleProviders.length === 0 &&
anthropicCompatibleProviders.length === 0 ? (
<div className="text-center py-8 border border-dashed border-border rounded-xl">
<span className="material-symbols-outlined text-[32px] text-text-muted mb-2">extension</span>
<p className="text-text-muted text-sm">No compatible providers added yet</p>
<span className="material-symbols-outlined text-[32px] text-text-muted mb-2">
extension
</span>
<p className="text-text-muted text-sm">
No compatible providers added yet
</p>
<p className="text-text-muted text-xs mt-1">
Use the buttons above to add OpenAI or Anthropic compatible endpoints
Use the buttons above to add OpenAI or Anthropic compatible
endpoints
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{[...compatibleProviders, ...anthropicCompatibleProviders].map((info) => (
<ApiKeyProviderCard
key={info.id}
providerId={info.id}
provider={info}
stats={getProviderStats(info.id, "apikey")}
authType="compatible"
onToggle={(active) => handleToggleProvider(info.id, "apikey", active)}
/>
))}
{[...compatibleProviders, ...anthropicCompatibleProviders].map(
(info) => (
<ApiKeyProviderCard
key={info.id}
providerId={info.id}
provider={info}
stats={getProviderStats(info.id, "apikey")}
authType="compatible"
onToggle={(active) =>
handleToggleProvider(info.id, "apikey", active)
}
/>
),
)}
</div>
)}
</div>
@@ -425,7 +489,6 @@ export default function ProvidersPage() {
function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
const { connected, error, errorCode, errorTime, allDisabled } = stats;
const [imgError, setImgError] = useState(false);
const dotColors = {
free: "bg-green-500",
@@ -433,7 +496,12 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
apikey: "bg-amber-500",
compatible: "bg-orange-500",
};
const dotLabels = { free: "Free", oauth: "OAuth", apikey: "API Key", compatible: "Compatible" };
const dotLabels = {
free: "Free",
oauth: "OAuth",
apikey: "API Key",
compatible: "Compatible",
};
return (
<Link href={`/dashboard/providers/${providerId}`} className="group">
@@ -445,40 +513,39 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
<div className="flex items-center gap-3">
<div
className="size-8 rounded-lg flex items-center justify-center"
style={{ backgroundColor: `${provider.color?.length > 7 ? provider.color : provider.color + "15"}` }}
style={{
backgroundColor: `${provider.color?.length > 7 ? provider.color : provider.color + "15"}`,
}}
>
{imgError ? (
<span className="text-xs font-bold" style={{ color: provider.color }}>
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
</span>
) : (
<Image
src={`/providers/${provider.id}.png`}
alt={provider.name}
width={30}
height={30}
className="object-contain rounded-lg max-w-[32px] max-h-[32px]"
sizes="32px"
onError={() => setImgError(true)}
/>
)}
<ProviderIcon
src={`/providers/${provider.id}.png`}
alt={provider.name}
size={30}
className="object-contain rounded-lg max-w-[32px] max-h-[32px]"
fallbackText={
provider.textIcon || provider.id.slice(0, 2).toUpperCase()
}
fallbackColor={provider.color}
/>
</div>
<div>
<h3 className="font-semibold">
{provider.name}
</h3>
<h3 className="font-semibold">{provider.name}</h3>
<div className="flex items-center gap-2 text-xs flex-wrap">
{allDisabled ? (
<Badge variant="default" size="sm">
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">pause_circle</span>
<span className="material-symbols-outlined text-[12px]">
pause_circle
</span>
Disabled
</span>
</Badge>
) : (
<>
{getStatusDisplay(connected, error, errorCode)}
{errorTime && <span className="text-text-muted">{errorTime}</span>}
{errorTime && (
<span className="text-text-muted">{errorTime}</span>
)}
</>
)}
</div>
@@ -497,7 +564,7 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
<Toggle
size="sm"
checked={!allDisabled}
onChange={() => { }}
onChange={() => {}}
title={allDisabled ? "Enable provider" : "Disable provider"}
/>
</div>
@@ -527,11 +594,18 @@ ProviderCard.propTypes = {
onToggle: PropTypes.func,
};
function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle }) {
function ApiKeyProviderCard({
providerId,
provider,
stats,
authType,
onToggle,
}) {
const { connected, error, errorCode, errorTime, allDisabled } = stats;
const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
const isAnthropicCompatible = providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
const [imgError, setImgError] = useState(false);
const isAnthropicCompatible = providerId.startsWith(
ANTHROPIC_COMPATIBLE_PREFIX,
);
const dotColors = {
free: "bg-green-500",
@@ -539,10 +613,18 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
apikey: "bg-amber-500",
compatible: "bg-orange-500",
};
const dotLabels = { free: "Free", oauth: "OAuth", apikey: "API Key", compatible: "Compatible" };
const dotLabels = {
free: "Free",
oauth: "OAuth",
apikey: "API Key",
compatible: "Compatible",
};
const getIconPath = () => {
if (isCompatible) return provider.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png";
if (isCompatible)
return provider.apiType === "responses"
? "/providers/oai-r.png"
: "/providers/oai-cc.png";
if (isAnthropicCompatible) return "/providers/anthropic-m.png";
return `/providers/${provider.id}.png`;
};
@@ -557,33 +639,30 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
<div className="flex items-center gap-3">
<div
className="size-8 rounded-lg flex items-center justify-center"
style={{ backgroundColor: `${provider.color?.length > 7 ? provider.color : provider.color + "15"}` }}
style={{
backgroundColor: `${provider.color?.length > 7 ? provider.color : provider.color + "15"}`,
}}
>
{imgError ? (
<span className="text-xs font-bold" style={{ color: provider.color }}>
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
</span>
) : (
<Image
src={getIconPath()}
alt={provider.name}
width={30}
height={30}
className="object-contain rounded-lg max-w-[30px] max-h-[30px]"
sizes="30px"
onError={() => setImgError(true)}
/>
)}
<ProviderIcon
src={getIconPath()}
alt={provider.name}
size={30}
className="object-contain rounded-lg max-w-[30px] max-h-[30px]"
fallbackText={
provider.textIcon || provider.id.slice(0, 2).toUpperCase()
}
fallbackColor={provider.color}
/>
</div>
<div>
<h3 className="font-semibold">
{provider.name}
</h3>
<h3 className="font-semibold">{provider.name}</h3>
<div className="flex items-center gap-2 text-xs flex-wrap">
{allDisabled ? (
<Badge variant="default" size="sm">
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">pause_circle</span>
<span className="material-symbols-outlined text-[12px]">
pause_circle
</span>
Disabled
</span>
</Badge>
@@ -592,13 +671,19 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
{getStatusDisplay(connected, error, errorCode)}
{isCompatible && (
<Badge variant="default" size="sm">
{provider.apiType === "responses" ? "Responses" : "Chat"}
{provider.apiType === "responses"
? "Responses"
: "Chat"}
</Badge>
)}
{isAnthropicCompatible && (
<Badge variant="default" size="sm">Messages</Badge>
<Badge variant="default" size="sm">
Messages
</Badge>
)}
{errorTime && (
<span className="text-text-muted">{errorTime}</span>
)}
{errorTime && <span className="text-text-muted">{errorTime}</span>}
</>
)}
</div>
@@ -617,7 +702,7 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
<Toggle
size="sm"
checked={!allDisabled}
onChange={() => { }}
onChange={() => {}}
title={allDisabled ? "Enable provider" : "Disable provider"}
/>
</div>
@@ -672,7 +757,12 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
}, [formData.apiType]);
const handleSubmit = async () => {
if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return;
if (
!formData.name.trim() ||
!formData.prefix.trim() ||
!formData.baseUrl.trim()
)
return;
setSubmitting(true);
try {
const res = await fetch("/api/provider-nodes", {
@@ -689,7 +779,12 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
const data = await res.json();
if (res.ok) {
onCreated(data.node);
setFormData({ name: "", prefix: "", apiType: "chat", baseUrl: "https://api.openai.com/v1" });
setFormData({
name: "",
prefix: "",
apiType: "chat",
baseUrl: "https://api.openai.com/v1",
});
setCheckKey("");
setValidationResult(null);
}
@@ -710,7 +805,7 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
baseUrl: formData.baseUrl,
apiKey: checkKey,
type: "openai-compatible",
modelId: checkModelId.trim() || undefined
modelId: checkModelId.trim() || undefined,
}),
});
const data = await res.json();
@@ -731,7 +826,11 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
return (
<>
<Badge variant="success">Valid</Badge>
{method === "chat" && <span className="text-sm text-text-muted">(via inference test)</span>}
{method === "chat" && (
<span className="text-sm text-text-muted">
(via inference test)
</span>
)}
</>
);
}
@@ -764,12 +863,16 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
label="API Type"
options={apiTypeOptions}
value={formData.apiType}
onChange={(e) => setFormData({ ...formData, apiType: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, apiType: e.target.value })
}
/>
<Input
label="Base URL"
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, baseUrl: e.target.value })
}
placeholder="https://api.openai.com/v1"
hint="Use the base URL (ending in /v1) for your OpenAI-compatible API."
/>
@@ -787,16 +890,31 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
hint="If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead."
/>
<div className="flex items-center gap-3">
<Button onClick={handleValidate} disabled={!checkKey || validating || !formData.baseUrl.trim()} variant="secondary">
<Button
onClick={handleValidate}
disabled={!checkKey || validating || !formData.baseUrl.trim()}
variant="secondary"
>
{validating ? "Checking..." : "Check"}
</Button>
{renderValidationResult()}
</div>
<div className="flex gap-2">
<Button onClick={handleSubmit} fullWidth disabled={!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim() || submitting}>
<Button
onClick={handleSubmit}
fullWidth
disabled={
!formData.name.trim() ||
!formData.prefix.trim() ||
!formData.baseUrl.trim() ||
submitting
}
>
{submitting ? "Creating..." : "Create"}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>Cancel</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</div>
</Modal>
@@ -830,7 +948,12 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
}, [isOpen]);
const handleSubmit = async () => {
if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return;
if (
!formData.name.trim() ||
!formData.prefix.trim() ||
!formData.baseUrl.trim()
)
return;
setSubmitting(true);
try {
const res = await fetch("/api/provider-nodes", {
@@ -846,7 +969,11 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
const data = await res.json();
if (res.ok) {
onCreated(data.node);
setFormData({ name: "", prefix: "", baseUrl: "https://api.anthropic.com/v1" });
setFormData({
name: "",
prefix: "",
baseUrl: "https://api.anthropic.com/v1",
});
setCheckKey("");
setValidationResult(null);
}
@@ -867,7 +994,7 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
baseUrl: formData.baseUrl,
apiKey: checkKey,
type: "anthropic-compatible",
modelId: checkModelId.trim() || undefined
modelId: checkModelId.trim() || undefined,
}),
});
const data = await res.json();
@@ -888,7 +1015,11 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
return (
<>
<Badge variant="success">Valid</Badge>
{method === "chat" && <span className="text-sm text-text-muted">(via inference test)</span>}
{method === "chat" && (
<span className="text-sm text-text-muted">
(via inference test)
</span>
)}
</>
);
}
@@ -920,7 +1051,9 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
<Input
label="Base URL"
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, baseUrl: e.target.value })
}
placeholder="https://api.anthropic.com/v1"
hint="Use the base URL (ending in /v1) for your Anthropic-compatible API. The system will append /messages."
/>
@@ -938,16 +1071,31 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
hint="If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead."
/>
<div className="flex items-center gap-3">
<Button onClick={handleValidate} disabled={!checkKey || validating || !formData.baseUrl.trim()} variant="secondary">
<Button
onClick={handleValidate}
disabled={!checkKey || validating || !formData.baseUrl.trim()}
variant="secondary"
>
{validating ? "Checking..." : "Check"}
</Button>
{renderValidationResult()}
</div>
<div className="flex gap-2">
<Button onClick={handleSubmit} fullWidth disabled={!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim() || submitting}>
<Button
onClick={handleSubmit}
fullWidth
disabled={
!formData.name.trim() ||
!formData.prefix.trim() ||
!formData.baseUrl.trim() ||
submitting
}
>
{submitting ? "Creating..." : "Create"}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>Cancel</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</div>
</Modal>
@@ -964,7 +1112,9 @@ function ProviderTestResultsView({ results }) {
if (results.error && !results.results) {
return (
<div className="text-center py-6">
<span className="material-symbols-outlined text-red-500 text-[32px] mb-2 block">error</span>
<span className="material-symbols-outlined text-red-500 text-[32px] mb-2 block">
error
</span>
<p className="text-sm text-red-400">{results.error}</p>
</div>
);
@@ -972,7 +1122,14 @@ function ProviderTestResultsView({ results }) {
const { summary, mode } = results;
const items = results.results || [];
const modeLabel = { oauth: "OAuth", free: "Free", apikey: "API Key", provider: "Provider", all: "All" }[mode] || mode;
const modeLabel =
{
oauth: "OAuth",
free: "Free",
apikey: "API Key",
provider: "Provider",
all: "All",
}[mode] || mode;
return (
<div className="flex flex-col gap-3">
@@ -987,7 +1144,9 @@ function ProviderTestResultsView({ results }) {
{summary.failed} failed
</span>
)}
<span className="text-text-muted ml-auto">{summary.total} tested</span>
<span className="text-text-muted ml-auto">
{summary.total} tested
</span>
</div>
)}
{items.map((r, i) => (
@@ -995,7 +1154,9 @@ function ProviderTestResultsView({ results }) {
key={r.connectionId || i}
className="flex items-center gap-2 text-xs px-3 py-2 rounded-lg bg-black/[0.03] dark:bg-white/[0.03]"
>
<span className={`material-symbols-outlined text-[16px] ${r.valid ? "text-emerald-500" : "text-red-500"}`}>
<span
className={`material-symbols-outlined text-[16px] ${r.valid ? "text-emerald-500" : "text-red-500"}`}
>
{r.valid ? "check_circle" : "error"}
</span>
<div className="flex-1 min-w-0">
@@ -1003,11 +1164,16 @@ function ProviderTestResultsView({ results }) {
<span className="text-text-muted ml-1.5">({r.provider})</span>
</div>
{r.latencyMs !== undefined && (
<span className="text-text-muted font-mono tabular-nums">{r.latencyMs}ms</span>
<span className="text-text-muted font-mono tabular-nums">
{r.latencyMs}ms
</span>
)}
<span
className={`text-[10px] uppercase font-bold px-1.5 py-0.5 rounded ${r.valid ? "bg-emerald-500/15 text-emerald-400" : "bg-red-500/15 text-red-400"
}`}
className={`text-[10px] uppercase font-bold px-1.5 py-0.5 rounded ${
r.valid
? "bg-emerald-500/15 text-emerald-400"
: "bg-red-500/15 text-red-400"
}`}
>
{r.valid ? "OK" : r.diagnosis?.type || "ERROR"}
</span>

View File

@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import Card from "@/shared/components/Card";
import ProviderIcon from "@/shared/components/ProviderIcon";
import Badge from "@/shared/components/Badge";
import QuotaProgressBar from "./QuotaProgressBar";
import { calculatePercentage } from "./utils";
@@ -25,11 +25,10 @@ export default function ProviderLimitCard({
onRefresh,
}) {
const [refreshing, setRefreshing] = useState(false);
const [imgError, setImgError] = useState(false);
const handleRefresh = async () => {
if (!onRefresh || refreshing) return;
setRefreshing(true);
try {
await onRefresh();
@@ -63,28 +62,20 @@ export default function ProviderLimitCard({
className="size-10 rounded-lg flex items-center justify-center p-1.5"
style={{ backgroundColor: `${providerColor}15` }}
>
{imgError ? (
<span
className="text-sm font-bold"
style={{ color: providerColor }}
>
{provider?.slice(0, 2).toUpperCase() || "PR"}
</span>
) : (
<Image
src={`/providers/${provider}.png`}
alt={provider || "Provider"}
width={40}
height={40}
className="object-contain rounded-lg"
sizes="40px"
onError={() => setImgError(true)}
/>
)}
<ProviderIcon
src={`/providers/${provider}.png`}
alt={provider || "Provider"}
size={40}
className="object-contain rounded-lg"
fallbackText={provider?.slice(0, 2).toUpperCase() || "PR"}
fallbackColor={providerColor}
/>
</div>
<div>
<h3 className="font-semibold text-text-primary">{name || provider}</h3>
<h3 className="font-semibold text-text-primary">
{name || provider}
</h3>
{plan && (
<Badge
variant={planVariants[plan?.toLowerCase()] || "default"}
@@ -146,7 +137,9 @@ export default function ProviderLimitCard({
<span className="material-symbols-outlined text-blue-500 text-[20px]">
info
</span>
<p className="text-sm text-blue-600 dark:text-blue-400">{message}</p>
<p className="text-sm text-blue-600 dark:text-blue-400">
{message}
</p>
</div>
</div>
)}
@@ -156,11 +149,12 @@ export default function ProviderLimitCard({
<div className="space-y-4">
{quotas.map((quota, index) => {
// For Antigravity, use remainingPercentage if available, otherwise calculate
const percentage = quota.remainingPercentage !== undefined
? Math.round((quota.total - quota.used) / quota.total * 100)
: calculatePercentage(quota.used, quota.total);
const percentage =
quota.remainingPercentage !== undefined
? Math.round(((quota.total - quota.used) / quota.total) * 100)
: calculatePercentage(quota.used, quota.total);
const unlimited = quota.total === 0 || quota.total === null;
return (
<QuotaProgressBar
key={`${quota.name}-${index}`}

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import Image from "next/image";
import ProviderIcon from "@/shared/components/ProviderIcon";
import ProviderLimitCard from "./ProviderLimitCard";
import QuotaTable from "./QuotaTable";
import { parseQuotaData, calculatePercentage } from "./utils";
@@ -30,7 +30,7 @@ export default function ProviderLimits() {
try {
const response = await fetch("/api/providers/client");
if (!response.ok) throw new Error("Failed to fetch connections");
const data = await response.json();
const connectionList = data.connections || [];
setConnections(connectionList);
@@ -48,23 +48,30 @@ export default function ProviderLimits() {
setErrors((prev) => ({ ...prev, [connectionId]: null }));
try {
console.log(`[ProviderLimits] Fetching quota for ${provider} (${connectionId})`);
console.log(
`[ProviderLimits] Fetching quota for ${provider} (${connectionId})`,
);
const response = await fetch(`/api/usage/${connectionId}`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMsg = errorData.error || response.statusText;
// Handle different error types gracefully
if (response.status === 404) {
// Connection not found - skip silently
console.warn(`[ProviderLimits] Connection not found for ${provider}, skipping`);
console.warn(
`[ProviderLimits] Connection not found for ${provider}, skipping`,
);
return;
}
if (response.status === 401) {
// Auth error - show message instead of throwing
console.warn(`[ProviderLimits] Auth error for ${provider}:`, errorMsg);
console.warn(
`[ProviderLimits] Auth error for ${provider}:`,
errorMsg,
);
setQuotaData((prev) => ({
...prev,
[connectionId]: {
@@ -74,16 +81,16 @@ export default function ProviderLimits() {
}));
return;
}
throw new Error(`HTTP ${response.status}: ${errorMsg}`);
}
const data = await response.json();
console.log(`[ProviderLimits] Got quota for ${provider}:`, data);
// Parse quota data using provider-specific parser
const parsedQuotas = parseQuotaData(provider, data);
setQuotaData((prev) => ({
...prev,
[connectionId]: {
@@ -94,7 +101,10 @@ export default function ProviderLimits() {
},
}));
} catch (error) {
console.error(`[ProviderLimits] Error fetching quota for ${provider} (${connectionId}):`, error);
console.error(
`[ProviderLimits] Error fetching quota for ${provider} (${connectionId}):`,
error,
);
setErrors((prev) => ({
...prev,
[connectionId]: error.message || "Failed to fetch quota",
@@ -110,7 +120,7 @@ export default function ProviderLimits() {
await fetchQuota(connectionId, provider);
setLastUpdated(new Date());
},
[fetchQuota]
[fetchQuota],
);
// Refresh all providers
@@ -122,15 +132,17 @@ export default function ProviderLimits() {
try {
const conns = await fetchConnections();
// Filter only supported OAuth providers
const oauthConnections = conns.filter(
(conn) => USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth"
(conn) =>
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
conn.authType === "oauth",
);
// Fetch quota for supported OAuth connections only
await Promise.all(
oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider))
oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider)),
);
setLastUpdated(new Date());
@@ -149,16 +161,20 @@ export default function ProviderLimits() {
setConnectionsLoading(false);
const oauthConnections = conns.filter(
(conn) => USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth"
(conn) =>
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
conn.authType === "oauth",
);
// Mark all as loading before fetching
const loadingState = {};
oauthConnections.forEach((conn) => { loadingState[conn.id] = true; });
oauthConnections.forEach((conn) => {
loadingState[conn.id] = true;
});
setLoading(loadingState);
await Promise.all(
oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider))
oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider)),
);
setLastUpdated(new Date());
};
@@ -243,8 +259,10 @@ export default function ProviderLimits() {
}, [lastUpdated]);
// Filter only supported providers
const filteredConnections = connections.filter((conn) =>
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth"
const filteredConnections = connections.filter(
(conn) =>
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
conn.authType === "oauth",
);
// Sort providers by USAGE_SUPPORTED_PROVIDERS order, then alphabetically
@@ -258,18 +276,18 @@ export default function ProviderLimits() {
// Calculate summary stats
const totalProviders = sortedConnections.length;
const activeWithLimits = Object.values(quotaData).filter(
(data) => data?.quotas?.length > 0
(data) => data?.quotas?.length > 0,
).length;
// Count low quotas (remaining < 30%)
const lowQuotasCount = Object.values(quotaData).reduce((count, data) => {
if (!data?.quotas) return count;
const hasLowQuota = data.quotas.some((quota) => {
const percentage = calculatePercentage(quota.used, quota.total);
return percentage < 30 && quota.total > 0;
});
return count + (hasLowQuota ? 1 : 0);
}, 0);
@@ -285,7 +303,8 @@ export default function ProviderLimits() {
No Providers Connected
</h3>
<p className="mt-2 text-sm text-text-muted max-w-md mx-auto">
Connect to providers with OAuth to track your API quota limits and usage.
Connect to providers with OAuth to track your API quota limits and
usage.
</p>
</div>
</Card>
@@ -353,13 +372,14 @@ export default function ProviderLimits() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg flex items-center justify-center overflow-hidden">
<Image
<ProviderIcon
src={`/providers/${conn.provider}.png`}
alt={conn.provider}
width={40}
height={40}
size={40}
className="object-contain"
sizes="40px"
fallbackText={
conn.provider?.slice(0, 2).toUpperCase() || "PR"
}
/>
</div>
<div>
@@ -371,14 +391,16 @@ export default function ProviderLimits() {
)}
</div>
</div>
<button
onClick={() => refreshProvider(conn.id, conn.provider)}
disabled={isLoading}
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50"
title="Refresh quota"
>
<span className={`material-symbols-outlined text-[20px] text-text-muted ${isLoading ? "animate-spin" : ""}`}>
<span
className={`material-symbols-outlined text-[20px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
>
refresh
</span>
</button>

View File

@@ -0,0 +1,103 @@
import { NextResponse } from "next/server";
import {
getProviderConnections,
updateProviderConnection,
} from "@/lib/localDb";
const MODEL_LOCK_PREFIX = "modelLock_";
function getActiveModelLocks(connection) {
const now = Date.now();
return Object.entries(connection)
.filter(([key, value]) => key.startsWith(MODEL_LOCK_PREFIX) && value)
.map(([key, value]) => ({
key,
model: key.slice(MODEL_LOCK_PREFIX.length) || "__all",
until: value,
active: new Date(value).getTime() > now,
}))
.filter((lock) => lock.active);
}
export async function GET() {
try {
const connections = await getProviderConnections();
const models = [];
for (const connection of connections) {
const locks = getActiveModelLocks(connection);
for (const lock of locks) {
models.push({
provider: connection.provider,
model: lock.model,
status: "cooldown",
until: lock.until,
connectionId: connection.id,
connectionName: connection.name || connection.email || connection.id,
lastError: connection.lastError || null,
});
}
if (locks.length === 0 && connection.testStatus === "unavailable") {
models.push({
provider: connection.provider,
model: "__all",
status: "unavailable",
connectionId: connection.id,
connectionName: connection.name || connection.email || connection.id,
lastError: connection.lastError || null,
});
}
}
return NextResponse.json({
models,
unavailableCount: models.length,
});
} catch (error) {
console.error("[API] Failed to get model availability:", error);
return NextResponse.json(
{ error: "Failed to fetch model availability" },
{ status: 500 },
);
}
}
export async function POST(request) {
try {
const { action, provider, model } = await request.json();
if (action !== "clearCooldown" || !provider || !model) {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
const connections = await getProviderConnections({ provider });
const lockKey = `${MODEL_LOCK_PREFIX}${model}`;
await Promise.all(
connections
.filter((connection) => connection[lockKey])
.map((connection) =>
updateProviderConnection(connection.id, {
[lockKey]: null,
...(connection.testStatus === "unavailable"
? {
testStatus: "active",
lastError: null,
lastErrorAt: null,
backoffLevel: 0,
}
: {}),
}),
),
);
return NextResponse.json({ ok: true });
} catch (error) {
console.error("[API] Failed to clear model cooldown:", error);
return NextResponse.json(
{ error: "Failed to clear cooldown" },
{ status: 500 },
);
}
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import ProviderIcon from "@/shared/components/ProviderIcon";
const CLI_TOOLS = [
{ id: "claude", name: "Claude Code", image: "/providers/claude.png" },
@@ -10,10 +10,30 @@ const CLI_TOOLS = [
];
const PROVIDERS = [
{ id: "openai", name: "OpenAI", color: "bg-emerald-500", textColor: "text-white" },
{ id: "anthropic", name: "Anthropic", color: "bg-orange-400", textColor: "text-white" },
{ id: "gemini", name: "Gemini", color: "bg-blue-500", textColor: "text-white" },
{ id: "github", name: "GitHub Copilot", color: "bg-gray-700", textColor: "text-white" },
{
id: "openai",
name: "OpenAI",
color: "bg-emerald-500",
textColor: "text-white",
},
{
id: "anthropic",
name: "Anthropic",
color: "bg-orange-400",
textColor: "text-white",
},
{
id: "gemini",
name: "Gemini",
color: "bg-blue-500",
textColor: "text-white",
},
{
id: "github",
name: "GitHub Copilot",
color: "bg-gray-700",
textColor: "text-white",
},
];
export default function FlowAnimation() {
@@ -30,26 +50,29 @@ export default function FlowAnimation() {
<div className="mt-16 w-full max-w-4xl relative h-[360px] hidden md:flex items-center justify-center animate-[float_6s_ease-in-out_infinite]">
{/* 9Router Hub - Center */}
<div className="relative z-20 w-32 h-32 rounded-full bg-[#23180f] border-2 border-[#f97815] shadow-[0_0_40px_rgba(249,120,21,0.3)] flex flex-col items-center justify-center gap-1 group cursor-pointer hover:scale-105 transition-transform duration-500">
<span className="material-symbols-outlined text-4xl text-[#f97815]">hub</span>
<span className="text-xs font-bold text-white tracking-widest uppercase">9Router</span>
<span className="material-symbols-outlined text-4xl text-[#f97815]">
hub
</span>
<span className="text-xs font-bold text-white tracking-widest uppercase">
9Router
</span>
<div className="absolute inset-0 rounded-full border border-[#f97815]/30 animate-ping opacity-20"></div>
</div>
{/* CLI Tools - Left side */}
<div className="absolute left-0 top-1/2 -translate-y-1/2 flex flex-col gap-7">
{CLI_TOOLS.map((tool) => (
<div
key={tool.id}
<div
key={tool.id}
className="flex items-center gap-3 opacity-70 hover:opacity-100 transition-opacity group"
>
<div className="w-16 h-16 rounded-2xl bg-[#23180f] border border-[#3a2f27] flex items-center justify-center overflow-hidden p-2 hover:border-[#f97815]/50 transition-all hover:scale-105">
<Image
src={tool.image}
<ProviderIcon
src={tool.image}
alt={tool.name}
width={48}
height={48}
size={48}
className="object-contain rounded-xl max-w-[48px] max-h-[48px]"
sizes="48px"
fallbackText={tool.name.slice(0, 2).toUpperCase()}
/>
</div>
</div>
@@ -57,40 +80,70 @@ export default function FlowAnimation() {
</div>
{/* SVG Lines from CLI to 9Router */}
<svg className="absolute inset-0 w-full h-full z-10 pointer-events-none stroke-yellow-700" xmlns="http://www.w3.org/2000/svg">
<path className="animate-[dash_2s_linear_infinite]" d="M 60 50 C 250 70, 250 180, 360 180" fill="none" strokeDasharray="5,5" strokeWidth="2"></path>
<path className="animate-[dash_2s_linear_infinite]" d="M 60 140 C 250 140, 250 180, 360 180" fill="none" strokeDasharray="5,5" strokeWidth="2"></path>
<path className="animate-[dash_2s_linear_infinite]" d="M 60 210 C 250 210, 250 180, 360 180" fill="none" strokeDasharray="5,5" strokeWidth="2"></path>
<path className="animate-[dash_2s_linear_infinite]" d="M 60 300 C 250 280, 250 180, 360 180" fill="none" strokeDasharray="5,5" strokeWidth="2"></path>
<svg
className="absolute inset-0 w-full h-full z-10 pointer-events-none stroke-yellow-700"
xmlns="http://www.w3.org/2000/svg"
>
<path
className="animate-[dash_2s_linear_infinite]"
d="M 60 50 C 250 70, 250 180, 360 180"
fill="none"
strokeDasharray="5,5"
strokeWidth="2"
></path>
<path
className="animate-[dash_2s_linear_infinite]"
d="M 60 140 C 250 140, 250 180, 360 180"
fill="none"
strokeDasharray="5,5"
strokeWidth="2"
></path>
<path
className="animate-[dash_2s_linear_infinite]"
d="M 60 210 C 250 210, 250 180, 360 180"
fill="none"
strokeDasharray="5,5"
strokeWidth="2"
></path>
<path
className="animate-[dash_2s_linear_infinite]"
d="M 60 300 C 250 280, 250 180, 360 180"
fill="none"
strokeDasharray="5,5"
strokeWidth="2"
></path>
</svg>
{/* SVG Lines from 9Router to Providers */}
<svg className="absolute inset-0 w-full h-full z-10 pointer-events-none" xmlns="http://www.w3.org/2000/svg">
<path
d="M 440 180 C 550 180, 550 50, 740 50"
fill="none"
stroke={activeFlow === 0 ? "#f97815" : "rgb(75, 85, 99)"}
<svg
className="absolute inset-0 w-full h-full z-10 pointer-events-none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M 440 180 C 550 180, 550 50, 740 50"
fill="none"
stroke={activeFlow === 0 ? "#f97815" : "rgb(75, 85, 99)"}
strokeWidth={activeFlow === 0 ? "3" : "2"}
className={activeFlow === 0 ? "animate-pulse" : ""}
></path>
<path
d="M 440 180 C 550 180, 550 130, 740 130"
fill="none"
stroke={activeFlow === 1 ? "#f97815" : "rgb(75, 85, 99)"}
<path
d="M 440 180 C 550 180, 550 130, 740 130"
fill="none"
stroke={activeFlow === 1 ? "#f97815" : "rgb(75, 85, 99)"}
strokeWidth={activeFlow === 1 ? "3" : "2"}
className={activeFlow === 1 ? "animate-pulse" : ""}
></path>
<path
d="M 440 180 C 550 180, 550 230, 740 230"
fill="none"
stroke={activeFlow === 2 ? "#f97815" : "rgb(75, 85, 99)"}
<path
d="M 440 180 C 550 180, 550 230, 740 230"
fill="none"
stroke={activeFlow === 2 ? "#f97815" : "rgb(75, 85, 99)"}
strokeWidth={activeFlow === 2 ? "3" : "2"}
className={activeFlow === 2 ? "animate-pulse" : ""}
></path>
<path
d="M 440 180 C 550 180, 550 310, 740 310"
fill="none"
stroke={activeFlow === 3 ? "#f97815" : "rgb(75, 85, 99)"}
<path
d="M 440 180 C 550 180, 550 310, 740 310"
fill="none"
stroke={activeFlow === 3 ? "#f97815" : "rgb(75, 85, 99)"}
strokeWidth={activeFlow === 3 ? "3" : "2"}
className={activeFlow === 3 ? "animate-pulse" : ""}
></path>
@@ -99,7 +152,7 @@ export default function FlowAnimation() {
{/* AI Providers - Right side */}
<div className="absolute right-0 top-0 bottom-0 flex flex-col justify-between py-6">
{PROVIDERS.map((provider, idx) => (
<div
<div
key={provider.id}
className={`px-4 py-2 rounded-lg ${provider.color} ${provider.textColor} flex items-center justify-center font-bold text-xs shadow-lg hover:scale-110 transition-all cursor-help min-w-[140px] ${
activeFlow === idx ? "ring-4 ring-[#f97815]/50 scale-110" : ""
@@ -113,9 +166,10 @@ export default function FlowAnimation() {
{/* Mobile fallback */}
<div className="md:hidden mt-8 w-full p-4 rounded-lg bg-[#23180f] border border-[#3a2f27]">
<p className="text-sm text-center text-gray-400">Interactive diagram visible on desktop</p>
<p className="text-sm text-center text-gray-400">
Interactive diagram visible on desktop
</p>
</div>
</div>
);
}

View File

@@ -3,49 +3,104 @@
import { usePathname, useRouter } from "next/navigation";
import { useMemo } from "react";
import Link from "next/link";
import Image from "next/image";
import PropTypes from "prop-types";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { ThemeToggle, LanguageSwitcher } from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
import { translate } from "@/i18n/runtime";
const getPageInfo = (pathname) => {
if (!pathname) return { title: "", description: "", breadcrumbs: [] };
// Provider detail page: /dashboard/providers/[id]
const providerMatch = pathname.match(/\/providers\/([^/]+)$/);
if (providerMatch) {
const providerId = providerMatch[1];
const providerInfo = OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId];
const providerInfo =
OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId];
if (providerInfo) {
return {
title: providerInfo.name,
description: "",
breadcrumbs: [
{ label: "Providers", href: "/dashboard/providers" },
{ label: providerInfo.name, image: `/providers/${providerInfo.id}.png` }
]
{
label: providerInfo.name,
image: `/providers/${providerInfo.id}.png`,
},
],
};
}
}
if (pathname.includes("/providers")) return { title: "Providers", description: "Manage your AI provider connections", breadcrumbs: [] };
if (pathname.includes("/combos")) return { title: "Combos", description: "Model combos with fallback", breadcrumbs: [] };
if (pathname.includes("/usage")) return { title: "Usage & Analytics", description: "Monitor your API usage, token consumption, and request logs", breadcrumbs: [] };
if (pathname.includes("/mitm")) return { title: "MITM Proxy", description: "Intercept CLI tool traffic and route through 9Router", breadcrumbs: [] };
if (pathname.includes("/cli-tools")) return { title: "CLI Tools", description: "Configure CLI tools", breadcrumbs: [] };
if (pathname.includes("/endpoint")) return { title: "Endpoint", description: "API endpoint configuration", breadcrumbs: [] };
if (pathname.includes("/profile")) return { title: "Settings", description: "Manage your preferences", breadcrumbs: [] };
if (pathname.includes("/translator")) return { title: "Translator", description: "Debug translation flow between formats", breadcrumbs: [] };
if (pathname.includes("/console-log")) return { title: "Console Log", description: "Live server console output", breadcrumbs: [] };
if (pathname === "/dashboard") return { title: "Endpoint", description: "API endpoint configuration", breadcrumbs: [] };
if (pathname.includes("/providers"))
return {
title: "Providers",
description: "Manage your AI provider connections",
breadcrumbs: [],
};
if (pathname.includes("/combos"))
return {
title: "Combos",
description: "Model combos with fallback",
breadcrumbs: [],
};
if (pathname.includes("/usage"))
return {
title: "Usage & Analytics",
description:
"Monitor your API usage, token consumption, and request logs",
breadcrumbs: [],
};
if (pathname.includes("/mitm"))
return {
title: "MITM Proxy",
description: "Intercept CLI tool traffic and route through 9Router",
breadcrumbs: [],
};
if (pathname.includes("/cli-tools"))
return {
title: "CLI Tools",
description: "Configure CLI tools",
breadcrumbs: [],
};
if (pathname.includes("/endpoint"))
return {
title: "Endpoint",
description: "API endpoint configuration",
breadcrumbs: [],
};
if (pathname.includes("/profile"))
return {
title: "Settings",
description: "Manage your preferences",
breadcrumbs: [],
};
if (pathname.includes("/translator"))
return {
title: "Translator",
description: "Debug translation flow between formats",
breadcrumbs: [],
};
if (pathname.includes("/console-log"))
return {
title: "Console Log",
description: "Live server console output",
breadcrumbs: [],
};
if (pathname === "/dashboard")
return {
title: "Endpoint",
description: "API endpoint configuration",
breadcrumbs: [],
};
return { title: "", description: "", breadcrumbs: [] };
};
export default function Header({ onMenuClick, showMenuButton = true }) {
const pathname = usePathname();
const router = useRouter();
// Memoize page info to prevent unnecessary recalculations
const pageInfo = useMemo(() => getPageInfo(pathname), [pathname]);
const { title, description, breadcrumbs } = pageInfo;
@@ -81,7 +136,10 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
{breadcrumbs.length > 0 ? (
<div className="flex items-center gap-2">
{breadcrumbs.map((crumb, index) => (
<div key={`${crumb.label}-${crumb.href || "current"}`} className="flex items-center gap-2">
<div
key={`${crumb.label}-${crumb.href || "current"}`}
className="flex items-center gap-2"
>
{index > 0 && (
<span className="material-symbols-outlined text-text-muted text-base">
chevron_right
@@ -97,14 +155,12 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
) : (
<div className="flex items-center gap-2">
{crumb.image && (
<Image
<ProviderIcon
src={crumb.image}
alt={crumb.label}
width={28}
height={28}
size={28}
className="object-contain rounded max-w-[28px] max-h-[28px]"
sizes="28px"
onError={(e) => { e.currentTarget.style.display = "none"; }}
fallbackText={crumb.label.slice(0, 2).toUpperCase()}
/>
)}
<h1 className="text-2xl font-semibold text-text-main tracking-tight">
@@ -117,9 +173,13 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
</div>
) : title ? (
<div>
<h1 className="text-2xl font-semibold text-text-main tracking-tight">{translate(title)}</h1>
<h1 className="text-2xl font-semibold text-text-main tracking-tight">
{translate(title)}
</h1>
{description && (
<p className="text-sm text-text-muted">{translate(description)}</p>
<p className="text-sm text-text-muted">
{translate(description)}
</p>
)}
</div>
) : null}
@@ -150,4 +210,3 @@ Header.propTypes = {
onMenuClick: PropTypes.func,
showMenuButton: PropTypes.bool,
};

View File

@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import PropTypes from "prop-types";
export default function ProviderIcon({
src,
alt,
size = 32,
className = "",
fallbackText = "?",
fallbackColor,
}) {
const [errored, setErrored] = useState(false);
if (!src || errored) {
return (
<span
className={`inline-flex items-center justify-center font-bold rounded-lg ${className}`.trim()}
style={{
width: size,
height: size,
color: fallbackColor,
fontSize: Math.max(10, Math.floor(size * 0.38)),
}}
>
{fallbackText}
</span>
);
}
return (
<img
src={src}
alt={alt}
width={size}
height={size}
className={className}
onError={() => setErrored(true)}
/>
);
}
ProviderIcon.propTypes = {
src: PropTypes.string,
alt: PropTypes.string,
size: PropTypes.number,
className: PropTypes.string,
fallbackText: PropTypes.string,
fallbackColor: PropTypes.string,
};