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:
decolua
2026-07-17 12:12:20 +07:00
co-authored by Cursor
parent 68566f53dc
commit ccb0842d0a
50 changed files with 612 additions and 392 deletions
+7 -5
View File
@@ -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} />
)}
</>
);
}
+2
View File
@@ -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"
/>
)}
</>
+3 -2
View File
@@ -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),
},
],
};
+1 -1
View File
@@ -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" />
)}
+22 -3
View File
@@ -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,
+59 -30
View File
@@ -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
View File
@@ -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";
+40
View File
@@ -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);
}