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
parent 68566f53dc
commit ccb0842d0a
50 changed files with 612 additions and 392 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
public/providers/gitlab.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
public/providers/mmf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
public/providers/morph.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
public/providers/novita.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
public/providers/reka.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
public/providers/venice.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
public/providers/vercel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -891,7 +891,7 @@ export default function BasicChatPageClient() {
<div className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-3 mt-2">
{message.attachments.map((attachment) => (
<a key={attachment.id} href={attachment.dataUrl} target="_blank" rel="noreferrer" className="overflow-hidden rounded-[18px] border border-white/10 bg-black/20">
<img src={attachment.dataUrl} alt={attachment.name} className="h-28 w-full object-cover" />
<img src={attachment.dataUrl} alt={attachment.name} className="h-28 w-full object-cover" loading="lazy" decoding="async" />
</a>
))}
</div>

View File

@@ -38,15 +38,10 @@ export default function AntigravityToolCard({
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !status) {
fetchStatus();
loadSavedMappings();
fetchModelAliases();
}
if (isExpanded) {
loadSavedMappings();
fetchModelAliases();
}
if (!isExpanded) return;
if (!status) fetchStatus();
loadSavedMappings();
fetchModelAliases();
}, [isExpanded]);
const loadSavedMappings = async () => {
@@ -243,6 +238,8 @@ export default function AntigravityToolCard({
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => { e.target.style.display = "none"; }}
loading="lazy"
decoding="async"
/>
</div>
<div className="min-w-0">
@@ -467,15 +464,17 @@ export default function AntigravityToolCard({
</Modal>
{/* Model Select Modal */}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null}
activeProviders={activeProviders}
modelAliases={modelAliases}
title={`Select model for ${currentEditingAlias}`}
/>
{modalOpen && (
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null}
activeProviders={activeProviders}
modelAliases={modelAliases}
title={`Select model for ${currentEditingAlias}`}
/>
)}
</Card>
);
}

View File

@@ -39,6 +39,7 @@ export default function ClaudeToolCard({
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState("");
const [ccFilterNaming, setCcFilterNaming] = useState(false);
const [exaMcpEnabled, setExaMcpEnabled] = useState(false);
const hasInitializedModels = useRef(false);
const getConfigStatus = () => {
@@ -58,15 +59,17 @@ export default function ClaudeToolCard({
}, [apiKeys, selectedApiKey]);
useEffect(() => {
if (initialStatus) setClaudeStatus(initialStatus);
if (initialStatus) {
setClaudeStatus(initialStatus);
setExaMcpEnabled(!!initialStatus.exaMcpEnabled);
}
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !claudeStatus) {
checkClaudeStatus();
if (isExpanded) {
if (!claudeStatus) checkClaudeStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
useEffect(() => {
@@ -123,6 +126,7 @@ export default function ClaudeToolCard({
const res = await fetch("/api/cli-tools/claude-settings");
const data = await res.json();
setClaudeStatus(data);
setExaMcpEnabled(!!data.exaMcpEnabled);
} catch (error) {
setClaudeStatus({ installed: false, error: error.message });
} finally {
@@ -162,12 +166,12 @@ export default function ClaudeToolCard({
const res = await fetch("/api/cli-tools/claude-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ env }),
body: JSON.stringify({ env, exaMcpEnabled }),
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" });
setClaudeStatus(prev => ({ ...prev, hasBackup: true, settings: { ...prev?.settings, env } }));
setClaudeStatus(prev => ({ ...prev, hasBackup: true, settings: { ...prev?.settings, env }, exaMcpEnabled }));
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
}
@@ -188,6 +192,7 @@ export default function ClaudeToolCard({
setMessage({ type: "success", text: "Settings reset successfully!" });
tool.defaultModels.forEach((model) => onModelMappingChange(model.alias, model.defaultValue || ""));
setSelectedApiKey("");
setExaMcpEnabled(false);
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
}
@@ -231,7 +236,7 @@ export default function ClaudeToolCard({
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/claude.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
<Image src="/providers/claude.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
@@ -352,6 +357,19 @@ export default function ClaudeToolCard({
</Tooltip>
</label>
</div>
{/* Exa MCP — ~/.claude.json mcpServers (not settings.json) */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Web Search</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input type="checkbox" checked={exaMcpEnabled} onChange={(e) => setExaMcpEnabled(e.target.checked)} className="w-3.5 h-3.5 accent-primary cursor-pointer" />
<span className="text-xs text-text-muted">Exa MCP</span>
<Tooltip text="Injects Exa MCP into ~/.claude.json so non-Claude models gain web search. Restart Claude Code after Apply.">
<span className="material-symbols-outlined text-text-muted text-[14px] cursor-help">info</span>
</Tooltip>
</label>
</div>
</div>
{message && (
@@ -377,7 +395,9 @@ export default function ClaudeToolCard({
</div>
)}
<ModelSelectModal isOpen={modalOpen} onClose={() => setModalOpen(false)} onSelect={handleModelSelect} selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} activeProviders={activeProviders} modelAliases={modelAliases} title={`Select model for ${currentEditingAlias}`} />
{modalOpen && (
<ModelSelectModal isOpen={modalOpen} onClose={() => setModalOpen(false)} onSelect={handleModelSelect} selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} activeProviders={activeProviders} modelAliases={modelAliases} title={`Select model for ${currentEditingAlias}`} />
)}
<ManualConfigModal
isOpen={showManualConfigModal}

View File

@@ -30,11 +30,10 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !status) {
checkStatus();
if (isExpanded) {
if (!status) checkStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
useEffect(() => {
@@ -157,7 +156,7 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/cline.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
<Image src="/providers/cline.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
@@ -280,15 +279,17 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Cline"
/>
{modalOpen && (
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Cline"
/>
)}
<ManualConfigModal
isOpen={showManualConfigModal}

View File

@@ -34,11 +34,10 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !codexStatus) {
checkCodexStatus();
if (isExpanded) {
if (!codexStatus) checkCodexStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
const fetchModelAliases = async () => {
@@ -199,7 +198,7 @@ model = "${effectiveSubagentModel}"
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/codex.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
<Image src="/providers/codex.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
@@ -371,25 +370,29 @@ model = "${effectiveSubagentModel}"
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Codex"
/>
{modalOpen && (
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Codex"
/>
)}
<ModelSelectModal
isOpen={subagentModalOpen}
onClose={() => setSubagentModalOpen(false)}
onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }}
selectedModel={subagentModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Subagent Model for Codex"
/>
{subagentModalOpen && (
<ModelSelectModal
isOpen={subagentModalOpen}
onClose={() => setSubagentModalOpen(false)}
onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }}
selectedModel={subagentModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Subagent Model for Codex"
/>
)}
<ManualConfigModal
isOpen={showManualConfigModal}

View File

@@ -36,11 +36,10 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !status) {
checkStatus();
if (isExpanded) {
if (!status) checkStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
// Pre-fill from existing config
@@ -184,7 +183,7 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/copilot.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
<Image src="/providers/copilot.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
@@ -290,27 +289,29 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => {
setModalOpen(false);
saveModels(selectedModelsRef.current);
}}
onSelect={(model) => {
if (!selectedModels.includes(model.value)) {
setSelectedModels([...selectedModels, model.value]);
}
}}
onDeselect={(model) => {
setSelectedModels(selectedModels.filter(m => m !== model.value));
}}
selectedModel={null}
activeProviders={activeProviders}
modelAliases={modelAliases}
addedModelValues={selectedModels}
closeOnSelect={false}
title="Add Model for GitHub Copilot"
/>
{modalOpen && (
<ModelSelectModal
isOpen={modalOpen}
onClose={() => {
setModalOpen(false);
saveModels(selectedModelsRef.current);
}}
onSelect={(model) => {
if (!selectedModels.includes(model.value)) {
setSelectedModels([...selectedModels, model.value]);
}
}}
onDeselect={(model) => {
setSelectedModels(selectedModels.filter(m => m !== model.value));
}}
selectedModel={null}
activeProviders={activeProviders}
modelAliases={modelAliases}
addedModelValues={selectedModels}
closeOnSelect={false}
title="Add Model for GitHub Copilot"
/>
)}
<ManualConfigModal
isOpen={showManualConfigModal}

View File

@@ -249,7 +249,7 @@ export default function CoworkToolCard({
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src={tool.image} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
<Image src={tool.image} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
@@ -514,27 +514,31 @@ export default function CoworkToolCard({
configs={getManualConfigs()}
/>
<ComboFormModal
isOpen={comboModalOpen}
combo={null}
onClose={() => setComboModalOpen(false)}
onSave={handleCreateCombo}
activeProviders={activeProviders}
forcePrefix="claude-"
title="Create Cowork Combo"
/>
{comboModalOpen && (
<ComboFormModal
isOpen={comboModalOpen}
combo={null}
onClose={() => setComboModalOpen(false)}
onSave={handleCreateCombo}
activeProviders={activeProviders}
forcePrefix="claude-"
title="Create Cowork Combo"
/>
)}
<ModelSelectModal
isOpen={modelSelectOpen}
onClose={() => setModelSelectOpen(false)}
onSelect={handleAddModel}
onDeselect={handleRemoveModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Cowork Model"
addedModelValues={selectedModels}
closeOnSelect={false}
/>
{modelSelectOpen && (
<ModelSelectModal
isOpen={modelSelectOpen}
onClose={() => setModelSelectOpen(false)}
onSelect={handleAddModel}
onDeselect={handleRemoveModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Cowork Model"
addedModelValues={selectedModels}
closeOnSelect={false}
/>
)}
<McpMarketplaceModal
isOpen={marketplaceOpen}

View File

@@ -58,11 +58,10 @@ export default function DeepSeekTuiToolCard({
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !deepseekStatus) {
checkStatus();
if (isExpanded) {
if (!deepseekStatus) checkStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
const fetchModelAliases = async () => {
@@ -187,7 +186,7 @@ model = "${selectedModel || "provider/model-id"}"
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src={tool.image || "/providers/deepseek-tui.png"} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
<Image src={tool.image || "/providers/deepseek-tui.png"} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
@@ -317,15 +316,17 @@ model = "${selectedModel || "provider/model-id"}"
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for DeepSeek TUI"
/>
{modalOpen && (
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for DeepSeek TUI"
/>
)}
<ManualConfigModal
isOpen={showManualConfigModal}

View File

@@ -2,6 +2,7 @@
import { useState } from "react";
import { Card, ModelSelectModal } from "@/shared/components";
import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/providerIcon";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import Image from "next/image";
import ApiKeySelect from "./ApiKeySelect";
@@ -217,21 +218,32 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => { e.target.style.display = "none"; }}
loading="lazy"
decoding="async"
/>
);
}
if (tool.icon) {
return <span className="material-symbols-outlined text-xl" style={{ color: tool.color }}>{tool.icon}</span>;
}
const iconSrc = getProviderIconSrc(toolId);
if (!iconSrc) {
return <span className="text-xs font-bold" style={{ color: tool.color }}>{(toolId || "?").slice(0, 2).toUpperCase()}</span>;
}
return (
<Image
src={`/providers/${toolId}.png`}
src={iconSrc}
alt={tool.name}
width={32}
height={32}
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => { e.target.style.display = "none"; }}
onError={(e) => {
markProviderIconMissing(toolId);
e.target.style.display = "none";
}}
loading="lazy"
decoding="async"
/>
);
};
@@ -257,14 +269,16 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba
</div>
)}
<ModelSelectModal
isOpen={showModelModal}
onClose={() => setShowModelModal(false)}
onSelect={handleSelectModel}
selectedModel={modelValue}
activeProviders={activeProviders}
title="Select Model"
/>
{showModelModal && (
<ModelSelectModal
isOpen={showModelModal}
onClose={() => setShowModelModal(false)}
onSelect={handleSelectModel}
selectedModel={modelValue}
activeProviders={activeProviders}
title="Select Model"
/>
)}
</Card>
);
}

View File

@@ -60,11 +60,10 @@ export default function DroidToolCard({
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !droidStatus) {
checkDroidStatus();
if (isExpanded) {
if (!droidStatus) checkDroidStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
const fetchModelAliases = async () => {
@@ -225,7 +224,7 @@ export default function DroidToolCard({
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/droid.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
<Image src="/providers/droid.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
@@ -389,15 +388,17 @@ export default function DroidToolCard({
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={null}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Factory Droid"
/>
{modalOpen && (
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={null}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Factory Droid"
/>
)}
<ManualConfigModal
isOpen={showManualConfigModal}

View File

@@ -59,11 +59,10 @@ export default function GrokBuildToolCard({
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !grokStatus) {
checkStatus();
if (isExpanded) {
if (!grokStatus) checkStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
const fetchModelAliases = async () => {
@@ -203,6 +202,8 @@ api_key = "${keyToUse}"
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => { e.target.style.display = "none"; }}
loading="lazy"
decoding="async"
/>
</div>
<div className="min-w-0">
@@ -366,15 +367,17 @@ api_key = "${keyToUse}"
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Grok Build"
/>
{modalOpen && (
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Grok Build"
/>
)}
<ManualConfigModal
isOpen={showManualConfigModal}

View File

@@ -58,11 +58,10 @@ export default function HermesToolCard({
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !hermesStatus) {
checkStatus();
if (isExpanded) {
if (!hermesStatus) checkStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
const fetchModelAliases = async () => {
@@ -185,7 +184,7 @@ export default function HermesToolCard({
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/hermes.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
<Image src="/providers/hermes.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
@@ -296,15 +295,17 @@ export default function HermesToolCard({
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Hermes Agent"
/>
{modalOpen && (
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Hermes Agent"
/>
)}
<ManualConfigModal
isOpen={showManualConfigModal}

View File

@@ -56,11 +56,10 @@ export default function JcodeToolCard({
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !jcodeStatus) {
checkJcodeStatus();
if (isExpanded) {
if (!jcodeStatus) checkJcodeStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
const fetchModelAliases = async () => {
@@ -215,7 +214,7 @@ id = "${selectedModel || "cc/claude-opus-4-7"}"`;
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src={tool.image || "/providers/jcode.png"} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
<Image src={tool.image || "/providers/jcode.png"} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
@@ -359,15 +358,17 @@ id = "${selectedModel || "cc/claude-opus-4-7"}"`;
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for jcode"
/>
{modalOpen && (
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for jcode"
/>
)}
<ManualConfigModal
isOpen={showManualConfigModal}

View File

@@ -30,11 +30,10 @@ export default function KiloToolCard({ tool, isExpanded, onToggle, baseUrl, apiK
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !status) {
checkStatus();
if (isExpanded) {
if (!status) checkStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
const fetchModelAliases = async () => {
@@ -144,7 +143,7 @@ export default function KiloToolCard({ tool, isExpanded, onToggle, baseUrl, apiK
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/kilocode.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
<Image src="/providers/kilocode.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
@@ -254,15 +253,17 @@ export default function KiloToolCard({ tool, isExpanded, onToggle, baseUrl, apiK
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Kilo Code"
/>
{modalOpen && (
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Kilo Code"
/>
)}
<ManualConfigModal
isOpen={showManualConfigModal}

View File

@@ -22,6 +22,8 @@ export default function MitmLinkCard({ tool }) {
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => { e.target.style.display = "none"; }}
loading="lazy"
decoding="async"
/>
</div>
<div className="min-w-0">

View File

@@ -143,6 +143,8 @@ export default function MitmToolCard({
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => { e.target.style.display = "none"; }}
loading="lazy"
decoding="async"
/>
</div>
<div className="min-w-0">
@@ -304,15 +306,17 @@ export default function MitmToolCard({
)}
{/* Model Select Modal */}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null}
activeProviders={activeProviders}
modelAliases={modelAliases}
title={`Select model for ${currentEditingAlias}`}
/>
{modalOpen && (
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null}
activeProviders={activeProviders}
modelAliases={modelAliases}
title={`Select model for ${currentEditingAlias}`}
/>
)}
</>
);
}

View File

@@ -57,11 +57,10 @@ export default function OpenClawToolCard({
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !openclawStatus) {
checkOpenclawStatus();
if (isExpanded) {
if (!openclawStatus) checkOpenclawStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
const fetchModelAliases = async () => {
@@ -233,7 +232,7 @@ export default function OpenClawToolCard({
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/openclaw.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
<Image src="/providers/openclaw.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
@@ -367,15 +366,17 @@ export default function OpenClawToolCard({
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Open Claw"
/>
{modalOpen && (
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Open Claw"
/>
)}
<ManualConfigModal
isOpen={showManualConfigModal}

View File

@@ -41,11 +41,10 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !status) {
checkStatus();
if (isExpanded) {
if (!status) checkStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
// Sync models from existing config
@@ -222,7 +221,7 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/opencode.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
<Image src="/providers/opencode.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
@@ -452,42 +451,46 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => {
setModalOpen(false);
saveModels(selectedModelsRef.current);
}}
onSelect={(model) => {
if (!selectedModels.includes(model.value)) {
setSelectedModels([...selectedModels, model.value]);
if (!activeModel) setActiveModel(model.value);
}
}}
onDeselect={(model) => {
const remaining = selectedModels.filter(m => m !== model.value);
setSelectedModels(remaining);
if (activeModel === model.value) {
setActiveModel(remaining[0] || "");
}
}}
selectedModel={null}
activeProviders={activeProviders}
modelAliases={modelAliases}
addedModelValues={selectedModels}
closeOnSelect={false}
title="Add Model for OpenCode"
/>
{modalOpen && (
<ModelSelectModal
isOpen={modalOpen}
onClose={() => {
setModalOpen(false);
saveModels(selectedModelsRef.current);
}}
onSelect={(model) => {
if (!selectedModels.includes(model.value)) {
setSelectedModels([...selectedModels, model.value]);
if (!activeModel) setActiveModel(model.value);
}
}}
onDeselect={(model) => {
const remaining = selectedModels.filter(m => m !== model.value);
setSelectedModels(remaining);
if (activeModel === model.value) {
setActiveModel(remaining[0] || "");
}
}}
selectedModel={null}
activeProviders={activeProviders}
modelAliases={modelAliases}
addedModelValues={selectedModels}
closeOnSelect={false}
title="Add Model for OpenCode"
/>
)}
<ModelSelectModal
isOpen={subagentModalOpen}
onClose={() => setSubagentModalOpen(false)}
onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }}
selectedModel={subagentModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Subagent Model for OpenCode"
/>
{subagentModalOpen && (
<ModelSelectModal
isOpen={subagentModalOpen}
onClose={() => setSubagentModalOpen(false)}
onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }}
selectedModel={subagentModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Subagent Model for OpenCode"
/>
)}
<ManualConfigModal
isOpen={showManualConfigModal}

View File

@@ -21,7 +21,7 @@ export default function ToolSummaryCard({ toolId, tool, status }) {
<div className="flex items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
{tool.image ? (
<Image src={tool.image} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
<Image src={tool.image} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
) : tool.icon ? (
<span className="material-symbols-outlined text-[28px]" style={{ color: tool.color }}>{tool.icon}</span>
) : null}

View File

@@ -7,6 +7,7 @@ import { CSS } from "@dnd-kit/utilities";
import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers";
import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, ConfirmModal, CapacityBadges, Select } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useModelCaps } from "@/shared/hooks/useModelCaps";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
// Validate combo name: only a-z, A-Z, 0-9, -, _
@@ -19,7 +20,7 @@ export default function CombosPage() {
const [editingCombo, setEditingCombo] = useState(null);
const [activeProviders, setActiveProviders] = useState([]);
const [comboStrategies, setComboStrategies] = useState({});
const [modelCaps, setModelCaps] = useState({});
const { getCaps } = useModelCaps();
const [confirmState, setConfirmState] = useState(null);
const { copied, copy } = useCopyToClipboard();
@@ -29,11 +30,10 @@ export default function CombosPage() {
const fetchData = async () => {
try {
const [combosRes, providersRes, settingsRes, modelsRes] = await Promise.all([
const [combosRes, providersRes, settingsRes] = await Promise.all([
fetch("/api/combos"),
fetch("/api/providers"),
fetch("/api/settings"),
fetch("/api/models"),
]);
const combosData = await combosRes.json();
const providersData = await providersRes.json();
@@ -44,13 +44,6 @@ export default function CombosPage() {
if (providersRes.ok) {
setActiveProviders(providersData.connections || []);
}
if (modelsRes.ok) {
const md = await modelsRes.json();
// Build fullModel -> caps map for badge lookup
const map = {};
for (const m of md.models || []) if (m.caps) map[m.fullModel] = m.caps;
setModelCaps(map);
}
setComboStrategies(settingsData.comboStrategies || {});
} catch (error) {
console.log("Error fetching data:", error);
@@ -189,7 +182,7 @@ export default function CombosPage() {
<ComboCard
key={combo.id}
combo={combo}
modelCaps={modelCaps}
getCaps={getCaps}
activeProviders={activeProviders}
copied={copied}
onCopy={copy}
@@ -203,23 +196,26 @@ export default function CombosPage() {
)}
{/* Create Modal - Use key to force remount and reset state */}
<ComboFormModal
key="create"
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSave={handleCreate}
activeProviders={activeProviders}
/>
{showCreateModal && (
<ComboFormModal
key="create"
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSave={handleCreate}
activeProviders={activeProviders}
/>
)}
{/* Edit Modal - Use key to force remount and reset state */}
<ComboFormModal
key={editingCombo?.id || "new"}
isOpen={!!editingCombo}
combo={editingCombo}
onClose={() => setEditingCombo(null)}
onSave={(data) => handleUpdate(editingCombo.id, data)}
activeProviders={activeProviders}
/>
{editingCombo && (
<ComboFormModal
key={editingCombo.id}
isOpen={!!editingCombo}
combo={editingCombo}
onClose={() => setEditingCombo(null)}
onSave={(data) => handleUpdate(editingCombo.id, data)}
activeProviders={activeProviders}
/>
)}
{/* Confirm Delete Modal */}
<ConfirmModal
@@ -240,7 +236,7 @@ const STRATEGY_OPTIONS = [
{ value: "fusion", label: "Fusion — panel + judge" },
];
function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, onSetStrategy }) {
function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, onSetStrategy }) {
const [showJudgeSelect, setShowJudgeSelect] = useState(false);
const current = strategy.fallbackStrategy || "fallback";
const judge = strategy.judgeModel || "";
@@ -262,7 +258,7 @@ function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy
combo.models.slice(0, 3).map((model, index) => (
<code key={index} className="inline-flex items-center gap-1 rounded bg-black/5 px-1.5 py-0.5 font-mono text-xs text-text-muted dark:bg-white/5">
<span>{model}</span>
<CapacityBadges caps={modelCaps[model]} />
<CapacityBadges caps={getCaps?.(model)} />
</code>
))
)}
@@ -340,15 +336,17 @@ function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy
</div>
{/* Judge model picker (single-select; combo members make natural judges too) */}
<ModelSelectModal
isOpen={showJudgeSelect}
onClose={() => setShowJudgeSelect(false)}
onSelect={(m) => { onSetStrategy({ judgeModel: m?.value || "" }); setShowJudgeSelect(false); }}
activeProviders={activeProviders}
title="Select Judge Model"
addedModelValues={judge ? [judge] : []}
closeOnSelect={true}
/>
{showJudgeSelect && (
<ModelSelectModal
isOpen={showJudgeSelect}
onClose={() => setShowJudgeSelect(false)}
onSelect={(m) => { onSetStrategy({ judgeModel: m?.value || "" }); setShowJudgeSelect(false); }}
activeProviders={activeProviders}
title="Select Judge Model"
addedModelValues={judge ? [judge] : []}
closeOnSelect={true}
/>
)}
</Card>
);
}
@@ -637,18 +635,20 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF
</Modal>
{/* Model Select 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}
/>
)}
</>
);
}

View File

@@ -350,6 +350,8 @@ export function GenericExampleCard({ providerId, kind }) {
className="max-h-40 rounded-lg border border-border object-contain bg-sidebar"
onError={(e) => { e.currentTarget.style.display = "none"; }}
onLoad={(e) => { e.currentTarget.style.display = "block"; }}
loading="lazy"
decoding="async"
/>
)}
</div>
@@ -383,6 +385,8 @@ export function GenericExampleCard({ providerId, kind }) {
className="max-h-40 rounded-lg border border-border object-contain bg-sidebar"
onError={(e) => { e.currentTarget.style.display = "none"; }}
onLoad={(e) => { e.currentTarget.style.display = "block"; }}
loading="lazy"
decoding="async"
/>
)}
</div>
@@ -487,6 +491,8 @@ export function GenericExampleCard({ providerId, kind }) {
src={`data:image/png;base64,${partialImage.b64_json}`}
alt="Partial"
className="max-w-full rounded-lg border border-border mt-1.5 opacity-80"
loading="lazy"
decoding="async"
/>
</div>
)}
@@ -529,6 +535,8 @@ export function GenericExampleCard({ providerId, kind }) {
src={binaryImageUrl || (result?.data?.data?.[0]?.b64_json ? `data:image/png;base64,${result.data.data[0].b64_json}` : result?.data?.data?.[0]?.url)}
alt="Generated"
className="max-w-full rounded-lg border border-border"
loading="lazy"
decoding="async"
/>
</div>
)}

View File

@@ -357,7 +357,7 @@ export default function ComboDetailPage() {
Download
</a>
</div>
<img src={testResult.imageUrl} alt="Generated" className="max-w-full rounded-lg border border-border" />
<img src={testResult.imageUrl} alt="Generated" className="max-w-full rounded-lg border border-border" loading="lazy" decoding="async" />
</div>
)}
{testResult.audioUrl && (
@@ -393,18 +393,20 @@ export default function ComboDetailPage() {
)}
</Card>
<ModelSelectModal
isOpen={showPicker}
onClose={() => setShowPicker(false)}
onSelect={handleAddModel}
onDeselect={handleDeselectModel}
activeProviders={connections}
modelAliases={modelAliases}
title={`Add ${kindLabel} Model`}
kindFilter={combo.kind}
addedModelValues={providers}
closeOnSelect={false}
/>
{showPicker && (
<ModelSelectModal
isOpen={showPicker}
onClose={() => setShowPicker(false)}
onSelect={handleAddModel}
onDeselect={handleDeselectModel}
activeProviders={connections}
modelAliases={modelAliases}
title={`Add ${kindLabel} Model`}
kindFilter={combo.kind}
addedModelValues={providers}
closeOnSelect={false}
/>
)}
</div>
);
}

View File

@@ -116,26 +116,22 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri
const [testingModelId, setTestingModelId] = useState(null);
const [testError, setTestError] = useState("");
const [showAddCustomModel, setShowAddCustomModel] = useState(false);
const [connections, setConnections] = useState([]);
const providerAlias = providerAliasOverride || getProviderAlias(providerId);
const effectiveType = kindFilter || "llm";
const fetchData = useCallback(async () => {
try {
const [aliasRes, connRes, customRes] = await Promise.all([
const [aliasRes, customRes] = await Promise.all([
fetch("/api/models/alias"),
fetch("/api/providers", { cache: "no-store" }),
fetch("/api/models/custom", { cache: "no-store" }),
]);
const aliasData = await aliasRes.json();
const connData = await connRes.json();
const customData = await customRes.json();
if (aliasRes.ok) setModelAliases(aliasData.aliases || {});
if (connRes.ok) setConnections((connData.connections || []).filter((c) => c.provider === providerId));
if (customRes.ok) setCustomModels(customData.models || []);
} catch (e) { console.log("ModelsCard fetch error:", e); }
}, [providerId]);
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
@@ -242,7 +238,7 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri
onSetAlias={(alias) => handleSetAlias(model.id, alias)}
onDeleteAlias={() => handleDeleteAlias(existingAlias)}
testStatus={modelTestResults[model.id]}
onTest={connections.length > 0 ? () => handleTestModel(model.id) : undefined}
onTest={() => handleTestModel(model.id)}
isTesting={testingModelId === model.id}
isFree={model.isFree}
/>
@@ -259,7 +255,7 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri
onSetAlias={() => {}}
onDeleteAlias={() => handleDeleteCustomModel(model.id)}
testStatus={modelTestResults[model.id]}
onTest={connections.length > 0 ? () => handleTestModel(model.id) : undefined}
onTest={() => handleTestModel(model.id)}
isTesting={testingModelId === model.id}
isCustom
/>

View File

@@ -10,6 +10,7 @@ import {
Toggle,
} from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { getProviderIconSrc } from "@/shared/utils/providerIcon";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
import {
FREE_PROVIDERS,
@@ -756,12 +757,12 @@ function ApiKeyProviderCard({
};
const getIconPath = () => {
if (isCompatible)
if (isCompatible && provider.apiType)
return provider.apiType === "responses"
? "/providers/oai-r.png"
: "/providers/oai-cc.png";
if (isAnthropicCompatible) return "/providers/anthropic-m.png";
return `/providers/${provider.id}.png`;
return getProviderIconSrc(provider.id);
};
return (

View File

@@ -1265,22 +1265,24 @@ export default function ProviderLimits() {
/>
)}
{hiddenQuotaRows.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-1 border-t border-black/5 pt-2 text-[10px] text-text-muted dark:border-white/5">
<span className="material-symbols-outlined text-[14px]">
<div className="mt-2 flex min-w-0 items-center gap-1 border-t border-black/5 pt-2 text-[10px] text-text-muted dark:border-white/5">
<span className="material-symbols-outlined shrink-0 text-[14px]">
visibility_off
</span>
<span>Hidden:</span>
{hiddenQuotaRows.map((quotaRow) => (
<button
key={getQuotaVisibilityKey(quotaRow)}
type="button"
onClick={() => handleShowQuota(conn.provider, quotaRow)}
className="rounded-md border border-black/10 px-1.5 py-0.5 transition-colors hover:bg-black/5 hover:text-text-primary dark:border-white/10 dark:hover:bg-white/5"
title="Show this quota row"
>
{quotaRow.name}
</button>
))}
<span className="shrink-0">Hidden:</span>
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto whitespace-nowrap">
{hiddenQuotaRows.map((quotaRow) => (
<button
key={getQuotaVisibilityKey(quotaRow)}
type="button"
onClick={() => handleShowQuota(conn.provider, quotaRow)}
className="shrink-0 rounded-md border border-black/10 px-1.5 py-0.5 transition-colors hover:bg-black/5 hover:text-text-primary dark:border-white/10 dark:hover:bg-white/5"
title="Show this quota row"
>
{quotaRow.name}
</button>
))}
</div>
</div>
)}
</div>

View File

@@ -10,6 +10,7 @@ import {
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/providerIcon";
// Force-stop FE animation if a provider stays active longer than this
const FE_ACTIVE_TIMEOUT_MS = 60000;
@@ -19,9 +20,8 @@ function getProviderConfig(providerId) {
return AI_PROVIDERS[providerId] || { color: "#6b7280", name: providerId };
}
// Use local provider images from /public/providers/
function getProviderImageUrl(providerId) {
return `/providers/${providerId}.png`;
return getProviderIconSrc(providerId);
}
// Custom provider node - rectangle with image + name
@@ -47,8 +47,19 @@ function ProviderNode({ data }) {
className="w-8 h-8 rounded-md flex items-center justify-center shrink-0"
style={{ backgroundColor: `${color}15` }}
>
{!imgError ? (
<img src={imageUrl} alt={label} className="w-6 h-6 rounded-sm object-contain" onError={() => setImgError(true)} />
{imageUrl && !imgError ? (
<img
src={imageUrl}
alt={label}
className="w-6 h-6 rounded-sm object-contain"
loading="lazy"
decoding="async"
onError={() => {
const m = imageUrl?.match(/^\/providers\/([^/]+)\.png$/i);
if (m) markProviderIconMissing(m[1]);
setImgError(true);
}}
/>
) : (
<span className="text-sm font-bold" style={{ color }}>{textIcon}</span>
)}
@@ -86,7 +97,7 @@ function RouterNode({ data }) {
<Handle type="source" position={Position.Left} id="left" className="!bg-transparent !border-0 !w-0 !h-0" />
<Handle type="source" position={Position.Right} id="right" className="!bg-transparent !border-0 !w-0 !h-0" />
<img src="/favicon.svg" alt="9Router" className="w-6 h-6 mr-2" />
<img src="/favicon.svg" alt="9Router" className="w-6 h-6 mr-2" loading="lazy" decoding="async" />
<span className="text-sm font-bold text-primary">9Router</span>
{data.activeCount > 0 && (
<span className="ml-2 px-1.5 py-0.5 rounded-full bg-primary text-white text-xs font-bold">

View File

@@ -6,15 +6,52 @@ import { promisify } from "util";
import fs from "fs/promises";
import path from "path";
import os from "os";
import { DEFAULT_PLUGINS } from "@/shared/constants/coworkPlugins";
const execAsync = promisify(exec);
// Exa MCP def — reuse from coworkPlugins (DRY).
const EXA_PLUGIN = DEFAULT_PLUGINS.find((p) => p.name === "exa");
const buildExaMcpEntry = () => ({
type: EXA_PLUGIN.transport,
url: EXA_PLUGIN.url,
});
// Get claude settings path based on OS
const getClaudeSettingsPath = () => {
const homeDir = os.homedir();
return path.join(homeDir, ".claude", "settings.json");
};
// Claude Code CLI reads mcpServers from ~/.claude.json (NOT settings.json).
const getClaudeJsonPath = () => path.join(os.homedir(), ".claude.json");
const readClaudeJson = async () => {
try {
const content = await fs.readFile(getClaudeJsonPath(), "utf-8");
return JSON.parse(content.replace(/,(\s*[}\]])/g, "$1"));
} catch {
return null;
}
};
const writeClaudeJsonMcp = async (mcpServers) => {
const filePath = getClaudeJsonPath();
let data = {};
try {
data = JSON.parse(await fs.readFile(filePath, "utf-8"));
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
if (mcpServers && Object.keys(mcpServers).length > 0) {
data.mcpServers = { ...(data.mcpServers || {}), ...mcpServers };
} else if (data.mcpServers) {
delete data.mcpServers.exa;
if (Object.keys(data.mcpServers).length === 0) delete data.mcpServers;
}
await fs.writeFile(filePath, JSON.stringify(data, null, 2));
};
// Check if claude CLI is installed (via which/where or config file exists)
const checkClaudeInstalled = async () => {
@@ -65,11 +102,13 @@ export async function GET() {
const settings = await readSettings();
const has9Router = !!(settings?.env?.ANTHROPIC_BASE_URL);
const claudeJson = await readClaudeJson();
return NextResponse.json({
installed: true,
settings: settings,
has9Router: has9Router,
exaMcpEnabled: !!claudeJson?.mcpServers?.exa,
settingsPath: getClaudeSettingsPath(),
});
} catch (error) {
@@ -84,7 +123,7 @@ export async function GET() {
// POST - Backup old fields and write new settings
export async function POST(request) {
try {
const { env } = await request.json();
const { env, exaMcpEnabled } = await request.json();
if (!env || typeof env !== "object") {
return NextResponse.json(
@@ -130,6 +169,11 @@ export async function POST(request) {
// Write new settings
await fs.writeFile(settingsPath, JSON.stringify(newSettings, null, 2));
// Exa MCP toggle — write to ~/.claude.json (CLI reads mcpServers from here).
if (EXA_PLUGIN) {
await writeClaudeJsonMcp(exaMcpEnabled ? { exa: buildExaMcpEntry() } : null);
}
return NextResponse.json({
success: true,
message: "Settings updated successfully",
@@ -185,6 +229,9 @@ export async function DELETE() {
}
}
// Remove injected MCP servers (Exa) from ~/.claude.json
await writeClaudeJsonMcp(null);
// Write updated settings
await fs.writeFile(settingsPath, JSON.stringify(currentSettings, null, 2));
@@ -200,4 +247,3 @@ export async function DELETE() {
);
}
}

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} />
)}
</>
);
}

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"
/>
)}
</>

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),
},
],
};

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" />
)}

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,

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 };
}

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";

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);
}