mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
fix(dashboard): cut duplicate API/icon spam, lazy-load provider assets
Share one /api/models fetch via useModelCaps cache, mount ModelSelectModal only when open, stop double fetchModelAliases on CLI tool cards, and resolve provider icons through a session 404 cache with missing PNGs + loading=lazy. Also include Claude Exa MCP toggle (claude-settings + ClaudeToolCard) that was already in the working tree. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -166,11 +166,13 @@ export default function ComboFormModal({ isOpen, combo, onClose, onSave, activeP
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ModelSelectModal isOpen={showModelSelect} onClose={() => setShowModelSelect(false)}
|
||||
onSelect={handleAddModel} onDeselect={handleDeselectModel}
|
||||
activeProviders={activeProviders} modelAliases={modelAliases}
|
||||
title="Add Model to Combo" kindFilter={kindFilter}
|
||||
addedModelValues={models} closeOnSelect={false} />
|
||||
{showModelSelect && (
|
||||
<ModelSelectModal isOpen={showModelSelect} onClose={() => setShowModelSelect(false)}
|
||||
onSelect={handleAddModel} onDeselect={handleDeselectModel}
|
||||
activeProviders={activeProviders} modelAliases={modelAliases}
|
||||
title="Add Model to Combo" kindFilter={kindFilter}
|
||||
addedModelValues={models} closeOnSelect={false} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,6 +106,8 @@ function DonateChannelCard({ channel }) {
|
||||
src={qr}
|
||||
alt={`${label} QR`}
|
||||
className="w-full max-w-[180px] aspect-square object-contain rounded-lg bg-white p-1"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -12,6 +12,7 @@ import DonateModal from "@/shared/components/DonateModal";
|
||||
import { useHeaderSearchStore } from "@/store/headerSearchStore";
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { getProviderIconSrc } from "@/shared/utils/providerIcon";
|
||||
import { translate } from "@/i18n/runtime";
|
||||
|
||||
const getPageInfo = (pathname) => {
|
||||
@@ -30,7 +31,7 @@ const getPageInfo = (pathname) => {
|
||||
breadcrumbs: [
|
||||
{ label: "Media Providers", href: `/dashboard/media-providers/${kindId}` },
|
||||
{ label: kindConfig?.label || kindId, href: `/dashboard/media-providers/${kindId}` },
|
||||
{ label: provider?.name || providerId, image: `/providers/${providerId}.png` },
|
||||
{ label: provider?.name || providerId, image: getProviderIconSrc(providerId) },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -62,7 +63,7 @@ const getPageInfo = (pathname) => {
|
||||
{ label: "Providers", href: "/dashboard/providers" },
|
||||
{
|
||||
label: providerInfo.name,
|
||||
image: `/providers/${providerInfo.id}.png`,
|
||||
image: getProviderIconSrc(providerInfo.id),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -154,7 +154,7 @@ export default function McpMarketplaceModal({ isOpen, onClose, onAdd, addedNames
|
||||
<div className="flex items-start gap-2 px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5">
|
||||
{s.iconUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={s.iconUrl} alt="" className="size-7 rounded shrink-0 object-contain" onError={(e) => { e.target.style.display = "none"; }} />
|
||||
<img src={s.iconUrl} alt="" className="size-7 rounded shrink-0 object-contain" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
|
||||
) : (
|
||||
<div className="size-7 rounded bg-surface shrink-0" />
|
||||
)}
|
||||
|
||||
@@ -2,18 +2,29 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/providerIcon";
|
||||
|
||||
function resolveSrc(src, providerId) {
|
||||
if (providerId) return getProviderIconSrc(providerId);
|
||||
if (!src) return null;
|
||||
const m = String(src).match(/^\/providers\/([^/]+)\.png$/i);
|
||||
if (m) return getProviderIconSrc(m[1]);
|
||||
return src;
|
||||
}
|
||||
|
||||
export default function ProviderIcon({
|
||||
src,
|
||||
providerId,
|
||||
alt,
|
||||
size = 32,
|
||||
className = "",
|
||||
fallbackText = "?",
|
||||
fallbackColor,
|
||||
}) {
|
||||
const effectiveSrc = resolveSrc(src, providerId);
|
||||
const [errored, setErrored] = useState(false);
|
||||
|
||||
if (!src || errored) {
|
||||
if (!effectiveSrc || errored) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center font-bold rounded-lg ${className}`.trim()}
|
||||
@@ -31,18 +42,26 @@ export default function ProviderIcon({
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
src={effectiveSrc}
|
||||
alt={alt}
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
onError={() => setErrored(true)}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => {
|
||||
const m = effectiveSrc.match(/^\/providers\/([^/]+)\.png$/i);
|
||||
if (m) markProviderIconMissing(m[1]);
|
||||
if (providerId) markProviderIconMissing(providerId);
|
||||
setErrored(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ProviderIcon.propTypes = {
|
||||
src: PropTypes.string,
|
||||
providerId: PropTypes.string,
|
||||
alt: PropTypes.string,
|
||||
size: PropTypes.number,
|
||||
className: PropTypes.string,
|
||||
|
||||
@@ -1,44 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
|
||||
|
||||
// Fetch model capabilities once and expose a lookup by fullModel ("provider/model") or bare model id.
|
||||
// Module cache: one /api/models fetch shared by every useModelCaps instance.
|
||||
let cache = null; // { byFull, byId } | null
|
||||
let inflight = null;
|
||||
|
||||
function buildMaps(models) {
|
||||
const byFull = {};
|
||||
const byId = {};
|
||||
for (const m of models || []) {
|
||||
if (!m.caps) continue;
|
||||
if (m.fullModel) byFull[m.fullModel] = m.caps;
|
||||
if (m.model) byId[m.model] = m.caps;
|
||||
}
|
||||
return { byFull, byId };
|
||||
}
|
||||
|
||||
function loadModelCaps() {
|
||||
if (cache) return Promise.resolve(cache);
|
||||
if (inflight) return inflight;
|
||||
inflight = fetch("/api/models")
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`models ${res.status}`);
|
||||
const data = await res.json();
|
||||
cache = buildMaps(data.models);
|
||||
return cache;
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep null so a later mount can retry
|
||||
return { byFull: {}, byId: {} };
|
||||
})
|
||||
.finally(() => { inflight = null; });
|
||||
return inflight;
|
||||
}
|
||||
|
||||
// Resolve caps from a "provider/model" string or a bare model id.
|
||||
function resolveCaps(byFull, byId, key) {
|
||||
if (!key) return null;
|
||||
if (byFull[key]) return byFull[key];
|
||||
const bare = key.includes("/") ? key.slice(key.indexOf("/") + 1) : key;
|
||||
if (byId[bare]) return byId[bare];
|
||||
const provider = key.includes("/") ? key.slice(0, key.indexOf("/")) : null;
|
||||
const c = getCapabilitiesForModel(provider, bare);
|
||||
return { vision: c.vision, search: c.search, reasoning: c.reasoning };
|
||||
}
|
||||
|
||||
export function useModelCaps() {
|
||||
const [byFull, setByFull] = useState({});
|
||||
const [byId, setById] = useState({});
|
||||
const [byFull, setByFull] = useState(() => cache?.byFull || {});
|
||||
const [byId, setById] = useState(() => cache?.byId || {});
|
||||
|
||||
useEffect(() => {
|
||||
if (cache) {
|
||||
setByFull(cache.byFull);
|
||||
setById(cache.byId);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const full = {};
|
||||
const id = {};
|
||||
for (const m of data.models || []) {
|
||||
if (!m.caps) continue;
|
||||
if (m.fullModel) full[m.fullModel] = m.caps;
|
||||
if (m.model) id[m.model] = m.caps;
|
||||
}
|
||||
if (alive) { setByFull(full); setById(id); }
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
loadModelCaps().then((maps) => {
|
||||
if (alive) { setByFull(maps.byFull); setById(maps.byId); }
|
||||
});
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
// Resolve caps from a "provider/model" string or a bare model id.
|
||||
const getCaps = (key) => {
|
||||
if (!key) return null;
|
||||
if (byFull[key]) return byFull[key];
|
||||
const bare = key.includes("/") ? key.slice(key.indexOf("/") + 1) : key;
|
||||
if (byId[bare]) return byId[bare];
|
||||
// Fallback: compute caps for dynamic models (passthrough/custom/suggested) not in static list
|
||||
const provider = key.includes("/") ? key.slice(0, key.indexOf("/")) : null;
|
||||
const c = getCapabilitiesForModel(provider, bare);
|
||||
return { vision: c.vision, search: c.search, reasoning: c.reasoning };
|
||||
};
|
||||
const getCaps = useCallback(
|
||||
(key) => resolveCaps(byFull, byId, key),
|
||||
[byFull, byId],
|
||||
);
|
||||
|
||||
return { getCaps };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Shared Utils - Export all
|
||||
export { cn } from "./cn";
|
||||
export * as api from "./api";
|
||||
export { getProviderIconSrc, markProviderIconMissing, resolveProviderIconId } from "./providerIcon";
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Provider icon paths under /public/providers.
|
||||
// Alias related brands; session-cache 404s so one miss never spams again.
|
||||
|
||||
const ICON_ALIASES = {
|
||||
"perplexity-agent": "perplexity",
|
||||
"gitlab-duo": "gitlab",
|
||||
"vercel-ai-gateway": "vercel",
|
||||
};
|
||||
|
||||
// Runtime only — first 404 remembers id for the whole session
|
||||
const failedIds = new Set();
|
||||
|
||||
function normalizeId(providerId) {
|
||||
if (!providerId || typeof providerId !== "string") return "";
|
||||
return providerId.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** Resolve icon file id (after alias). Empty if previously failed this session. */
|
||||
export function resolveProviderIconId(providerId) {
|
||||
const id = normalizeId(providerId);
|
||||
if (!id) return "";
|
||||
if (failedIds.has(id)) return "";
|
||||
const aliased = ICON_ALIASES[id] || id;
|
||||
if (failedIds.has(aliased)) return "";
|
||||
return aliased;
|
||||
}
|
||||
|
||||
/** `/providers/{id}.png` or null when previously failed. */
|
||||
export function getProviderIconSrc(providerId) {
|
||||
const id = resolveProviderIconId(providerId);
|
||||
return id ? `/providers/${id}.png` : null;
|
||||
}
|
||||
|
||||
/** Call from img onError so later mounts skip the request. */
|
||||
export function markProviderIconMissing(providerId) {
|
||||
const id = normalizeId(providerId);
|
||||
if (id) failedIds.add(id);
|
||||
const aliased = ICON_ALIASES[id];
|
||||
if (aliased) failedIds.add(aliased);
|
||||
}
|
||||
Reference in New Issue
Block a user