diff --git a/public/providers/agentrouter.png b/public/providers/agentrouter.png new file mode 100644 index 00000000..6b5ca60d Binary files /dev/null and b/public/providers/agentrouter.png differ diff --git a/public/providers/codebuddy-cn.png b/public/providers/codebuddy-cn.png index eae3f4c1..c836282f 100644 Binary files a/public/providers/codebuddy-cn.png and b/public/providers/codebuddy-cn.png differ diff --git a/public/providers/commandcode.png b/public/providers/commandcode.png index ed7c8c99..3570fb00 100644 Binary files a/public/providers/commandcode.png and b/public/providers/commandcode.png differ diff --git a/public/providers/featherless.png b/public/providers/featherless.png new file mode 100644 index 00000000..b918a0ff Binary files /dev/null and b/public/providers/featherless.png differ diff --git a/public/providers/gitlab.png b/public/providers/gitlab.png new file mode 100644 index 00000000..9c790b1a Binary files /dev/null and b/public/providers/gitlab.png differ diff --git a/public/providers/longcat.png b/public/providers/longcat.png new file mode 100644 index 00000000..e72dffb9 Binary files /dev/null and b/public/providers/longcat.png differ diff --git a/public/providers/mmf.png b/public/providers/mmf.png new file mode 100644 index 00000000..3fbdd81d Binary files /dev/null and b/public/providers/mmf.png differ diff --git a/public/providers/morph.png b/public/providers/morph.png new file mode 100644 index 00000000..3034fa5e Binary files /dev/null and b/public/providers/morph.png differ diff --git a/public/providers/novita.png b/public/providers/novita.png new file mode 100644 index 00000000..d765fc56 Binary files /dev/null and b/public/providers/novita.png differ diff --git a/public/providers/perplexity-agent.png b/public/providers/perplexity-agent.png new file mode 100644 index 00000000..0b5851e5 Binary files /dev/null and b/public/providers/perplexity-agent.png differ diff --git a/public/providers/qoder.png b/public/providers/qoder.png index 41e81c1d..be87ec61 100644 Binary files a/public/providers/qoder.png and b/public/providers/qoder.png differ diff --git a/public/providers/reka.png b/public/providers/reka.png new file mode 100644 index 00000000..00016257 Binary files /dev/null and b/public/providers/reka.png differ diff --git a/public/providers/venice.png b/public/providers/venice.png new file mode 100644 index 00000000..38c528b5 Binary files /dev/null and b/public/providers/venice.png differ diff --git a/public/providers/vercel-ai-gateway.png b/public/providers/vercel-ai-gateway.png new file mode 100644 index 00000000..0319f0e6 Binary files /dev/null and b/public/providers/vercel-ai-gateway.png differ diff --git a/public/providers/vercel.png b/public/providers/vercel.png new file mode 100644 index 00000000..0319f0e6 Binary files /dev/null and b/public/providers/vercel.png differ diff --git a/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js b/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js index a97d0a5e..f9331074 100644 --- a/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js +++ b/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js @@ -891,7 +891,7 @@ export default function BasicChatPageClient() {
{message.attachments.map((attachment) => ( - {attachment.name} + {attachment.name} ))}
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js index 48962cca..6a7339d3 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js @@ -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" />
@@ -467,15 +464,17 @@ export default function AntigravityToolCard({ {/* Model Select Modal */} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} - activeProviders={activeProviders} - modelAliases={modelAliases} - title={`Select model for ${currentEditingAlias}`} - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} + activeProviders={activeProviders} + modelAliases={modelAliases} + title={`Select model for ${currentEditingAlias}`} + /> + )} ); } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js index 589d847a..9268475d 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js @@ -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({
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -352,6 +357,19 @@ export default function ClaudeToolCard({
+ + {/* Exa MCP — ~/.claude.json mcpServers (not settings.json) */} +
+ Web Search + arrow_forward + +
{message && ( @@ -377,7 +395,9 @@ export default function ClaudeToolCard({
)} - setModalOpen(false)} onSelect={handleModelSelect} selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} activeProviders={activeProviders} modelAliases={modelAliases} title={`Select model for ${currentEditingAlias}`} /> + {modalOpen && ( + setModalOpen(false)} onSelect={handleModelSelect} selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} activeProviders={activeProviders} modelAliases={modelAliases} title={`Select model for ${currentEditingAlias}`} /> + )} { - 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
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -280,15 +279,17 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api
)} - setModalOpen(false)} - onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Cline" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Cline" + /> + )} { - if (isExpanded && !codexStatus) { - checkCodexStatus(); + if (isExpanded) { + if (!codexStatus) checkCodexStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); const fetchModelAliases = async () => { @@ -199,7 +198,7 @@ model = "${effectiveSubagentModel}"
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -371,25 +370,29 @@ model = "${effectiveSubagentModel}"
)} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Codex" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Codex" + /> + )} - setSubagentModalOpen(false)} - onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }} - selectedModel={subagentModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Subagent Model for Codex" - /> + {subagentModalOpen && ( + setSubagentModalOpen(false)} + onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }} + selectedModel={subagentModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Subagent Model for Codex" + /> + )} { - 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
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -290,27 +289,29 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
)} - { - 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 && ( + { + 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" + /> + )}
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -514,27 +514,31 @@ export default function CoworkToolCard({ configs={getManualConfigs()} /> - setComboModalOpen(false)} - onSave={handleCreateCombo} - activeProviders={activeProviders} - forcePrefix="claude-" - title="Create Cowork Combo" - /> + {comboModalOpen && ( + setComboModalOpen(false)} + onSave={handleCreateCombo} + activeProviders={activeProviders} + forcePrefix="claude-" + title="Create Cowork Combo" + /> + )} - setModelSelectOpen(false)} - onSelect={handleAddModel} - onDeselect={handleRemoveModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Cowork Model" - addedModelValues={selectedModels} - closeOnSelect={false} - /> + {modelSelectOpen && ( + setModelSelectOpen(false)} + onSelect={handleAddModel} + onDeselect={handleRemoveModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Cowork Model" + addedModelValues={selectedModels} + closeOnSelect={false} + /> + )} { - 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"}"
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -317,15 +316,17 @@ model = "${selectedModel || "provider/model-id"}"
)} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for DeepSeek TUI" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for DeepSeek TUI" + /> + )} { e.target.style.display = "none"; }} + loading="lazy" + decoding="async" /> ); } if (tool.icon) { return {tool.icon}; } + const iconSrc = getProviderIconSrc(toolId); + if (!iconSrc) { + return {(toolId || "?").slice(0, 2).toUpperCase()}; + } return ( {tool.name} { 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
)} - setShowModelModal(false)} - onSelect={handleSelectModel} - selectedModel={modelValue} - activeProviders={activeProviders} - title="Select Model" - /> + {showModelModal && ( + setShowModelModal(false)} + onSelect={handleSelectModel} + selectedModel={modelValue} + activeProviders={activeProviders} + title="Select Model" + /> + )} ); } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js index 7e8e2eac..adc2a7ae 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js @@ -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({
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -389,15 +388,17 @@ export default function DroidToolCard({
)} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={null} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Factory Droid" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={null} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Factory Droid" + /> + )} { - 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" />
@@ -366,15 +367,17 @@ api_key = "${keyToUse}"
)} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Grok Build" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Grok Build" + /> + )} { - 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({
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -296,15 +295,17 @@ export default function HermesToolCard({
)} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Hermes Agent" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Hermes Agent" + /> + )} { - 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"}"`;
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -359,15 +358,17 @@ id = "${selectedModel || "cc/claude-opus-4-7"}"`;
)} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for jcode" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for jcode" + /> + )} { - 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
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -254,15 +253,17 @@ export default function KiloToolCard({ tool, isExpanded, onToggle, baseUrl, apiK
)} - setModalOpen(false)} - onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Kilo Code" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Kilo Code" + /> + )} { e.target.style.display = "none"; }} + loading="lazy" + decoding="async" />
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js index 82384292..3ca51d2e 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js @@ -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" />
@@ -304,15 +306,17 @@ export default function MitmToolCard({ )} {/* Model Select Modal */} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} - activeProviders={activeProviders} - modelAliases={modelAliases} - title={`Select model for ${currentEditingAlias}`} - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} + activeProviders={activeProviders} + modelAliases={modelAliases} + title={`Select model for ${currentEditingAlias}`} + /> + )} ); } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js index 88a73c63..b646a6eb 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js @@ -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({
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -367,15 +366,17 @@ export default function OpenClawToolCard({
)} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Open Claw" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Open Claw" + /> + )} { - 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,
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -452,42 +451,46 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
)} - { - 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 && ( + { + 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" + /> + )} - setSubagentModalOpen(false)} - onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }} - selectedModel={subagentModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Subagent Model for OpenCode" - /> + {subagentModalOpen && ( + setSubagentModalOpen(false)} + onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }} + selectedModel={subagentModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Subagent Model for OpenCode" + /> + )}
{tool.image ? ( - {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" /> ) : tool.icon ? ( {tool.icon} ) : null} diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index abfc215c..fb4c17d6 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -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() { setShowCreateModal(false)} - onSave={handleCreate} - activeProviders={activeProviders} - /> + {showCreateModal && ( + setShowCreateModal(false)} + onSave={handleCreate} + activeProviders={activeProviders} + /> + )} - {/* Edit Modal - Use key to force remount and reset state */} - setEditingCombo(null)} - onSave={(data) => handleUpdate(editingCombo.id, data)} - activeProviders={activeProviders} - /> + {editingCombo && ( + setEditingCombo(null)} + onSave={(data) => handleUpdate(editingCombo.id, data)} + activeProviders={activeProviders} + /> + )} {/* Confirm Delete Modal */} ( {model} - + )) )} @@ -340,15 +336,17 @@ function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy
{/* Judge model picker (single-select; combo members make natural judges too) */} - setShowJudgeSelect(false)} - onSelect={(m) => { onSetStrategy({ judgeModel: m?.value || "" }); setShowJudgeSelect(false); }} - activeProviders={activeProviders} - title="Select Judge Model" - addedModelValues={judge ? [judge] : []} - closeOnSelect={true} - /> + {showJudgeSelect && ( + setShowJudgeSelect(false)} + onSelect={(m) => { onSetStrategy({ judgeModel: m?.value || "" }); setShowJudgeSelect(false); }} + activeProviders={activeProviders} + title="Select Judge Model" + addedModelValues={judge ? [judge] : []} + closeOnSelect={true} + /> + )} ); } @@ -637,18 +635,20 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF {/* Model Select Modal */} - setShowModelSelect(false)} - onSelect={handleAddModel} - onDeselect={handleDeselectModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Add Model to Combo" - kindFilter={kindFilter} - addedModelValues={models} - closeOnSelect={false} - /> + {showModelSelect && ( + setShowModelSelect(false)} + onSelect={handleAddModel} + onDeselect={handleDeselectModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Add Model to Combo" + kindFilter={kindFilter} + addedModelValues={models} + closeOnSelect={false} + /> + )} ); } diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js index 815528d7..76c571b8 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js @@ -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" /> )}
@@ -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" /> )}
@@ -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" />
)} @@ -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" />
)} diff --git a/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js b/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js index 45c15460..7ad8b673 100644 --- a/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js @@ -357,7 +357,7 @@ export default function ComboDetailPage() { Download
- Generated + Generated
)} {testResult.audioUrl && ( @@ -393,18 +393,20 @@ export default function ComboDetailPage() { )} - setShowPicker(false)} - onSelect={handleAddModel} - onDeselect={handleDeselectModel} - activeProviders={connections} - modelAliases={modelAliases} - title={`Add ${kindLabel} Model`} - kindFilter={combo.kind} - addedModelValues={providers} - closeOnSelect={false} - /> + {showPicker && ( + setShowPicker(false)} + onSelect={handleAddModel} + onDeselect={handleDeselectModel} + activeProviders={connections} + modelAliases={modelAliases} + title={`Add ${kindLabel} Model`} + kindFilter={combo.kind} + addedModelValues={providers} + closeOnSelect={false} + /> + )}
); } diff --git a/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js b/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js index a6d9533e..9bdf4b41 100644 --- a/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js +++ b/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js @@ -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 /> diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index dd134f33..61461726 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -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 ( diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index babfa6e2..a0396897 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -1265,22 +1265,24 @@ export default function ProviderLimits() { /> )} {hiddenQuotaRows.length > 0 && ( -
- +
+ visibility_off - Hidden: - {hiddenQuotaRows.map((quotaRow) => ( - - ))} + Hidden: +
+ {hiddenQuotaRows.map((quotaRow) => ( + + ))} +
)}
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js b/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js index 3827a0f8..57b217a3 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js @@ -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 ? ( - {label} setImgError(true)} /> + {imageUrl && !imgError ? ( + {label} { + const m = imageUrl?.match(/^\/providers\/([^/]+)\.png$/i); + if (m) markProviderIconMissing(m[1]); + setImgError(true); + }} + /> ) : ( {textIcon} )} @@ -86,7 +97,7 @@ function RouterNode({ data }) { - 9Router + 9Router 9Router {data.activeCount > 0 && ( diff --git a/src/app/api/cli-tools/claude-settings/route.js b/src/app/api/cli-tools/claude-settings/route.js index bb5fa06f..b831e395 100644 --- a/src/app/api/cli-tools/claude-settings/route.js +++ b/src/app/api/cli-tools/claude-settings/route.js @@ -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() { ); } } - diff --git a/src/shared/components/ComboFormModal.js b/src/shared/components/ComboFormModal.js index d80732da..ca022f80 100644 --- a/src/shared/components/ComboFormModal.js +++ b/src/shared/components/ComboFormModal.js @@ -166,11 +166,13 @@ export default function ComboFormModal({ isOpen, combo, onClose, onSave, activeP
- setShowModelSelect(false)} - onSelect={handleAddModel} onDeselect={handleDeselectModel} - activeProviders={activeProviders} modelAliases={modelAliases} - title="Add Model to Combo" kindFilter={kindFilter} - addedModelValues={models} closeOnSelect={false} /> + {showModelSelect && ( + setShowModelSelect(false)} + onSelect={handleAddModel} onDeselect={handleDeselectModel} + activeProviders={activeProviders} modelAliases={modelAliases} + title="Add Model to Combo" kindFilter={kindFilter} + addedModelValues={models} closeOnSelect={false} /> + )} ); } diff --git a/src/shared/components/DonateModal.js b/src/shared/components/DonateModal.js index bf1e9af5..e4f3daea 100644 --- a/src/shared/components/DonateModal.js +++ b/src/shared/components/DonateModal.js @@ -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" /> )} diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js index f70a0128..c3bbe606 100644 --- a/src/shared/components/Header.js +++ b/src/shared/components/Header.js @@ -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), }, ], }; diff --git a/src/shared/components/McpMarketplaceModal.js b/src/shared/components/McpMarketplaceModal.js index 5b18dfb5..826ad260 100644 --- a/src/shared/components/McpMarketplaceModal.js +++ b/src/shared/components/McpMarketplaceModal.js @@ -154,7 +154,7 @@ export default function McpMarketplaceModal({ isOpen, onClose, onAdd, addedNames
{s.iconUrl ? ( // eslint-disable-next-line @next/next/no-img-element - { e.target.style.display = "none"; }} /> + { e.target.style.display = "none"; }} loading="lazy" decoding="async" /> ) : (
)} diff --git a/src/shared/components/ProviderIcon.js b/src/shared/components/ProviderIcon.js index ca558508..f69342e3 100644 --- a/src/shared/components/ProviderIcon.js +++ b/src/shared/components/ProviderIcon.js @@ -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 ( 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, diff --git a/src/shared/hooks/useModelCaps.js b/src/shared/hooks/useModelCaps.js index 3dbdcdd4..e6fb4c52 100644 --- a/src/shared/hooks/useModelCaps.js +++ b/src/shared/hooks/useModelCaps.js @@ -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 }; } diff --git a/src/shared/utils/index.js b/src/shared/utils/index.js index f64461ab..72fc1a5a 100644 --- a/src/shared/utils/index.js +++ b/src/shared/utils/index.js @@ -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"; diff --git a/src/shared/utils/providerIcon.js b/src/shared/utils/providerIcon.js new file mode 100644 index 00000000..32167d14 --- /dev/null +++ b/src/shared/utils/providerIcon.js @@ -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); +}