fix(dashboard): cut duplicate API/icon spam, lazy-load provider assets

Share one /api/models fetch via useModelCaps cache, mount ModelSelectModal
only when open, stop double fetchModelAliases on CLI tool cards, and resolve
provider icons through a session 404 cache with missing PNGs + loading=lazy.
Also include Claude Exa MCP toggle (claude-settings + ClaudeToolCard) that
was already in the working tree.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
decolua
2026-07-17 12:12:20 +07:00
co-authored by Cursor
parent 68566f53dc
commit ccb0842d0a
50 changed files with 612 additions and 392 deletions
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -891,7 +891,7 @@ export default function BasicChatPageClient() {
<div className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-3 mt-2"> <div className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-3 mt-2">
{message.attachments.map((attachment) => ( {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"> <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> </a>
))} ))}
</div> </div>
@@ -38,15 +38,10 @@ export default function AntigravityToolCard({
}, [initialStatus]); }, [initialStatus]);
useEffect(() => { useEffect(() => {
if (isExpanded && !status) { if (!isExpanded) return;
fetchStatus(); if (!status) fetchStatus();
loadSavedMappings(); loadSavedMappings();
fetchModelAliases(); fetchModelAliases();
}
if (isExpanded) {
loadSavedMappings();
fetchModelAliases();
}
}, [isExpanded]); }, [isExpanded]);
const loadSavedMappings = async () => { const loadSavedMappings = async () => {
@@ -243,6 +238,8 @@ export default function AntigravityToolCard({
className="size-8 object-contain rounded-lg" className="size-8 object-contain rounded-lg"
sizes="32px" sizes="32px"
onError={(e) => { e.target.style.display = "none"; }} onError={(e) => { e.target.style.display = "none"; }}
loading="lazy"
decoding="async"
/> />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
@@ -467,15 +464,17 @@ export default function AntigravityToolCard({
</Modal> </Modal>
{/* Model Select Modal */} {/* Model Select Modal */}
<ModelSelectModal {modalOpen && (
isOpen={modalOpen} <ModelSelectModal
onClose={() => setModalOpen(false)} isOpen={modalOpen}
onSelect={handleModelSelect} onClose={() => setModalOpen(false)}
selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} onSelect={handleModelSelect}
activeProviders={activeProviders} selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null}
modelAliases={modelAliases} activeProviders={activeProviders}
title={`Select model for ${currentEditingAlias}`} modelAliases={modelAliases}
/> title={`Select model for ${currentEditingAlias}`}
/>
)}
</Card> </Card>
); );
} }
@@ -39,6 +39,7 @@ export default function ClaudeToolCard({
const [showManualConfigModal, setShowManualConfigModal] = useState(false); const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState(""); const [customBaseUrl, setCustomBaseUrl] = useState("");
const [ccFilterNaming, setCcFilterNaming] = useState(false); const [ccFilterNaming, setCcFilterNaming] = useState(false);
const [exaMcpEnabled, setExaMcpEnabled] = useState(false);
const hasInitializedModels = useRef(false); const hasInitializedModels = useRef(false);
const getConfigStatus = () => { const getConfigStatus = () => {
@@ -58,15 +59,17 @@ export default function ClaudeToolCard({
}, [apiKeys, selectedApiKey]); }, [apiKeys, selectedApiKey]);
useEffect(() => { useEffect(() => {
if (initialStatus) setClaudeStatus(initialStatus); if (initialStatus) {
setClaudeStatus(initialStatus);
setExaMcpEnabled(!!initialStatus.exaMcpEnabled);
}
}, [initialStatus]); }, [initialStatus]);
useEffect(() => { useEffect(() => {
if (isExpanded && !claudeStatus) { if (isExpanded) {
checkClaudeStatus(); if (!claudeStatus) checkClaudeStatus();
fetchModelAliases(); fetchModelAliases();
} }
if (isExpanded) fetchModelAliases();
}, [isExpanded]); }, [isExpanded]);
useEffect(() => { useEffect(() => {
@@ -123,6 +126,7 @@ export default function ClaudeToolCard({
const res = await fetch("/api/cli-tools/claude-settings"); const res = await fetch("/api/cli-tools/claude-settings");
const data = await res.json(); const data = await res.json();
setClaudeStatus(data); setClaudeStatus(data);
setExaMcpEnabled(!!data.exaMcpEnabled);
} catch (error) { } catch (error) {
setClaudeStatus({ installed: false, error: error.message }); setClaudeStatus({ installed: false, error: error.message });
} finally { } finally {
@@ -162,12 +166,12 @@ export default function ClaudeToolCard({
const res = await fetch("/api/cli-tools/claude-settings", { const res = await fetch("/api/cli-tools/claude-settings", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ env }), body: JSON.stringify({ env, exaMcpEnabled }),
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" }); 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 { } else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" }); 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!" }); setMessage({ type: "success", text: "Settings reset successfully!" });
tool.defaultModels.forEach((model) => onModelMappingChange(model.alias, model.defaultValue || "")); tool.defaultModels.forEach((model) => onModelMappingChange(model.alias, model.defaultValue || ""));
setSelectedApiKey(""); setSelectedApiKey("");
setExaMcpEnabled(false);
} else { } else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" }); 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 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="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0"> <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>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
@@ -352,6 +357,19 @@ export default function ClaudeToolCard({
</Tooltip> </Tooltip>
</label> </label>
</div> </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> </div>
{message && ( {message && (
@@ -377,7 +395,9 @@ export default function ClaudeToolCard({
</div> </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 <ManualConfigModal
isOpen={showManualConfigModal} isOpen={showManualConfigModal}
@@ -30,11 +30,10 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api
}, [initialStatus]); }, [initialStatus]);
useEffect(() => { useEffect(() => {
if (isExpanded && !status) { if (isExpanded) {
checkStatus(); if (!status) checkStatus();
fetchModelAliases(); fetchModelAliases();
} }
if (isExpanded) fetchModelAliases();
}, [isExpanded]); }, [isExpanded]);
useEffect(() => { 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 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="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0"> <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>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <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> </div>
)} )}
<ModelSelectModal {modalOpen && (
isOpen={modalOpen} <ModelSelectModal
onClose={() => setModalOpen(false)} isOpen={modalOpen}
onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }} onClose={() => setModalOpen(false)}
selectedModel={selectedModel} onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }}
activeProviders={activeProviders} selectedModel={selectedModel}
modelAliases={modelAliases} activeProviders={activeProviders}
title="Select Model for Cline" modelAliases={modelAliases}
/> title="Select Model for Cline"
/>
)}
<ManualConfigModal <ManualConfigModal
isOpen={showManualConfigModal} isOpen={showManualConfigModal}
@@ -34,11 +34,10 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
}, [initialStatus]); }, [initialStatus]);
useEffect(() => { useEffect(() => {
if (isExpanded && !codexStatus) { if (isExpanded) {
checkCodexStatus(); if (!codexStatus) checkCodexStatus();
fetchModelAliases(); fetchModelAliases();
} }
if (isExpanded) fetchModelAliases();
}, [isExpanded]); }, [isExpanded]);
const fetchModelAliases = async () => { 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 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="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0"> <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>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
@@ -371,25 +370,29 @@ model = "${effectiveSubagentModel}"
</div> </div>
)} )}
<ModelSelectModal {modalOpen && (
isOpen={modalOpen} <ModelSelectModal
onClose={() => setModalOpen(false)} isOpen={modalOpen}
onSelect={handleModelSelect} onClose={() => setModalOpen(false)}
selectedModel={selectedModel} onSelect={handleModelSelect}
activeProviders={activeProviders} selectedModel={selectedModel}
modelAliases={modelAliases} activeProviders={activeProviders}
title="Select Model for Codex" modelAliases={modelAliases}
/> title="Select Model for Codex"
/>
)}
<ModelSelectModal {subagentModalOpen && (
isOpen={subagentModalOpen} <ModelSelectModal
onClose={() => setSubagentModalOpen(false)} isOpen={subagentModalOpen}
onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }} onClose={() => setSubagentModalOpen(false)}
selectedModel={subagentModel} onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }}
activeProviders={activeProviders} selectedModel={subagentModel}
modelAliases={modelAliases} activeProviders={activeProviders}
title="Select Subagent Model for Codex" modelAliases={modelAliases}
/> title="Select Subagent Model for Codex"
/>
)}
<ManualConfigModal <ManualConfigModal
isOpen={showManualConfigModal} isOpen={showManualConfigModal}
@@ -36,11 +36,10 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
}, [initialStatus]); }, [initialStatus]);
useEffect(() => { useEffect(() => {
if (isExpanded && !status) { if (isExpanded) {
checkStatus(); if (!status) checkStatus();
fetchModelAliases(); fetchModelAliases();
} }
if (isExpanded) fetchModelAliases();
}, [isExpanded]); }, [isExpanded]);
// Pre-fill from existing config // 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 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="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0"> <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>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <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> </div>
)} )}
<ModelSelectModal {modalOpen && (
isOpen={modalOpen} <ModelSelectModal
onClose={() => { isOpen={modalOpen}
setModalOpen(false); onClose={() => {
saveModels(selectedModelsRef.current); setModalOpen(false);
}} saveModels(selectedModelsRef.current);
onSelect={(model) => { }}
if (!selectedModels.includes(model.value)) { onSelect={(model) => {
setSelectedModels([...selectedModels, model.value]); if (!selectedModels.includes(model.value)) {
} setSelectedModels([...selectedModels, model.value]);
}} }
onDeselect={(model) => { }}
setSelectedModels(selectedModels.filter(m => m !== model.value)); onDeselect={(model) => {
}} setSelectedModels(selectedModels.filter(m => m !== model.value));
selectedModel={null} }}
activeProviders={activeProviders} selectedModel={null}
modelAliases={modelAliases} activeProviders={activeProviders}
addedModelValues={selectedModels} modelAliases={modelAliases}
closeOnSelect={false} addedModelValues={selectedModels}
title="Add Model for GitHub Copilot" closeOnSelect={false}
/> title="Add Model for GitHub Copilot"
/>
)}
<ManualConfigModal <ManualConfigModal
isOpen={showManualConfigModal} isOpen={showManualConfigModal}
@@ -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 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="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0"> <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>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
@@ -514,27 +514,31 @@ export default function CoworkToolCard({
configs={getManualConfigs()} configs={getManualConfigs()}
/> />
<ComboFormModal {comboModalOpen && (
isOpen={comboModalOpen} <ComboFormModal
combo={null} isOpen={comboModalOpen}
onClose={() => setComboModalOpen(false)} combo={null}
onSave={handleCreateCombo} onClose={() => setComboModalOpen(false)}
activeProviders={activeProviders} onSave={handleCreateCombo}
forcePrefix="claude-" activeProviders={activeProviders}
title="Create Cowork Combo" forcePrefix="claude-"
/> title="Create Cowork Combo"
/>
)}
<ModelSelectModal {modelSelectOpen && (
isOpen={modelSelectOpen} <ModelSelectModal
onClose={() => setModelSelectOpen(false)} isOpen={modelSelectOpen}
onSelect={handleAddModel} onClose={() => setModelSelectOpen(false)}
onDeselect={handleRemoveModel} onSelect={handleAddModel}
activeProviders={activeProviders} onDeselect={handleRemoveModel}
modelAliases={modelAliases} activeProviders={activeProviders}
title="Select Cowork Model" modelAliases={modelAliases}
addedModelValues={selectedModels} title="Select Cowork Model"
closeOnSelect={false} addedModelValues={selectedModels}
/> closeOnSelect={false}
/>
)}
<McpMarketplaceModal <McpMarketplaceModal
isOpen={marketplaceOpen} isOpen={marketplaceOpen}
@@ -58,11 +58,10 @@ export default function DeepSeekTuiToolCard({
}, [initialStatus]); }, [initialStatus]);
useEffect(() => { useEffect(() => {
if (isExpanded && !deepseekStatus) { if (isExpanded) {
checkStatus(); if (!deepseekStatus) checkStatus();
fetchModelAliases(); fetchModelAliases();
} }
if (isExpanded) fetchModelAliases();
}, [isExpanded]); }, [isExpanded]);
const fetchModelAliases = async () => { 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 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="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0"> <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>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
@@ -317,15 +316,17 @@ model = "${selectedModel || "provider/model-id"}"
</div> </div>
)} )}
<ModelSelectModal {modalOpen && (
isOpen={modalOpen} <ModelSelectModal
onClose={() => setModalOpen(false)} isOpen={modalOpen}
onSelect={handleModelSelect} onClose={() => setModalOpen(false)}
selectedModel={selectedModel} onSelect={handleModelSelect}
activeProviders={activeProviders} selectedModel={selectedModel}
modelAliases={modelAliases} activeProviders={activeProviders}
title="Select Model for DeepSeek TUI" modelAliases={modelAliases}
/> title="Select Model for DeepSeek TUI"
/>
)}
<ManualConfigModal <ManualConfigModal
isOpen={showManualConfigModal} isOpen={showManualConfigModal}
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { Card, ModelSelectModal } from "@/shared/components"; import { Card, ModelSelectModal } from "@/shared/components";
import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/providerIcon";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import Image from "next/image"; import Image from "next/image";
import ApiKeySelect from "./ApiKeySelect"; import ApiKeySelect from "./ApiKeySelect";
@@ -217,21 +218,32 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba
className="size-8 object-contain rounded-lg" className="size-8 object-contain rounded-lg"
sizes="32px" sizes="32px"
onError={(e) => { e.target.style.display = "none"; }} onError={(e) => { e.target.style.display = "none"; }}
loading="lazy"
decoding="async"
/> />
); );
} }
if (tool.icon) { if (tool.icon) {
return <span className="material-symbols-outlined text-xl" style={{ color: tool.color }}>{tool.icon}</span>; 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 ( return (
<Image <Image
src={`/providers/${toolId}.png`} src={iconSrc}
alt={tool.name} alt={tool.name}
width={32} width={32}
height={32} height={32}
className="size-8 object-contain rounded-lg" className="size-8 object-contain rounded-lg"
sizes="32px" 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> </div>
)} )}
<ModelSelectModal {showModelModal && (
isOpen={showModelModal} <ModelSelectModal
onClose={() => setShowModelModal(false)} isOpen={showModelModal}
onSelect={handleSelectModel} onClose={() => setShowModelModal(false)}
selectedModel={modelValue} onSelect={handleSelectModel}
activeProviders={activeProviders} selectedModel={modelValue}
title="Select Model" activeProviders={activeProviders}
/> title="Select Model"
/>
)}
</Card> </Card>
); );
} }
@@ -60,11 +60,10 @@ export default function DroidToolCard({
}, [initialStatus]); }, [initialStatus]);
useEffect(() => { useEffect(() => {
if (isExpanded && !droidStatus) { if (isExpanded) {
checkDroidStatus(); if (!droidStatus) checkDroidStatus();
fetchModelAliases(); fetchModelAliases();
} }
if (isExpanded) fetchModelAliases();
}, [isExpanded]); }, [isExpanded]);
const fetchModelAliases = async () => { 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 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="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0"> <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>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
@@ -389,15 +388,17 @@ export default function DroidToolCard({
</div> </div>
)} )}
<ModelSelectModal {modalOpen && (
isOpen={modalOpen} <ModelSelectModal
onClose={() => setModalOpen(false)} isOpen={modalOpen}
onSelect={handleModelSelect} onClose={() => setModalOpen(false)}
selectedModel={null} onSelect={handleModelSelect}
activeProviders={activeProviders} selectedModel={null}
modelAliases={modelAliases} activeProviders={activeProviders}
title="Select Model for Factory Droid" modelAliases={modelAliases}
/> title="Select Model for Factory Droid"
/>
)}
<ManualConfigModal <ManualConfigModal
isOpen={showManualConfigModal} isOpen={showManualConfigModal}
@@ -59,11 +59,10 @@ export default function GrokBuildToolCard({
}, [initialStatus]); }, [initialStatus]);
useEffect(() => { useEffect(() => {
if (isExpanded && !grokStatus) { if (isExpanded) {
checkStatus(); if (!grokStatus) checkStatus();
fetchModelAliases(); fetchModelAliases();
} }
if (isExpanded) fetchModelAliases();
}, [isExpanded]); }, [isExpanded]);
const fetchModelAliases = async () => { const fetchModelAliases = async () => {
@@ -203,6 +202,8 @@ api_key = "${keyToUse}"
className="size-8 object-contain rounded-lg" className="size-8 object-contain rounded-lg"
sizes="32px" sizes="32px"
onError={(e) => { e.target.style.display = "none"; }} onError={(e) => { e.target.style.display = "none"; }}
loading="lazy"
decoding="async"
/> />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
@@ -366,15 +367,17 @@ api_key = "${keyToUse}"
</div> </div>
)} )}
<ModelSelectModal {modalOpen && (
isOpen={modalOpen} <ModelSelectModal
onClose={() => setModalOpen(false)} isOpen={modalOpen}
onSelect={handleModelSelect} onClose={() => setModalOpen(false)}
selectedModel={selectedModel} onSelect={handleModelSelect}
activeProviders={activeProviders} selectedModel={selectedModel}
modelAliases={modelAliases} activeProviders={activeProviders}
title="Select Model for Grok Build" modelAliases={modelAliases}
/> title="Select Model for Grok Build"
/>
)}
<ManualConfigModal <ManualConfigModal
isOpen={showManualConfigModal} isOpen={showManualConfigModal}
@@ -58,11 +58,10 @@ export default function HermesToolCard({
}, [initialStatus]); }, [initialStatus]);
useEffect(() => { useEffect(() => {
if (isExpanded && !hermesStatus) { if (isExpanded) {
checkStatus(); if (!hermesStatus) checkStatus();
fetchModelAliases(); fetchModelAliases();
} }
if (isExpanded) fetchModelAliases();
}, [isExpanded]); }, [isExpanded]);
const fetchModelAliases = async () => { 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 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="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0"> <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>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
@@ -296,15 +295,17 @@ export default function HermesToolCard({
</div> </div>
)} )}
<ModelSelectModal {modalOpen && (
isOpen={modalOpen} <ModelSelectModal
onClose={() => setModalOpen(false)} isOpen={modalOpen}
onSelect={handleModelSelect} onClose={() => setModalOpen(false)}
selectedModel={selectedModel} onSelect={handleModelSelect}
activeProviders={activeProviders} selectedModel={selectedModel}
modelAliases={modelAliases} activeProviders={activeProviders}
title="Select Model for Hermes Agent" modelAliases={modelAliases}
/> title="Select Model for Hermes Agent"
/>
)}
<ManualConfigModal <ManualConfigModal
isOpen={showManualConfigModal} isOpen={showManualConfigModal}
@@ -56,11 +56,10 @@ export default function JcodeToolCard({
}, [initialStatus]); }, [initialStatus]);
useEffect(() => { useEffect(() => {
if (isExpanded && !jcodeStatus) { if (isExpanded) {
checkJcodeStatus(); if (!jcodeStatus) checkJcodeStatus();
fetchModelAliases(); fetchModelAliases();
} }
if (isExpanded) fetchModelAliases();
}, [isExpanded]); }, [isExpanded]);
const fetchModelAliases = async () => { 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 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="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0"> <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>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
@@ -359,15 +358,17 @@ id = "${selectedModel || "cc/claude-opus-4-7"}"`;
</div> </div>
)} )}
<ModelSelectModal {modalOpen && (
isOpen={modalOpen} <ModelSelectModal
onClose={() => setModalOpen(false)} isOpen={modalOpen}
onSelect={handleModelSelect} onClose={() => setModalOpen(false)}
selectedModel={selectedModel} onSelect={handleModelSelect}
activeProviders={activeProviders} selectedModel={selectedModel}
modelAliases={modelAliases} activeProviders={activeProviders}
title="Select Model for jcode" modelAliases={modelAliases}
/> title="Select Model for jcode"
/>
)}
<ManualConfigModal <ManualConfigModal
isOpen={showManualConfigModal} isOpen={showManualConfigModal}
@@ -30,11 +30,10 @@ export default function KiloToolCard({ tool, isExpanded, onToggle, baseUrl, apiK
}, [initialStatus]); }, [initialStatus]);
useEffect(() => { useEffect(() => {
if (isExpanded && !status) { if (isExpanded) {
checkStatus(); if (!status) checkStatus();
fetchModelAliases(); fetchModelAliases();
} }
if (isExpanded) fetchModelAliases();
}, [isExpanded]); }, [isExpanded]);
const fetchModelAliases = async () => { 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 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="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0"> <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>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <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> </div>
)} )}
<ModelSelectModal {modalOpen && (
isOpen={modalOpen} <ModelSelectModal
onClose={() => setModalOpen(false)} isOpen={modalOpen}
onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }} onClose={() => setModalOpen(false)}
selectedModel={selectedModel} onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }}
activeProviders={activeProviders} selectedModel={selectedModel}
modelAliases={modelAliases} activeProviders={activeProviders}
title="Select Model for Kilo Code" modelAliases={modelAliases}
/> title="Select Model for Kilo Code"
/>
)}
<ManualConfigModal <ManualConfigModal
isOpen={showManualConfigModal} isOpen={showManualConfigModal}
@@ -22,6 +22,8 @@ export default function MitmLinkCard({ tool }) {
className="size-8 object-contain rounded-lg" className="size-8 object-contain rounded-lg"
sizes="32px" sizes="32px"
onError={(e) => { e.target.style.display = "none"; }} onError={(e) => { e.target.style.display = "none"; }}
loading="lazy"
decoding="async"
/> />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
@@ -143,6 +143,8 @@ export default function MitmToolCard({
className="size-8 object-contain rounded-lg" className="size-8 object-contain rounded-lg"
sizes="32px" sizes="32px"
onError={(e) => { e.target.style.display = "none"; }} onError={(e) => { e.target.style.display = "none"; }}
loading="lazy"
decoding="async"
/> />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
@@ -304,15 +306,17 @@ export default function MitmToolCard({
)} )}
{/* Model Select Modal */} {/* Model Select Modal */}
<ModelSelectModal {modalOpen && (
isOpen={modalOpen} <ModelSelectModal
onClose={() => setModalOpen(false)} isOpen={modalOpen}
onSelect={handleModelSelect} onClose={() => setModalOpen(false)}
selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} onSelect={handleModelSelect}
activeProviders={activeProviders} selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null}
modelAliases={modelAliases} activeProviders={activeProviders}
title={`Select model for ${currentEditingAlias}`} modelAliases={modelAliases}
/> title={`Select model for ${currentEditingAlias}`}
/>
)}
</> </>
); );
} }
@@ -57,11 +57,10 @@ export default function OpenClawToolCard({
}, [initialStatus]); }, [initialStatus]);
useEffect(() => { useEffect(() => {
if (isExpanded && !openclawStatus) { if (isExpanded) {
checkOpenclawStatus(); if (!openclawStatus) checkOpenclawStatus();
fetchModelAliases(); fetchModelAliases();
} }
if (isExpanded) fetchModelAliases();
}, [isExpanded]); }, [isExpanded]);
const fetchModelAliases = async () => { 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 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="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0"> <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>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
@@ -367,15 +366,17 @@ export default function OpenClawToolCard({
</div> </div>
)} )}
<ModelSelectModal {modalOpen && (
isOpen={modalOpen} <ModelSelectModal
onClose={() => setModalOpen(false)} isOpen={modalOpen}
onSelect={handleModelSelect} onClose={() => setModalOpen(false)}
selectedModel={selectedModel} onSelect={handleModelSelect}
activeProviders={activeProviders} selectedModel={selectedModel}
modelAliases={modelAliases} activeProviders={activeProviders}
title="Select Model for Open Claw" modelAliases={modelAliases}
/> title="Select Model for Open Claw"
/>
)}
<ManualConfigModal <ManualConfigModal
isOpen={showManualConfigModal} isOpen={showManualConfigModal}
@@ -41,11 +41,10 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
}, [initialStatus]); }, [initialStatus]);
useEffect(() => { useEffect(() => {
if (isExpanded && !status) { if (isExpanded) {
checkStatus(); if (!status) checkStatus();
fetchModelAliases(); fetchModelAliases();
} }
if (isExpanded) fetchModelAliases();
}, [isExpanded]); }, [isExpanded]);
// Sync models from existing config // 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 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="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0"> <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>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <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> </div>
)} )}
<ModelSelectModal {modalOpen && (
isOpen={modalOpen} <ModelSelectModal
onClose={() => { isOpen={modalOpen}
setModalOpen(false); onClose={() => {
saveModels(selectedModelsRef.current); setModalOpen(false);
}} saveModels(selectedModelsRef.current);
onSelect={(model) => { }}
if (!selectedModels.includes(model.value)) { onSelect={(model) => {
setSelectedModels([...selectedModels, model.value]); if (!selectedModels.includes(model.value)) {
if (!activeModel) setActiveModel(model.value); setSelectedModels([...selectedModels, model.value]);
} if (!activeModel) setActiveModel(model.value);
}} }
onDeselect={(model) => { }}
const remaining = selectedModels.filter(m => m !== model.value); onDeselect={(model) => {
setSelectedModels(remaining); const remaining = selectedModels.filter(m => m !== model.value);
if (activeModel === model.value) { setSelectedModels(remaining);
setActiveModel(remaining[0] || ""); if (activeModel === model.value) {
} setActiveModel(remaining[0] || "");
}} }
selectedModel={null} }}
activeProviders={activeProviders} selectedModel={null}
modelAliases={modelAliases} activeProviders={activeProviders}
addedModelValues={selectedModels} modelAliases={modelAliases}
closeOnSelect={false} addedModelValues={selectedModels}
title="Add Model for OpenCode" closeOnSelect={false}
/> title="Add Model for OpenCode"
/>
)}
<ModelSelectModal {subagentModalOpen && (
isOpen={subagentModalOpen} <ModelSelectModal
onClose={() => setSubagentModalOpen(false)} isOpen={subagentModalOpen}
onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }} onClose={() => setSubagentModalOpen(false)}
selectedModel={subagentModel} onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }}
activeProviders={activeProviders} selectedModel={subagentModel}
modelAliases={modelAliases} activeProviders={activeProviders}
title="Select Subagent Model for OpenCode" modelAliases={modelAliases}
/> title="Select Subagent Model for OpenCode"
/>
)}
<ManualConfigModal <ManualConfigModal
isOpen={showManualConfigModal} isOpen={showManualConfigModal}
@@ -21,7 +21,7 @@ export default function ToolSummaryCard({ toolId, tool, status }) {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0"> <div className="size-8 flex items-center justify-center shrink-0">
{tool.image ? ( {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 ? ( ) : tool.icon ? (
<span className="material-symbols-outlined text-[28px]" style={{ color: tool.color }}>{tool.icon}</span> <span className="material-symbols-outlined text-[28px]" style={{ color: tool.color }}>{tool.icon}</span>
) : null} ) : null}
+50 -50
View File
@@ -7,6 +7,7 @@ import { CSS } from "@dnd-kit/utilities";
import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers"; import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers";
import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, ConfirmModal, CapacityBadges, Select } from "@/shared/components"; import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, ConfirmModal, CapacityBadges, Select } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useModelCaps } from "@/shared/hooks/useModelCaps";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
// Validate combo name: only a-z, A-Z, 0-9, -, _ // Validate combo name: only a-z, A-Z, 0-9, -, _
@@ -19,7 +20,7 @@ export default function CombosPage() {
const [editingCombo, setEditingCombo] = useState(null); const [editingCombo, setEditingCombo] = useState(null);
const [activeProviders, setActiveProviders] = useState([]); const [activeProviders, setActiveProviders] = useState([]);
const [comboStrategies, setComboStrategies] = useState({}); const [comboStrategies, setComboStrategies] = useState({});
const [modelCaps, setModelCaps] = useState({}); const { getCaps } = useModelCaps();
const [confirmState, setConfirmState] = useState(null); const [confirmState, setConfirmState] = useState(null);
const { copied, copy } = useCopyToClipboard(); const { copied, copy } = useCopyToClipboard();
@@ -29,11 +30,10 @@ export default function CombosPage() {
const fetchData = async () => { const fetchData = async () => {
try { try {
const [combosRes, providersRes, settingsRes, modelsRes] = await Promise.all([ const [combosRes, providersRes, settingsRes] = await Promise.all([
fetch("/api/combos"), fetch("/api/combos"),
fetch("/api/providers"), fetch("/api/providers"),
fetch("/api/settings"), fetch("/api/settings"),
fetch("/api/models"),
]); ]);
const combosData = await combosRes.json(); const combosData = await combosRes.json();
const providersData = await providersRes.json(); const providersData = await providersRes.json();
@@ -44,13 +44,6 @@ export default function CombosPage() {
if (providersRes.ok) { if (providersRes.ok) {
setActiveProviders(providersData.connections || []); 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 || {}); setComboStrategies(settingsData.comboStrategies || {});
} catch (error) { } catch (error) {
console.log("Error fetching data:", error); console.log("Error fetching data:", error);
@@ -189,7 +182,7 @@ export default function CombosPage() {
<ComboCard <ComboCard
key={combo.id} key={combo.id}
combo={combo} combo={combo}
modelCaps={modelCaps} getCaps={getCaps}
activeProviders={activeProviders} activeProviders={activeProviders}
copied={copied} copied={copied}
onCopy={copy} onCopy={copy}
@@ -203,23 +196,26 @@ export default function CombosPage() {
)} )}
{/* Create Modal - Use key to force remount and reset state */} {/* Create Modal - Use key to force remount and reset state */}
<ComboFormModal {showCreateModal && (
key="create" <ComboFormModal
isOpen={showCreateModal} key="create"
onClose={() => setShowCreateModal(false)} isOpen={showCreateModal}
onSave={handleCreate} onClose={() => setShowCreateModal(false)}
activeProviders={activeProviders} onSave={handleCreate}
/> activeProviders={activeProviders}
/>
)}
{/* Edit Modal - Use key to force remount and reset state */} {editingCombo && (
<ComboFormModal <ComboFormModal
key={editingCombo?.id || "new"} key={editingCombo.id}
isOpen={!!editingCombo} isOpen={!!editingCombo}
combo={editingCombo} combo={editingCombo}
onClose={() => setEditingCombo(null)} onClose={() => setEditingCombo(null)}
onSave={(data) => handleUpdate(editingCombo.id, data)} onSave={(data) => handleUpdate(editingCombo.id, data)}
activeProviders={activeProviders} activeProviders={activeProviders}
/> />
)}
{/* Confirm Delete Modal */} {/* Confirm Delete Modal */}
<ConfirmModal <ConfirmModal
@@ -240,7 +236,7 @@ const STRATEGY_OPTIONS = [
{ value: "fusion", label: "Fusion — panel + judge" }, { 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 [showJudgeSelect, setShowJudgeSelect] = useState(false);
const current = strategy.fallbackStrategy || "fallback"; const current = strategy.fallbackStrategy || "fallback";
const judge = strategy.judgeModel || ""; const judge = strategy.judgeModel || "";
@@ -262,7 +258,7 @@ function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy
combo.models.slice(0, 3).map((model, index) => ( 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"> <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> <span>{model}</span>
<CapacityBadges caps={modelCaps[model]} /> <CapacityBadges caps={getCaps?.(model)} />
</code> </code>
)) ))
)} )}
@@ -340,15 +336,17 @@ function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy
</div> </div>
{/* Judge model picker (single-select; combo members make natural judges too) */} {/* Judge model picker (single-select; combo members make natural judges too) */}
<ModelSelectModal {showJudgeSelect && (
isOpen={showJudgeSelect} <ModelSelectModal
onClose={() => setShowJudgeSelect(false)} isOpen={showJudgeSelect}
onSelect={(m) => { onSetStrategy({ judgeModel: m?.value || "" }); setShowJudgeSelect(false); }} onClose={() => setShowJudgeSelect(false)}
activeProviders={activeProviders} onSelect={(m) => { onSetStrategy({ judgeModel: m?.value || "" }); setShowJudgeSelect(false); }}
title="Select Judge Model" activeProviders={activeProviders}
addedModelValues={judge ? [judge] : []} title="Select Judge Model"
closeOnSelect={true} addedModelValues={judge ? [judge] : []}
/> closeOnSelect={true}
/>
)}
</Card> </Card>
); );
} }
@@ -637,18 +635,20 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF
</Modal> </Modal>
{/* Model Select Modal */} {/* Model Select Modal */}
<ModelSelectModal {showModelSelect && (
isOpen={showModelSelect} <ModelSelectModal
onClose={() => setShowModelSelect(false)} isOpen={showModelSelect}
onSelect={handleAddModel} onClose={() => setShowModelSelect(false)}
onDeselect={handleDeselectModel} onSelect={handleAddModel}
activeProviders={activeProviders} onDeselect={handleDeselectModel}
modelAliases={modelAliases} activeProviders={activeProviders}
title="Add Model to Combo" modelAliases={modelAliases}
kindFilter={kindFilter} title="Add Model to Combo"
addedModelValues={models} kindFilter={kindFilter}
closeOnSelect={false} addedModelValues={models}
/> closeOnSelect={false}
/>
)}
</> </>
); );
} }
@@ -350,6 +350,8 @@ export function GenericExampleCard({ providerId, kind }) {
className="max-h-40 rounded-lg border border-border object-contain bg-sidebar" className="max-h-40 rounded-lg border border-border object-contain bg-sidebar"
onError={(e) => { e.currentTarget.style.display = "none"; }} onError={(e) => { e.currentTarget.style.display = "none"; }}
onLoad={(e) => { e.currentTarget.style.display = "block"; }} onLoad={(e) => { e.currentTarget.style.display = "block"; }}
loading="lazy"
decoding="async"
/> />
)} )}
</div> </div>
@@ -383,6 +385,8 @@ export function GenericExampleCard({ providerId, kind }) {
className="max-h-40 rounded-lg border border-border object-contain bg-sidebar" className="max-h-40 rounded-lg border border-border object-contain bg-sidebar"
onError={(e) => { e.currentTarget.style.display = "none"; }} onError={(e) => { e.currentTarget.style.display = "none"; }}
onLoad={(e) => { e.currentTarget.style.display = "block"; }} onLoad={(e) => { e.currentTarget.style.display = "block"; }}
loading="lazy"
decoding="async"
/> />
)} )}
</div> </div>
@@ -487,6 +491,8 @@ export function GenericExampleCard({ providerId, kind }) {
src={`data:image/png;base64,${partialImage.b64_json}`} src={`data:image/png;base64,${partialImage.b64_json}`}
alt="Partial" alt="Partial"
className="max-w-full rounded-lg border border-border mt-1.5 opacity-80" className="max-w-full rounded-lg border border-border mt-1.5 opacity-80"
loading="lazy"
decoding="async"
/> />
</div> </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)} 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" alt="Generated"
className="max-w-full rounded-lg border border-border" className="max-w-full rounded-lg border border-border"
loading="lazy"
decoding="async"
/> />
</div> </div>
)} )}
@@ -357,7 +357,7 @@ export default function ComboDetailPage() {
Download Download
</a> </a>
</div> </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> </div>
)} )}
{testResult.audioUrl && ( {testResult.audioUrl && (
@@ -393,18 +393,20 @@ export default function ComboDetailPage() {
)} )}
</Card> </Card>
<ModelSelectModal {showPicker && (
isOpen={showPicker} <ModelSelectModal
onClose={() => setShowPicker(false)} isOpen={showPicker}
onSelect={handleAddModel} onClose={() => setShowPicker(false)}
onDeselect={handleDeselectModel} onSelect={handleAddModel}
activeProviders={connections} onDeselect={handleDeselectModel}
modelAliases={modelAliases} activeProviders={connections}
title={`Add ${kindLabel} Model`} modelAliases={modelAliases}
kindFilter={combo.kind} title={`Add ${kindLabel} Model`}
addedModelValues={providers} kindFilter={combo.kind}
closeOnSelect={false} addedModelValues={providers}
/> closeOnSelect={false}
/>
)}
</div> </div>
); );
} }
@@ -116,26 +116,22 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri
const [testingModelId, setTestingModelId] = useState(null); const [testingModelId, setTestingModelId] = useState(null);
const [testError, setTestError] = useState(""); const [testError, setTestError] = useState("");
const [showAddCustomModel, setShowAddCustomModel] = useState(false); const [showAddCustomModel, setShowAddCustomModel] = useState(false);
const [connections, setConnections] = useState([]);
const providerAlias = providerAliasOverride || getProviderAlias(providerId); const providerAlias = providerAliasOverride || getProviderAlias(providerId);
const effectiveType = kindFilter || "llm"; const effectiveType = kindFilter || "llm";
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
try { try {
const [aliasRes, connRes, customRes] = await Promise.all([ const [aliasRes, customRes] = await Promise.all([
fetch("/api/models/alias"), fetch("/api/models/alias"),
fetch("/api/providers", { cache: "no-store" }),
fetch("/api/models/custom", { cache: "no-store" }), fetch("/api/models/custom", { cache: "no-store" }),
]); ]);
const aliasData = await aliasRes.json(); const aliasData = await aliasRes.json();
const connData = await connRes.json();
const customData = await customRes.json(); const customData = await customRes.json();
if (aliasRes.ok) setModelAliases(aliasData.aliases || {}); if (aliasRes.ok) setModelAliases(aliasData.aliases || {});
if (connRes.ok) setConnections((connData.connections || []).filter((c) => c.provider === providerId));
if (customRes.ok) setCustomModels(customData.models || []); if (customRes.ok) setCustomModels(customData.models || []);
} catch (e) { console.log("ModelsCard fetch error:", e); } } catch (e) { console.log("ModelsCard fetch error:", e); }
}, [providerId]); }, []);
useEffect(() => { fetchData(); }, [fetchData]); useEffect(() => { fetchData(); }, [fetchData]);
@@ -242,7 +238,7 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri
onSetAlias={(alias) => handleSetAlias(model.id, alias)} onSetAlias={(alias) => handleSetAlias(model.id, alias)}
onDeleteAlias={() => handleDeleteAlias(existingAlias)} onDeleteAlias={() => handleDeleteAlias(existingAlias)}
testStatus={modelTestResults[model.id]} testStatus={modelTestResults[model.id]}
onTest={connections.length > 0 ? () => handleTestModel(model.id) : undefined} onTest={() => handleTestModel(model.id)}
isTesting={testingModelId === model.id} isTesting={testingModelId === model.id}
isFree={model.isFree} isFree={model.isFree}
/> />
@@ -259,7 +255,7 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri
onSetAlias={() => {}} onSetAlias={() => {}}
onDeleteAlias={() => handleDeleteCustomModel(model.id)} onDeleteAlias={() => handleDeleteCustomModel(model.id)}
testStatus={modelTestResults[model.id]} testStatus={modelTestResults[model.id]}
onTest={connections.length > 0 ? () => handleTestModel(model.id) : undefined} onTest={() => handleTestModel(model.id)}
isTesting={testingModelId === model.id} isTesting={testingModelId === model.id}
isCustom isCustom
/> />
@@ -10,6 +10,7 @@ import {
Toggle, Toggle,
} from "@/shared/components"; } from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon"; import ProviderIcon from "@/shared/components/ProviderIcon";
import { getProviderIconSrc } from "@/shared/utils/providerIcon";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
import { import {
FREE_PROVIDERS, FREE_PROVIDERS,
@@ -756,12 +757,12 @@ function ApiKeyProviderCard({
}; };
const getIconPath = () => { const getIconPath = () => {
if (isCompatible) if (isCompatible && provider.apiType)
return provider.apiType === "responses" return provider.apiType === "responses"
? "/providers/oai-r.png" ? "/providers/oai-r.png"
: "/providers/oai-cc.png"; : "/providers/oai-cc.png";
if (isAnthropicCompatible) return "/providers/anthropic-m.png"; if (isAnthropicCompatible) return "/providers/anthropic-m.png";
return `/providers/${provider.id}.png`; return getProviderIconSrc(provider.id);
}; };
return ( return (
@@ -1265,22 +1265,24 @@ export default function ProviderLimits() {
/> />
)} )}
{hiddenQuotaRows.length > 0 && ( {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"> <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 text-[14px]"> <span className="material-symbols-outlined shrink-0 text-[14px]">
visibility_off visibility_off
</span> </span>
<span>Hidden:</span> <span className="shrink-0">Hidden:</span>
{hiddenQuotaRows.map((quotaRow) => ( <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto whitespace-nowrap">
<button {hiddenQuotaRows.map((quotaRow) => (
key={getQuotaVisibilityKey(quotaRow)} <button
type="button" key={getQuotaVisibilityKey(quotaRow)}
onClick={() => handleShowQuota(conn.provider, quotaRow)} type="button"
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" onClick={() => handleShowQuota(conn.provider, quotaRow)}
title="Show this quota row" 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> {quotaRow.name}
))} </button>
))}
</div>
</div> </div>
)} )}
</div> </div>
@@ -10,6 +10,7 @@ import {
} from "@xyflow/react"; } from "@xyflow/react";
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
import { AI_PROVIDERS } from "@/shared/constants/providers"; 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 // Force-stop FE animation if a provider stays active longer than this
const FE_ACTIVE_TIMEOUT_MS = 60000; const FE_ACTIVE_TIMEOUT_MS = 60000;
@@ -19,9 +20,8 @@ function getProviderConfig(providerId) {
return AI_PROVIDERS[providerId] || { color: "#6b7280", name: providerId }; return AI_PROVIDERS[providerId] || { color: "#6b7280", name: providerId };
} }
// Use local provider images from /public/providers/
function getProviderImageUrl(providerId) { function getProviderImageUrl(providerId) {
return `/providers/${providerId}.png`; return getProviderIconSrc(providerId);
} }
// Custom provider node - rectangle with image + name // 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" className="w-8 h-8 rounded-md flex items-center justify-center shrink-0"
style={{ backgroundColor: `${color}15` }} style={{ backgroundColor: `${color}15` }}
> >
{!imgError ? ( {imageUrl && !imgError ? (
<img src={imageUrl} alt={label} className="w-6 h-6 rounded-sm object-contain" onError={() => setImgError(true)} /> <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> <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.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" /> <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> <span className="text-sm font-bold text-primary">9Router</span>
{data.activeCount > 0 && ( {data.activeCount > 0 && (
<span className="ml-2 px-1.5 py-0.5 rounded-full bg-primary text-white text-xs font-bold"> <span className="ml-2 px-1.5 py-0.5 rounded-full bg-primary text-white text-xs font-bold">
+48 -2
View File
@@ -6,15 +6,52 @@ import { promisify } from "util";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import os from "os"; import os from "os";
import { DEFAULT_PLUGINS } from "@/shared/constants/coworkPlugins";
const execAsync = promisify(exec); 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 // Get claude settings path based on OS
const getClaudeSettingsPath = () => { const getClaudeSettingsPath = () => {
const homeDir = os.homedir(); const homeDir = os.homedir();
return path.join(homeDir, ".claude", "settings.json"); 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) // Check if claude CLI is installed (via which/where or config file exists)
const checkClaudeInstalled = async () => { const checkClaudeInstalled = async () => {
@@ -65,11 +102,13 @@ export async function GET() {
const settings = await readSettings(); const settings = await readSettings();
const has9Router = !!(settings?.env?.ANTHROPIC_BASE_URL); const has9Router = !!(settings?.env?.ANTHROPIC_BASE_URL);
const claudeJson = await readClaudeJson();
return NextResponse.json({ return NextResponse.json({
installed: true, installed: true,
settings: settings, settings: settings,
has9Router: has9Router, has9Router: has9Router,
exaMcpEnabled: !!claudeJson?.mcpServers?.exa,
settingsPath: getClaudeSettingsPath(), settingsPath: getClaudeSettingsPath(),
}); });
} catch (error) { } catch (error) {
@@ -84,7 +123,7 @@ export async function GET() {
// POST - Backup old fields and write new settings // POST - Backup old fields and write new settings
export async function POST(request) { export async function POST(request) {
try { try {
const { env } = await request.json(); const { env, exaMcpEnabled } = await request.json();
if (!env || typeof env !== "object") { if (!env || typeof env !== "object") {
return NextResponse.json( return NextResponse.json(
@@ -130,6 +169,11 @@ export async function POST(request) {
// Write new settings // Write new settings
await fs.writeFile(settingsPath, JSON.stringify(newSettings, null, 2)); 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({ return NextResponse.json({
success: true, success: true,
message: "Settings updated successfully", 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 // Write updated settings
await fs.writeFile(settingsPath, JSON.stringify(currentSettings, null, 2)); await fs.writeFile(settingsPath, JSON.stringify(currentSettings, null, 2));
@@ -200,4 +247,3 @@ export async function DELETE() {
); );
} }
} }
+7 -5
View File
@@ -166,11 +166,13 @@ export default function ComboFormModal({ isOpen, combo, onClose, onSave, activeP
</div> </div>
</Modal> </Modal>
<ModelSelectModal isOpen={showModelSelect} onClose={() => setShowModelSelect(false)} {showModelSelect && (
onSelect={handleAddModel} onDeselect={handleDeselectModel} <ModelSelectModal isOpen={showModelSelect} onClose={() => setShowModelSelect(false)}
activeProviders={activeProviders} modelAliases={modelAliases} onSelect={handleAddModel} onDeselect={handleDeselectModel}
title="Add Model to Combo" kindFilter={kindFilter} activeProviders={activeProviders} modelAliases={modelAliases}
addedModelValues={models} closeOnSelect={false} /> title="Add Model to Combo" kindFilter={kindFilter}
addedModelValues={models} closeOnSelect={false} />
)}
</> </>
); );
} }
+2
View File
@@ -106,6 +106,8 @@ function DonateChannelCard({ channel }) {
src={qr} src={qr}
alt={`${label} QR`} alt={`${label} QR`}
className="w-full max-w-[180px] aspect-square object-contain rounded-lg bg-white p-1" className="w-full max-w-[180px] aspect-square object-contain rounded-lg bg-white p-1"
loading="lazy"
decoding="async"
/> />
)} )}
</> </>
+3 -2
View File
@@ -12,6 +12,7 @@ import DonateModal from "@/shared/components/DonateModal";
import { useHeaderSearchStore } from "@/store/headerSearchStore"; import { useHeaderSearchStore } from "@/store/headerSearchStore";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS } from "@/shared/constants/providers"; import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS } from "@/shared/constants/providers";
import { getProviderIconSrc } from "@/shared/utils/providerIcon";
import { translate } from "@/i18n/runtime"; import { translate } from "@/i18n/runtime";
const getPageInfo = (pathname) => { const getPageInfo = (pathname) => {
@@ -30,7 +31,7 @@ const getPageInfo = (pathname) => {
breadcrumbs: [ breadcrumbs: [
{ label: "Media Providers", href: `/dashboard/media-providers/${kindId}` }, { label: "Media Providers", href: `/dashboard/media-providers/${kindId}` },
{ label: kindConfig?.label || kindId, 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: "Providers", href: "/dashboard/providers" },
{ {
label: providerInfo.name, label: providerInfo.name,
image: `/providers/${providerInfo.id}.png`, image: getProviderIconSrc(providerInfo.id),
}, },
], ],
}; };
+1 -1
View File
@@ -154,7 +154,7 @@ export default function McpMarketplaceModal({ isOpen, onClose, onAdd, addedNames
<div className="flex items-start gap-2 px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5"> <div className="flex items-start gap-2 px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5">
{s.iconUrl ? ( {s.iconUrl ? (
// eslint-disable-next-line @next/next/no-img-element // 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" /> <div className="size-7 rounded bg-surface shrink-0" />
)} )}
+22 -3
View File
@@ -2,18 +2,29 @@
import { useState } from "react"; import { useState } from "react";
import PropTypes from "prop-types"; 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({ export default function ProviderIcon({
src, src,
providerId,
alt, alt,
size = 32, size = 32,
className = "", className = "",
fallbackText = "?", fallbackText = "?",
fallbackColor, fallbackColor,
}) { }) {
const effectiveSrc = resolveSrc(src, providerId);
const [errored, setErrored] = useState(false); const [errored, setErrored] = useState(false);
if (!src || errored) { if (!effectiveSrc || errored) {
return ( return (
<span <span
className={`inline-flex items-center justify-center font-bold rounded-lg ${className}`.trim()} className={`inline-flex items-center justify-center font-bold rounded-lg ${className}`.trim()}
@@ -31,18 +42,26 @@ export default function ProviderIcon({
return ( return (
<img <img
src={src} src={effectiveSrc}
alt={alt} alt={alt}
width={size} width={size}
height={size} height={size}
className={className} 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 = { ProviderIcon.propTypes = {
src: PropTypes.string, src: PropTypes.string,
providerId: PropTypes.string,
alt: PropTypes.string, alt: PropTypes.string,
size: PropTypes.number, size: PropTypes.number,
className: PropTypes.string, className: PropTypes.string,
+59 -30
View File
@@ -1,44 +1,73 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js"; 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() { export function useModelCaps() {
const [byFull, setByFull] = useState({}); const [byFull, setByFull] = useState(() => cache?.byFull || {});
const [byId, setById] = useState({}); const [byId, setById] = useState(() => cache?.byId || {});
useEffect(() => { useEffect(() => {
if (cache) {
setByFull(cache.byFull);
setById(cache.byId);
return;
}
let alive = true; let alive = true;
(async () => { loadModelCaps().then((maps) => {
try { if (alive) { setByFull(maps.byFull); setById(maps.byId); }
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 */ }
})();
return () => { alive = false; }; return () => { alive = false; };
}, []); }, []);
// Resolve caps from a "provider/model" string or a bare model id. const getCaps = useCallback(
const getCaps = (key) => { (key) => resolveCaps(byFull, byId, key),
if (!key) return null; [byFull, byId],
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 };
};
return { getCaps }; return { getCaps };
} }
+1
View File
@@ -1,6 +1,7 @@
// Shared Utils - Export all // Shared Utils - Export all
export { cn } from "./cn"; export { cn } from "./cn";
export * as api from "./api"; export * as api from "./api";
export { getProviderIconSrc, markProviderIconMissing, resolveProviderIconId } from "./providerIcon";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
+40
View File
@@ -0,0 +1,40 @@
// Provider icon paths under /public/providers.
// Alias related brands; session-cache 404s so one miss never spams again.
const ICON_ALIASES = {
"perplexity-agent": "perplexity",
"gitlab-duo": "gitlab",
"vercel-ai-gateway": "vercel",
};
// Runtime only — first 404 remembers id for the whole session
const failedIds = new Set();
function normalizeId(providerId) {
if (!providerId || typeof providerId !== "string") return "";
return providerId.trim().toLowerCase();
}
/** Resolve icon file id (after alias). Empty if previously failed this session. */
export function resolveProviderIconId(providerId) {
const id = normalizeId(providerId);
if (!id) return "";
if (failedIds.has(id)) return "";
const aliased = ICON_ALIASES[id] || id;
if (failedIds.has(aliased)) return "";
return aliased;
}
/** `/providers/{id}.png` or null when previously failed. */
export function getProviderIconSrc(providerId) {
const id = resolveProviderIconId(providerId);
return id ? `/providers/${id}.png` : null;
}
/** Call from img onError so later mounts skip the request. */
export function markProviderIconMissing(providerId) {
const id = normalizeId(providerId);
if (id) failedIds.add(id);
const aliased = ICON_ALIASES[id];
if (aliased) failedIds.add(aliased);
}