diff --git a/public/providers/assemblyai.png b/public/providers/assemblyai.png new file mode 100644 index 0000000..d4367af Binary files /dev/null and b/public/providers/assemblyai.png differ diff --git a/public/providers/deepgram.png b/public/providers/deepgram.png new file mode 100644 index 0000000..dc58142 Binary files /dev/null and b/public/providers/deepgram.png differ diff --git a/public/providers/hyperbolic.png b/public/providers/hyperbolic.png new file mode 100644 index 0000000..0b4802d Binary files /dev/null and b/public/providers/hyperbolic.png differ diff --git a/public/providers/nanobanana.png b/public/providers/nanobanana.png new file mode 100644 index 0000000..9df2d30 Binary files /dev/null and b/public/providers/nanobanana.png differ diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index 8b0bb12..f23edea 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -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( {connected} Connected - + , ); } if (error > 0) { - const errText = errorCode ? `${error} Error (${errorCode})` : `${error} Error`; + const errText = errorCode + ? `${error} Error (${errorCode})` + : `${error} Error`; parts.push( {errText} - + , ); } 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() { )} */} - {renderValidationResult()}
- - +
@@ -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 ( <> Valid - {method === "chat" && (via inference test)} + {method === "chat" && ( + + (via inference test) + + )} ); } @@ -920,7 +1051,9 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) { 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." />
- {renderValidationResult()}
- - +
@@ -964,7 +1112,9 @@ function ProviderTestResultsView({ results }) { if (results.error && !results.results) { return (
- error + + error +

{results.error}

); @@ -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 (
@@ -987,7 +1144,9 @@ function ProviderTestResultsView({ results }) { {summary.failed} failed )} - {summary.total} tested + + {summary.total} tested +
)} {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]" > - + {r.valid ? "check_circle" : "error"}
@@ -1003,11 +1164,16 @@ function ProviderTestResultsView({ results }) { ({r.provider})
{r.latencyMs !== undefined && ( - {r.latencyMs}ms + + {r.latencyMs}ms + )} {r.valid ? "OK" : r.diagnosis?.type || "ERROR"} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js index 6c6176b..e018699 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js @@ -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 ? ( - - {provider?.slice(0, 2).toUpperCase() || "PR"} - - ) : ( - {provider setImgError(true)} - /> - )} + - +
-

{name || provider}

+

+ {name || provider} +

{plan && ( info -

{message}

+

+ {message} +

)} @@ -156,11 +149,12 @@ export default function ProviderLimitCard({
{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 ( ({ ...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

- 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.

@@ -353,13 +372,14 @@ export default function ProviderLimits() {
- {conn.provider}
@@ -371,14 +391,16 @@ export default function ProviderLimits() { )}
- + diff --git a/src/app/api/models/availability/route.js b/src/app/api/models/availability/route.js new file mode 100644 index 0000000..cceacda --- /dev/null +++ b/src/app/api/models/availability/route.js @@ -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 }, + ); + } +} diff --git a/src/app/landing/components/FlowAnimation.js b/src/app/landing/components/FlowAnimation.js index 482e7a1..4209cc0 100644 --- a/src/app/landing/components/FlowAnimation.js +++ b/src/app/landing/components/FlowAnimation.js @@ -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() {
{/* 9Router Hub - Center */}
- hub - 9Router + + hub + + + 9Router +
{/* CLI Tools - Left side */}
{CLI_TOOLS.map((tool) => ( -
- {tool.name}
@@ -57,40 +80,70 @@ export default function FlowAnimation() {
{/* SVG Lines from CLI to 9Router */} - - - - - + + + + + {/* SVG Lines from 9Router to Providers */} - - + - - - @@ -99,7 +152,7 @@ export default function FlowAnimation() { {/* AI Providers - Right side */}
{PROVIDERS.map((provider, idx) => ( -
-

Interactive diagram visible on desktop

+

+ Interactive diagram visible on desktop +

); } - diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js index dd01a37..66eccce 100644 --- a/src/shared/components/Header.js +++ b/src/shared/components/Header.js @@ -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 ? (
{breadcrumbs.map((crumb, index) => ( -
+
{index > 0 && ( chevron_right @@ -97,14 +155,12 @@ export default function Header({ onMenuClick, showMenuButton = true }) { ) : (
{crumb.image && ( - {crumb.label} { e.currentTarget.style.display = "none"; }} + fallbackText={crumb.label.slice(0, 2).toUpperCase()} /> )}

@@ -117,9 +173,13 @@ export default function Header({ onMenuClick, showMenuButton = true }) {

) : title ? (
-

{translate(title)}

+

+ {translate(title)} +

{description && ( -

{translate(description)}

+

+ {translate(description)} +

)}
) : null} @@ -150,4 +210,3 @@ Header.propTypes = { onMenuClick: PropTypes.func, showMenuButton: PropTypes.bool, }; - diff --git a/src/shared/components/ProviderIcon.js b/src/shared/components/ProviderIcon.js new file mode 100644 index 0000000..ca55850 --- /dev/null +++ b/src/shared/components/ProviderIcon.js @@ -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 ( + + {fallbackText} + + ); + } + + return ( + {alt} setErrored(true)} + /> + ); +} + +ProviderIcon.propTypes = { + src: PropTypes.string, + alt: PropTypes.string, + size: PropTypes.number, + className: PropTypes.string, + fallbackText: PropTypes.string, + fallbackColor: PropTypes.string, +};