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>
BIN
public/providers/agentrouter.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 9.1 KiB |
BIN
public/providers/featherless.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/providers/gitlab.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/providers/longcat.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
public/providers/mmf.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
public/providers/morph.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/providers/novita.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/providers/perplexity-agent.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 5.9 KiB |
BIN
public/providers/reka.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/providers/venice.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
public/providers/vercel-ai-gateway.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/providers/vercel.png
Normal file
|
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">
|
||||
{message.attachments.map((attachment) => (
|
||||
<a key={attachment.id} href={attachment.dataUrl} target="_blank" rel="noreferrer" className="overflow-hidden rounded-[18px] border border-white/10 bg-black/20">
|
||||
<img src={attachment.dataUrl} alt={attachment.name} className="h-28 w-full object-cover" />
|
||||
<img src={attachment.dataUrl} alt={attachment.name} className="h-28 w-full object-cover" loading="lazy" decoding="async" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -38,15 +38,10 @@ export default function AntigravityToolCard({
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !status) {
|
||||
fetchStatus();
|
||||
loadSavedMappings();
|
||||
fetchModelAliases();
|
||||
}
|
||||
if (isExpanded) {
|
||||
loadSavedMappings();
|
||||
fetchModelAliases();
|
||||
}
|
||||
if (!isExpanded) return;
|
||||
if (!status) fetchStatus();
|
||||
loadSavedMappings();
|
||||
fetchModelAliases();
|
||||
}, [isExpanded]);
|
||||
|
||||
const loadSavedMappings = async () => {
|
||||
@@ -243,6 +238,8 @@ export default function AntigravityToolCard({
|
||||
className="size-8 object-contain rounded-lg"
|
||||
sizes="32px"
|
||||
onError={(e) => { e.target.style.display = "none"; }}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
@@ -467,15 +464,17 @@ export default function AntigravityToolCard({
|
||||
</Modal>
|
||||
|
||||
{/* Model Select Modal */}
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title={`Select model for ${currentEditingAlias}`}
|
||||
/>
|
||||
{modalOpen && (
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title={`Select model for ${currentEditingAlias}`}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export default function ClaudeToolCard({
|
||||
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
||||
const [ccFilterNaming, setCcFilterNaming] = useState(false);
|
||||
const [exaMcpEnabled, setExaMcpEnabled] = useState(false);
|
||||
const hasInitializedModels = useRef(false);
|
||||
|
||||
const getConfigStatus = () => {
|
||||
@@ -58,15 +59,17 @@ export default function ClaudeToolCard({
|
||||
}, [apiKeys, selectedApiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialStatus) setClaudeStatus(initialStatus);
|
||||
if (initialStatus) {
|
||||
setClaudeStatus(initialStatus);
|
||||
setExaMcpEnabled(!!initialStatus.exaMcpEnabled);
|
||||
}
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !claudeStatus) {
|
||||
checkClaudeStatus();
|
||||
if (isExpanded) {
|
||||
if (!claudeStatus) checkClaudeStatus();
|
||||
fetchModelAliases();
|
||||
}
|
||||
if (isExpanded) fetchModelAliases();
|
||||
}, [isExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -123,6 +126,7 @@ export default function ClaudeToolCard({
|
||||
const res = await fetch("/api/cli-tools/claude-settings");
|
||||
const data = await res.json();
|
||||
setClaudeStatus(data);
|
||||
setExaMcpEnabled(!!data.exaMcpEnabled);
|
||||
} catch (error) {
|
||||
setClaudeStatus({ installed: false, error: error.message });
|
||||
} finally {
|
||||
@@ -162,12 +166,12 @@ export default function ClaudeToolCard({
|
||||
const res = await fetch("/api/cli-tools/claude-settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ env }),
|
||||
body: JSON.stringify({ env, exaMcpEnabled }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: "Settings applied successfully!" });
|
||||
setClaudeStatus(prev => ({ ...prev, hasBackup: true, settings: { ...prev?.settings, env } }));
|
||||
setClaudeStatus(prev => ({ ...prev, hasBackup: true, settings: { ...prev?.settings, env }, exaMcpEnabled }));
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
|
||||
}
|
||||
@@ -188,6 +192,7 @@ export default function ClaudeToolCard({
|
||||
setMessage({ type: "success", text: "Settings reset successfully!" });
|
||||
tool.defaultModels.forEach((model) => onModelMappingChange(model.alias, model.defaultValue || ""));
|
||||
setSelectedApiKey("");
|
||||
setExaMcpEnabled(false);
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
|
||||
}
|
||||
@@ -231,7 +236,7 @@ export default function ClaudeToolCard({
|
||||
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="size-8 flex items-center justify-center shrink-0">
|
||||
<Image src="/providers/claude.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
|
||||
<Image src="/providers/claude.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
@@ -352,6 +357,19 @@ export default function ClaudeToolCard({
|
||||
</Tooltip>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Exa MCP — ~/.claude.json mcpServers (not settings.json) */}
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Web Search</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<input type="checkbox" checked={exaMcpEnabled} onChange={(e) => setExaMcpEnabled(e.target.checked)} className="w-3.5 h-3.5 accent-primary cursor-pointer" />
|
||||
<span className="text-xs text-text-muted">Exa MCP</span>
|
||||
<Tooltip text="Injects Exa MCP into ~/.claude.json so non-Claude models gain web search. Restart Claude Code after Apply.">
|
||||
<span className="material-symbols-outlined text-text-muted text-[14px] cursor-help">info</span>
|
||||
</Tooltip>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
@@ -377,7 +395,9 @@ export default function ClaudeToolCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelSelectModal isOpen={modalOpen} onClose={() => setModalOpen(false)} onSelect={handleModelSelect} selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} activeProviders={activeProviders} modelAliases={modelAliases} title={`Select model for ${currentEditingAlias}`} />
|
||||
{modalOpen && (
|
||||
<ModelSelectModal isOpen={modalOpen} onClose={() => setModalOpen(false)} onSelect={handleModelSelect} selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} activeProviders={activeProviders} modelAliases={modelAliases} title={`Select model for ${currentEditingAlias}`} />
|
||||
)}
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
|
||||
@@ -30,11 +30,10 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !status) {
|
||||
checkStatus();
|
||||
if (isExpanded) {
|
||||
if (!status) checkStatus();
|
||||
fetchModelAliases();
|
||||
}
|
||||
if (isExpanded) fetchModelAliases();
|
||||
}, [isExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -157,7 +156,7 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api
|
||||
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="size-8 flex items-center justify-center shrink-0">
|
||||
<Image src="/providers/cline.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
|
||||
<Image src="/providers/cline.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
@@ -280,15 +279,17 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for Cline"
|
||||
/>
|
||||
{modalOpen && (
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for Cline"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
|
||||
@@ -34,11 +34,10 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !codexStatus) {
|
||||
checkCodexStatus();
|
||||
if (isExpanded) {
|
||||
if (!codexStatus) checkCodexStatus();
|
||||
fetchModelAliases();
|
||||
}
|
||||
if (isExpanded) fetchModelAliases();
|
||||
}, [isExpanded]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
@@ -199,7 +198,7 @@ model = "${effectiveSubagentModel}"
|
||||
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="size-8 flex items-center justify-center shrink-0">
|
||||
<Image src="/providers/codex.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
|
||||
<Image src="/providers/codex.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
@@ -371,25 +370,29 @@ model = "${effectiveSubagentModel}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for Codex"
|
||||
/>
|
||||
{modalOpen && (
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for Codex"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={subagentModalOpen}
|
||||
onClose={() => setSubagentModalOpen(false)}
|
||||
onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }}
|
||||
selectedModel={subagentModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Subagent Model for Codex"
|
||||
/>
|
||||
{subagentModalOpen && (
|
||||
<ModelSelectModal
|
||||
isOpen={subagentModalOpen}
|
||||
onClose={() => setSubagentModalOpen(false)}
|
||||
onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }}
|
||||
selectedModel={subagentModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Subagent Model for Codex"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
|
||||
@@ -36,11 +36,10 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !status) {
|
||||
checkStatus();
|
||||
if (isExpanded) {
|
||||
if (!status) checkStatus();
|
||||
fetchModelAliases();
|
||||
}
|
||||
if (isExpanded) fetchModelAliases();
|
||||
}, [isExpanded]);
|
||||
|
||||
// Pre-fill from existing config
|
||||
@@ -184,7 +183,7 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
|
||||
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="size-8 flex items-center justify-center shrink-0">
|
||||
<Image src="/providers/copilot.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
|
||||
<Image src="/providers/copilot.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
@@ -290,27 +289,29 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => {
|
||||
setModalOpen(false);
|
||||
saveModels(selectedModelsRef.current);
|
||||
}}
|
||||
onSelect={(model) => {
|
||||
if (!selectedModels.includes(model.value)) {
|
||||
setSelectedModels([...selectedModels, model.value]);
|
||||
}
|
||||
}}
|
||||
onDeselect={(model) => {
|
||||
setSelectedModels(selectedModels.filter(m => m !== model.value));
|
||||
}}
|
||||
selectedModel={null}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
addedModelValues={selectedModels}
|
||||
closeOnSelect={false}
|
||||
title="Add Model for GitHub Copilot"
|
||||
/>
|
||||
{modalOpen && (
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => {
|
||||
setModalOpen(false);
|
||||
saveModels(selectedModelsRef.current);
|
||||
}}
|
||||
onSelect={(model) => {
|
||||
if (!selectedModels.includes(model.value)) {
|
||||
setSelectedModels([...selectedModels, model.value]);
|
||||
}
|
||||
}}
|
||||
onDeselect={(model) => {
|
||||
setSelectedModels(selectedModels.filter(m => m !== model.value));
|
||||
}}
|
||||
selectedModel={null}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
addedModelValues={selectedModels}
|
||||
closeOnSelect={false}
|
||||
title="Add Model for GitHub Copilot"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
|
||||
@@ -249,7 +249,7 @@ export default function CoworkToolCard({
|
||||
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="size-8 flex items-center justify-center shrink-0">
|
||||
<Image src={tool.image} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
|
||||
<Image src={tool.image} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
@@ -514,27 +514,31 @@ export default function CoworkToolCard({
|
||||
configs={getManualConfigs()}
|
||||
/>
|
||||
|
||||
<ComboFormModal
|
||||
isOpen={comboModalOpen}
|
||||
combo={null}
|
||||
onClose={() => setComboModalOpen(false)}
|
||||
onSave={handleCreateCombo}
|
||||
activeProviders={activeProviders}
|
||||
forcePrefix="claude-"
|
||||
title="Create Cowork Combo"
|
||||
/>
|
||||
{comboModalOpen && (
|
||||
<ComboFormModal
|
||||
isOpen={comboModalOpen}
|
||||
combo={null}
|
||||
onClose={() => setComboModalOpen(false)}
|
||||
onSave={handleCreateCombo}
|
||||
activeProviders={activeProviders}
|
||||
forcePrefix="claude-"
|
||||
title="Create Cowork Combo"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={modelSelectOpen}
|
||||
onClose={() => setModelSelectOpen(false)}
|
||||
onSelect={handleAddModel}
|
||||
onDeselect={handleRemoveModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Cowork Model"
|
||||
addedModelValues={selectedModels}
|
||||
closeOnSelect={false}
|
||||
/>
|
||||
{modelSelectOpen && (
|
||||
<ModelSelectModal
|
||||
isOpen={modelSelectOpen}
|
||||
onClose={() => setModelSelectOpen(false)}
|
||||
onSelect={handleAddModel}
|
||||
onDeselect={handleRemoveModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Cowork Model"
|
||||
addedModelValues={selectedModels}
|
||||
closeOnSelect={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<McpMarketplaceModal
|
||||
isOpen={marketplaceOpen}
|
||||
|
||||
@@ -58,11 +58,10 @@ export default function DeepSeekTuiToolCard({
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !deepseekStatus) {
|
||||
checkStatus();
|
||||
if (isExpanded) {
|
||||
if (!deepseekStatus) checkStatus();
|
||||
fetchModelAliases();
|
||||
}
|
||||
if (isExpanded) fetchModelAliases();
|
||||
}, [isExpanded]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
@@ -187,7 +186,7 @@ model = "${selectedModel || "provider/model-id"}"
|
||||
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="size-8 flex items-center justify-center shrink-0">
|
||||
<Image src={tool.image || "/providers/deepseek-tui.png"} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
|
||||
<Image src={tool.image || "/providers/deepseek-tui.png"} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
@@ -317,15 +316,17 @@ model = "${selectedModel || "provider/model-id"}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for DeepSeek TUI"
|
||||
/>
|
||||
{modalOpen && (
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for DeepSeek TUI"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card, ModelSelectModal } from "@/shared/components";
|
||||
import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/providerIcon";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import Image from "next/image";
|
||||
import ApiKeySelect from "./ApiKeySelect";
|
||||
@@ -217,21 +218,32 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba
|
||||
className="size-8 object-contain rounded-lg"
|
||||
sizes="32px"
|
||||
onError={(e) => { e.target.style.display = "none"; }}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (tool.icon) {
|
||||
return <span className="material-symbols-outlined text-xl" style={{ color: tool.color }}>{tool.icon}</span>;
|
||||
}
|
||||
const iconSrc = getProviderIconSrc(toolId);
|
||||
if (!iconSrc) {
|
||||
return <span className="text-xs font-bold" style={{ color: tool.color }}>{(toolId || "?").slice(0, 2).toUpperCase()}</span>;
|
||||
}
|
||||
return (
|
||||
<Image
|
||||
src={`/providers/${toolId}.png`}
|
||||
src={iconSrc}
|
||||
alt={tool.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="size-8 object-contain rounded-lg"
|
||||
sizes="32px"
|
||||
onError={(e) => { e.target.style.display = "none"; }}
|
||||
onError={(e) => {
|
||||
markProviderIconMissing(toolId);
|
||||
e.target.style.display = "none";
|
||||
}}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -257,14 +269,16 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={showModelModal}
|
||||
onClose={() => setShowModelModal(false)}
|
||||
onSelect={handleSelectModel}
|
||||
selectedModel={modelValue}
|
||||
activeProviders={activeProviders}
|
||||
title="Select Model"
|
||||
/>
|
||||
{showModelModal && (
|
||||
<ModelSelectModal
|
||||
isOpen={showModelModal}
|
||||
onClose={() => setShowModelModal(false)}
|
||||
onSelect={handleSelectModel}
|
||||
selectedModel={modelValue}
|
||||
activeProviders={activeProviders}
|
||||
title="Select Model"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,11 +60,10 @@ export default function DroidToolCard({
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !droidStatus) {
|
||||
checkDroidStatus();
|
||||
if (isExpanded) {
|
||||
if (!droidStatus) checkDroidStatus();
|
||||
fetchModelAliases();
|
||||
}
|
||||
if (isExpanded) fetchModelAliases();
|
||||
}, [isExpanded]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
@@ -225,7 +224,7 @@ export default function DroidToolCard({
|
||||
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="size-8 flex items-center justify-center shrink-0">
|
||||
<Image src="/providers/droid.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
|
||||
<Image src="/providers/droid.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
@@ -389,15 +388,17 @@ export default function DroidToolCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={null}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for Factory Droid"
|
||||
/>
|
||||
{modalOpen && (
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={null}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for Factory Droid"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
|
||||
@@ -59,11 +59,10 @@ export default function GrokBuildToolCard({
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !grokStatus) {
|
||||
checkStatus();
|
||||
if (isExpanded) {
|
||||
if (!grokStatus) checkStatus();
|
||||
fetchModelAliases();
|
||||
}
|
||||
if (isExpanded) fetchModelAliases();
|
||||
}, [isExpanded]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
@@ -203,6 +202,8 @@ api_key = "${keyToUse}"
|
||||
className="size-8 object-contain rounded-lg"
|
||||
sizes="32px"
|
||||
onError={(e) => { e.target.style.display = "none"; }}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
@@ -366,15 +367,17 @@ api_key = "${keyToUse}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for Grok Build"
|
||||
/>
|
||||
{modalOpen && (
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for Grok Build"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
|
||||
@@ -58,11 +58,10 @@ export default function HermesToolCard({
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !hermesStatus) {
|
||||
checkStatus();
|
||||
if (isExpanded) {
|
||||
if (!hermesStatus) checkStatus();
|
||||
fetchModelAliases();
|
||||
}
|
||||
if (isExpanded) fetchModelAliases();
|
||||
}, [isExpanded]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
@@ -185,7 +184,7 @@ export default function HermesToolCard({
|
||||
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="size-8 flex items-center justify-center shrink-0">
|
||||
<Image src="/providers/hermes.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
|
||||
<Image src="/providers/hermes.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
@@ -296,15 +295,17 @@ export default function HermesToolCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for Hermes Agent"
|
||||
/>
|
||||
{modalOpen && (
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for Hermes Agent"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
|
||||
@@ -56,11 +56,10 @@ export default function JcodeToolCard({
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !jcodeStatus) {
|
||||
checkJcodeStatus();
|
||||
if (isExpanded) {
|
||||
if (!jcodeStatus) checkJcodeStatus();
|
||||
fetchModelAliases();
|
||||
}
|
||||
if (isExpanded) fetchModelAliases();
|
||||
}, [isExpanded]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
@@ -215,7 +214,7 @@ id = "${selectedModel || "cc/claude-opus-4-7"}"`;
|
||||
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="size-8 flex items-center justify-center shrink-0">
|
||||
<Image src={tool.image || "/providers/jcode.png"} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
|
||||
<Image src={tool.image || "/providers/jcode.png"} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
@@ -359,15 +358,17 @@ id = "${selectedModel || "cc/claude-opus-4-7"}"`;
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for jcode"
|
||||
/>
|
||||
{modalOpen && (
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for jcode"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
|
||||
@@ -30,11 +30,10 @@ export default function KiloToolCard({ tool, isExpanded, onToggle, baseUrl, apiK
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !status) {
|
||||
checkStatus();
|
||||
if (isExpanded) {
|
||||
if (!status) checkStatus();
|
||||
fetchModelAliases();
|
||||
}
|
||||
if (isExpanded) fetchModelAliases();
|
||||
}, [isExpanded]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
@@ -144,7 +143,7 @@ export default function KiloToolCard({ tool, isExpanded, onToggle, baseUrl, apiK
|
||||
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="size-8 flex items-center justify-center shrink-0">
|
||||
<Image src="/providers/kilocode.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
|
||||
<Image src="/providers/kilocode.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
@@ -254,15 +253,17 @@ export default function KiloToolCard({ tool, isExpanded, onToggle, baseUrl, apiK
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for Kilo Code"
|
||||
/>
|
||||
{modalOpen && (
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for Kilo Code"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
|
||||
@@ -22,6 +22,8 @@ export default function MitmLinkCard({ tool }) {
|
||||
className="size-8 object-contain rounded-lg"
|
||||
sizes="32px"
|
||||
onError={(e) => { e.target.style.display = "none"; }}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -143,6 +143,8 @@ export default function MitmToolCard({
|
||||
className="size-8 object-contain rounded-lg"
|
||||
sizes="32px"
|
||||
onError={(e) => { e.target.style.display = "none"; }}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
@@ -304,15 +306,17 @@ export default function MitmToolCard({
|
||||
)}
|
||||
|
||||
{/* Model Select Modal */}
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title={`Select model for ${currentEditingAlias}`}
|
||||
/>
|
||||
{modalOpen && (
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title={`Select model for ${currentEditingAlias}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,11 +57,10 @@ export default function OpenClawToolCard({
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !openclawStatus) {
|
||||
checkOpenclawStatus();
|
||||
if (isExpanded) {
|
||||
if (!openclawStatus) checkOpenclawStatus();
|
||||
fetchModelAliases();
|
||||
}
|
||||
if (isExpanded) fetchModelAliases();
|
||||
}, [isExpanded]);
|
||||
|
||||
const fetchModelAliases = async () => {
|
||||
@@ -233,7 +232,7 @@ export default function OpenClawToolCard({
|
||||
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="size-8 flex items-center justify-center shrink-0">
|
||||
<Image src="/providers/openclaw.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
|
||||
<Image src="/providers/openclaw.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
@@ -367,15 +366,17 @@ export default function OpenClawToolCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for Open Claw"
|
||||
/>
|
||||
{modalOpen && (
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={selectedModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Model for Open Claw"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
|
||||
@@ -41,11 +41,10 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
|
||||
}, [initialStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !status) {
|
||||
checkStatus();
|
||||
if (isExpanded) {
|
||||
if (!status) checkStatus();
|
||||
fetchModelAliases();
|
||||
}
|
||||
if (isExpanded) fetchModelAliases();
|
||||
}, [isExpanded]);
|
||||
|
||||
// Sync models from existing config
|
||||
@@ -222,7 +221,7 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
|
||||
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="size-8 flex items-center justify-center shrink-0">
|
||||
<Image src="/providers/opencode.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
|
||||
<Image src="/providers/opencode.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
@@ -452,42 +451,46 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => {
|
||||
setModalOpen(false);
|
||||
saveModels(selectedModelsRef.current);
|
||||
}}
|
||||
onSelect={(model) => {
|
||||
if (!selectedModels.includes(model.value)) {
|
||||
setSelectedModels([...selectedModels, model.value]);
|
||||
if (!activeModel) setActiveModel(model.value);
|
||||
}
|
||||
}}
|
||||
onDeselect={(model) => {
|
||||
const remaining = selectedModels.filter(m => m !== model.value);
|
||||
setSelectedModels(remaining);
|
||||
if (activeModel === model.value) {
|
||||
setActiveModel(remaining[0] || "");
|
||||
}
|
||||
}}
|
||||
selectedModel={null}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
addedModelValues={selectedModels}
|
||||
closeOnSelect={false}
|
||||
title="Add Model for OpenCode"
|
||||
/>
|
||||
{modalOpen && (
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => {
|
||||
setModalOpen(false);
|
||||
saveModels(selectedModelsRef.current);
|
||||
}}
|
||||
onSelect={(model) => {
|
||||
if (!selectedModels.includes(model.value)) {
|
||||
setSelectedModels([...selectedModels, model.value]);
|
||||
if (!activeModel) setActiveModel(model.value);
|
||||
}
|
||||
}}
|
||||
onDeselect={(model) => {
|
||||
const remaining = selectedModels.filter(m => m !== model.value);
|
||||
setSelectedModels(remaining);
|
||||
if (activeModel === model.value) {
|
||||
setActiveModel(remaining[0] || "");
|
||||
}
|
||||
}}
|
||||
selectedModel={null}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
addedModelValues={selectedModels}
|
||||
closeOnSelect={false}
|
||||
title="Add Model for OpenCode"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={subagentModalOpen}
|
||||
onClose={() => setSubagentModalOpen(false)}
|
||||
onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }}
|
||||
selectedModel={subagentModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Subagent Model for OpenCode"
|
||||
/>
|
||||
{subagentModalOpen && (
|
||||
<ModelSelectModal
|
||||
isOpen={subagentModalOpen}
|
||||
onClose={() => setSubagentModalOpen(false)}
|
||||
onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }}
|
||||
selectedModel={subagentModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Select Subagent Model for OpenCode"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function ToolSummaryCard({ toolId, tool, status }) {
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-8 flex items-center justify-center shrink-0">
|
||||
{tool.image ? (
|
||||
<Image src={tool.image} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
|
||||
<Image src={tool.image} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
|
||||
) : tool.icon ? (
|
||||
<span className="material-symbols-outlined text-[28px]" style={{ color: tool.color }}>{tool.icon}</span>
|
||||
) : null}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers";
|
||||
import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, ConfirmModal, CapacityBadges, Select } from "@/shared/components";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { useModelCaps } from "@/shared/hooks/useModelCaps";
|
||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||
|
||||
// Validate combo name: only a-z, A-Z, 0-9, -, _
|
||||
@@ -19,7 +20,7 @@ export default function CombosPage() {
|
||||
const [editingCombo, setEditingCombo] = useState(null);
|
||||
const [activeProviders, setActiveProviders] = useState([]);
|
||||
const [comboStrategies, setComboStrategies] = useState({});
|
||||
const [modelCaps, setModelCaps] = useState({});
|
||||
const { getCaps } = useModelCaps();
|
||||
const [confirmState, setConfirmState] = useState(null);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
@@ -29,11 +30,10 @@ export default function CombosPage() {
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [combosRes, providersRes, settingsRes, modelsRes] = await Promise.all([
|
||||
const [combosRes, providersRes, settingsRes] = await Promise.all([
|
||||
fetch("/api/combos"),
|
||||
fetch("/api/providers"),
|
||||
fetch("/api/settings"),
|
||||
fetch("/api/models"),
|
||||
]);
|
||||
const combosData = await combosRes.json();
|
||||
const providersData = await providersRes.json();
|
||||
@@ -44,13 +44,6 @@ export default function CombosPage() {
|
||||
if (providersRes.ok) {
|
||||
setActiveProviders(providersData.connections || []);
|
||||
}
|
||||
if (modelsRes.ok) {
|
||||
const md = await modelsRes.json();
|
||||
// Build fullModel -> caps map for badge lookup
|
||||
const map = {};
|
||||
for (const m of md.models || []) if (m.caps) map[m.fullModel] = m.caps;
|
||||
setModelCaps(map);
|
||||
}
|
||||
setComboStrategies(settingsData.comboStrategies || {});
|
||||
} catch (error) {
|
||||
console.log("Error fetching data:", error);
|
||||
@@ -189,7 +182,7 @@ export default function CombosPage() {
|
||||
<ComboCard
|
||||
key={combo.id}
|
||||
combo={combo}
|
||||
modelCaps={modelCaps}
|
||||
getCaps={getCaps}
|
||||
activeProviders={activeProviders}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
@@ -203,23 +196,26 @@ export default function CombosPage() {
|
||||
)}
|
||||
|
||||
{/* Create Modal - Use key to force remount and reset state */}
|
||||
<ComboFormModal
|
||||
key="create"
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSave={handleCreate}
|
||||
activeProviders={activeProviders}
|
||||
/>
|
||||
{showCreateModal && (
|
||||
<ComboFormModal
|
||||
key="create"
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSave={handleCreate}
|
||||
activeProviders={activeProviders}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Modal - Use key to force remount and reset state */}
|
||||
<ComboFormModal
|
||||
key={editingCombo?.id || "new"}
|
||||
isOpen={!!editingCombo}
|
||||
combo={editingCombo}
|
||||
onClose={() => setEditingCombo(null)}
|
||||
onSave={(data) => handleUpdate(editingCombo.id, data)}
|
||||
activeProviders={activeProviders}
|
||||
/>
|
||||
{editingCombo && (
|
||||
<ComboFormModal
|
||||
key={editingCombo.id}
|
||||
isOpen={!!editingCombo}
|
||||
combo={editingCombo}
|
||||
onClose={() => setEditingCombo(null)}
|
||||
onSave={(data) => handleUpdate(editingCombo.id, data)}
|
||||
activeProviders={activeProviders}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirm Delete Modal */}
|
||||
<ConfirmModal
|
||||
@@ -240,7 +236,7 @@ const STRATEGY_OPTIONS = [
|
||||
{ value: "fusion", label: "Fusion — panel + judge" },
|
||||
];
|
||||
|
||||
function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, onSetStrategy }) {
|
||||
function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, onSetStrategy }) {
|
||||
const [showJudgeSelect, setShowJudgeSelect] = useState(false);
|
||||
const current = strategy.fallbackStrategy || "fallback";
|
||||
const judge = strategy.judgeModel || "";
|
||||
@@ -262,7 +258,7 @@ function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy
|
||||
combo.models.slice(0, 3).map((model, index) => (
|
||||
<code key={index} className="inline-flex items-center gap-1 rounded bg-black/5 px-1.5 py-0.5 font-mono text-xs text-text-muted dark:bg-white/5">
|
||||
<span>{model}</span>
|
||||
<CapacityBadges caps={modelCaps[model]} />
|
||||
<CapacityBadges caps={getCaps?.(model)} />
|
||||
</code>
|
||||
))
|
||||
)}
|
||||
@@ -340,15 +336,17 @@ function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy
|
||||
</div>
|
||||
|
||||
{/* Judge model picker (single-select; combo members make natural judges too) */}
|
||||
<ModelSelectModal
|
||||
isOpen={showJudgeSelect}
|
||||
onClose={() => setShowJudgeSelect(false)}
|
||||
onSelect={(m) => { onSetStrategy({ judgeModel: m?.value || "" }); setShowJudgeSelect(false); }}
|
||||
activeProviders={activeProviders}
|
||||
title="Select Judge Model"
|
||||
addedModelValues={judge ? [judge] : []}
|
||||
closeOnSelect={true}
|
||||
/>
|
||||
{showJudgeSelect && (
|
||||
<ModelSelectModal
|
||||
isOpen={showJudgeSelect}
|
||||
onClose={() => setShowJudgeSelect(false)}
|
||||
onSelect={(m) => { onSetStrategy({ judgeModel: m?.value || "" }); setShowJudgeSelect(false); }}
|
||||
activeProviders={activeProviders}
|
||||
title="Select Judge Model"
|
||||
addedModelValues={judge ? [judge] : []}
|
||||
closeOnSelect={true}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -637,18 +635,20 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF
|
||||
</Modal>
|
||||
|
||||
{/* Model Select Modal */}
|
||||
<ModelSelectModal
|
||||
isOpen={showModelSelect}
|
||||
onClose={() => setShowModelSelect(false)}
|
||||
onSelect={handleAddModel}
|
||||
onDeselect={handleDeselectModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Add Model to Combo"
|
||||
kindFilter={kindFilter}
|
||||
addedModelValues={models}
|
||||
closeOnSelect={false}
|
||||
/>
|
||||
{showModelSelect && (
|
||||
<ModelSelectModal
|
||||
isOpen={showModelSelect}
|
||||
onClose={() => setShowModelSelect(false)}
|
||||
onSelect={handleAddModel}
|
||||
onDeselect={handleDeselectModel}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title="Add Model to Combo"
|
||||
kindFilter={kindFilter}
|
||||
addedModelValues={models}
|
||||
closeOnSelect={false}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -350,6 +350,8 @@ export function GenericExampleCard({ providerId, kind }) {
|
||||
className="max-h-40 rounded-lg border border-border object-contain bg-sidebar"
|
||||
onError={(e) => { e.currentTarget.style.display = "none"; }}
|
||||
onLoad={(e) => { e.currentTarget.style.display = "block"; }}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -383,6 +385,8 @@ export function GenericExampleCard({ providerId, kind }) {
|
||||
className="max-h-40 rounded-lg border border-border object-contain bg-sidebar"
|
||||
onError={(e) => { e.currentTarget.style.display = "none"; }}
|
||||
onLoad={(e) => { e.currentTarget.style.display = "block"; }}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -487,6 +491,8 @@ export function GenericExampleCard({ providerId, kind }) {
|
||||
src={`data:image/png;base64,${partialImage.b64_json}`}
|
||||
alt="Partial"
|
||||
className="max-w-full rounded-lg border border-border mt-1.5 opacity-80"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -529,6 +535,8 @@ export function GenericExampleCard({ providerId, kind }) {
|
||||
src={binaryImageUrl || (result?.data?.data?.[0]?.b64_json ? `data:image/png;base64,${result.data.data[0].b64_json}` : result?.data?.data?.[0]?.url)}
|
||||
alt="Generated"
|
||||
className="max-w-full rounded-lg border border-border"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -357,7 +357,7 @@ export default function ComboDetailPage() {
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
<img src={testResult.imageUrl} alt="Generated" className="max-w-full rounded-lg border border-border" />
|
||||
<img src={testResult.imageUrl} alt="Generated" className="max-w-full rounded-lg border border-border" loading="lazy" decoding="async" />
|
||||
</div>
|
||||
)}
|
||||
{testResult.audioUrl && (
|
||||
@@ -393,18 +393,20 @@ export default function ComboDetailPage() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={showPicker}
|
||||
onClose={() => setShowPicker(false)}
|
||||
onSelect={handleAddModel}
|
||||
onDeselect={handleDeselectModel}
|
||||
activeProviders={connections}
|
||||
modelAliases={modelAliases}
|
||||
title={`Add ${kindLabel} Model`}
|
||||
kindFilter={combo.kind}
|
||||
addedModelValues={providers}
|
||||
closeOnSelect={false}
|
||||
/>
|
||||
{showPicker && (
|
||||
<ModelSelectModal
|
||||
isOpen={showPicker}
|
||||
onClose={() => setShowPicker(false)}
|
||||
onSelect={handleAddModel}
|
||||
onDeselect={handleDeselectModel}
|
||||
activeProviders={connections}
|
||||
modelAliases={modelAliases}
|
||||
title={`Add ${kindLabel} Model`}
|
||||
kindFilter={combo.kind}
|
||||
addedModelValues={providers}
|
||||
closeOnSelect={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -1265,22 +1265,24 @@ export default function ProviderLimits() {
|
||||
/>
|
||||
)}
|
||||
{hiddenQuotaRows.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1 border-t border-black/5 pt-2 text-[10px] text-text-muted dark:border-white/5">
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
<div className="mt-2 flex min-w-0 items-center gap-1 border-t border-black/5 pt-2 text-[10px] text-text-muted dark:border-white/5">
|
||||
<span className="material-symbols-outlined shrink-0 text-[14px]">
|
||||
visibility_off
|
||||
</span>
|
||||
<span>Hidden:</span>
|
||||
{hiddenQuotaRows.map((quotaRow) => (
|
||||
<button
|
||||
key={getQuotaVisibilityKey(quotaRow)}
|
||||
type="button"
|
||||
onClick={() => handleShowQuota(conn.provider, quotaRow)}
|
||||
className="rounded-md border border-black/10 px-1.5 py-0.5 transition-colors hover:bg-black/5 hover:text-text-primary dark:border-white/10 dark:hover:bg-white/5"
|
||||
title="Show this quota row"
|
||||
>
|
||||
{quotaRow.name}
|
||||
</button>
|
||||
))}
|
||||
<span className="shrink-0">Hidden:</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto whitespace-nowrap">
|
||||
{hiddenQuotaRows.map((quotaRow) => (
|
||||
<button
|
||||
key={getQuotaVisibilityKey(quotaRow)}
|
||||
type="button"
|
||||
onClick={() => handleShowQuota(conn.provider, quotaRow)}
|
||||
className="shrink-0 rounded-md border border-black/10 px-1.5 py-0.5 transition-colors hover:bg-black/5 hover:text-text-primary dark:border-white/10 dark:hover:bg-white/5"
|
||||
title="Show this quota row"
|
||||
>
|
||||
{quotaRow.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/providerIcon";
|
||||
|
||||
// Force-stop FE animation if a provider stays active longer than this
|
||||
const FE_ACTIVE_TIMEOUT_MS = 60000;
|
||||
@@ -19,9 +20,8 @@ function getProviderConfig(providerId) {
|
||||
return AI_PROVIDERS[providerId] || { color: "#6b7280", name: providerId };
|
||||
}
|
||||
|
||||
// Use local provider images from /public/providers/
|
||||
function getProviderImageUrl(providerId) {
|
||||
return `/providers/${providerId}.png`;
|
||||
return getProviderIconSrc(providerId);
|
||||
}
|
||||
|
||||
// Custom provider node - rectangle with image + name
|
||||
@@ -47,8 +47,19 @@ function ProviderNode({ data }) {
|
||||
className="w-8 h-8 rounded-md flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: `${color}15` }}
|
||||
>
|
||||
{!imgError ? (
|
||||
<img src={imageUrl} alt={label} className="w-6 h-6 rounded-sm object-contain" onError={() => setImgError(true)} />
|
||||
{imageUrl && !imgError ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={label}
|
||||
className="w-6 h-6 rounded-sm object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => {
|
||||
const m = imageUrl?.match(/^\/providers\/([^/]+)\.png$/i);
|
||||
if (m) markProviderIconMissing(m[1]);
|
||||
setImgError(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm font-bold" style={{ color }}>{textIcon}</span>
|
||||
)}
|
||||
@@ -86,7 +97,7 @@ function RouterNode({ data }) {
|
||||
<Handle type="source" position={Position.Left} id="left" className="!bg-transparent !border-0 !w-0 !h-0" />
|
||||
<Handle type="source" position={Position.Right} id="right" className="!bg-transparent !border-0 !w-0 !h-0" />
|
||||
|
||||
<img src="/favicon.svg" alt="9Router" className="w-6 h-6 mr-2" />
|
||||
<img src="/favicon.svg" alt="9Router" className="w-6 h-6 mr-2" loading="lazy" decoding="async" />
|
||||
<span className="text-sm font-bold text-primary">9Router</span>
|
||||
{data.activeCount > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 rounded-full bg-primary text-white text-xs font-bold">
|
||||
|
||||
@@ -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() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,11 +166,13 @@ export default function ComboFormModal({ isOpen, combo, onClose, onSave, activeP
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ModelSelectModal isOpen={showModelSelect} onClose={() => setShowModelSelect(false)}
|
||||
onSelect={handleAddModel} onDeselect={handleDeselectModel}
|
||||
activeProviders={activeProviders} modelAliases={modelAliases}
|
||||
title="Add Model to Combo" kindFilter={kindFilter}
|
||||
addedModelValues={models} closeOnSelect={false} />
|
||||
{showModelSelect && (
|
||||
<ModelSelectModal isOpen={showModelSelect} onClose={() => setShowModelSelect(false)}
|
||||
onSelect={handleAddModel} onDeselect={handleDeselectModel}
|
||||
activeProviders={activeProviders} modelAliases={modelAliases}
|
||||
title="Add Model to Combo" kindFilter={kindFilter}
|
||||
addedModelValues={models} closeOnSelect={false} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,6 +106,8 @@ function DonateChannelCard({ channel }) {
|
||||
src={qr}
|
||||
alt={`${label} QR`}
|
||||
className="w-full max-w-[180px] aspect-square object-contain rounded-lg bg-white p-1"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -12,6 +12,7 @@ import DonateModal from "@/shared/components/DonateModal";
|
||||
import { useHeaderSearchStore } from "@/store/headerSearchStore";
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { getProviderIconSrc } from "@/shared/utils/providerIcon";
|
||||
import { translate } from "@/i18n/runtime";
|
||||
|
||||
const getPageInfo = (pathname) => {
|
||||
@@ -30,7 +31,7 @@ const getPageInfo = (pathname) => {
|
||||
breadcrumbs: [
|
||||
{ label: "Media Providers", href: `/dashboard/media-providers/${kindId}` },
|
||||
{ label: kindConfig?.label || kindId, href: `/dashboard/media-providers/${kindId}` },
|
||||
{ label: provider?.name || providerId, image: `/providers/${providerId}.png` },
|
||||
{ label: provider?.name || providerId, image: getProviderIconSrc(providerId) },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -62,7 +63,7 @@ const getPageInfo = (pathname) => {
|
||||
{ label: "Providers", href: "/dashboard/providers" },
|
||||
{
|
||||
label: providerInfo.name,
|
||||
image: `/providers/${providerInfo.id}.png`,
|
||||
image: getProviderIconSrc(providerInfo.id),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -154,7 +154,7 @@ export default function McpMarketplaceModal({ isOpen, onClose, onAdd, addedNames
|
||||
<div className="flex items-start gap-2 px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5">
|
||||
{s.iconUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={s.iconUrl} alt="" className="size-7 rounded shrink-0 object-contain" onError={(e) => { e.target.style.display = "none"; }} />
|
||||
<img src={s.iconUrl} alt="" className="size-7 rounded shrink-0 object-contain" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
|
||||
) : (
|
||||
<div className="size-7 rounded bg-surface shrink-0" />
|
||||
)}
|
||||
|
||||
@@ -2,18 +2,29 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/providerIcon";
|
||||
|
||||
function resolveSrc(src, providerId) {
|
||||
if (providerId) return getProviderIconSrc(providerId);
|
||||
if (!src) return null;
|
||||
const m = String(src).match(/^\/providers\/([^/]+)\.png$/i);
|
||||
if (m) return getProviderIconSrc(m[1]);
|
||||
return src;
|
||||
}
|
||||
|
||||
export default function ProviderIcon({
|
||||
src,
|
||||
providerId,
|
||||
alt,
|
||||
size = 32,
|
||||
className = "",
|
||||
fallbackText = "?",
|
||||
fallbackColor,
|
||||
}) {
|
||||
const effectiveSrc = resolveSrc(src, providerId);
|
||||
const [errored, setErrored] = useState(false);
|
||||
|
||||
if (!src || errored) {
|
||||
if (!effectiveSrc || errored) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center font-bold rounded-lg ${className}`.trim()}
|
||||
@@ -31,18 +42,26 @@ export default function ProviderIcon({
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
src={effectiveSrc}
|
||||
alt={alt}
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
onError={() => setErrored(true)}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => {
|
||||
const m = effectiveSrc.match(/^\/providers\/([^/]+)\.png$/i);
|
||||
if (m) markProviderIconMissing(m[1]);
|
||||
if (providerId) markProviderIconMissing(providerId);
|
||||
setErrored(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ProviderIcon.propTypes = {
|
||||
src: PropTypes.string,
|
||||
providerId: PropTypes.string,
|
||||
alt: PropTypes.string,
|
||||
size: PropTypes.number,
|
||||
className: PropTypes.string,
|
||||
|
||||
@@ -1,44 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
|
||||
|
||||
// Fetch model capabilities once and expose a lookup by fullModel ("provider/model") or bare model id.
|
||||
// Module cache: one /api/models fetch shared by every useModelCaps instance.
|
||||
let cache = null; // { byFull, byId } | null
|
||||
let inflight = null;
|
||||
|
||||
function buildMaps(models) {
|
||||
const byFull = {};
|
||||
const byId = {};
|
||||
for (const m of models || []) {
|
||||
if (!m.caps) continue;
|
||||
if (m.fullModel) byFull[m.fullModel] = m.caps;
|
||||
if (m.model) byId[m.model] = m.caps;
|
||||
}
|
||||
return { byFull, byId };
|
||||
}
|
||||
|
||||
function loadModelCaps() {
|
||||
if (cache) return Promise.resolve(cache);
|
||||
if (inflight) return inflight;
|
||||
inflight = fetch("/api/models")
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`models ${res.status}`);
|
||||
const data = await res.json();
|
||||
cache = buildMaps(data.models);
|
||||
return cache;
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep null so a later mount can retry
|
||||
return { byFull: {}, byId: {} };
|
||||
})
|
||||
.finally(() => { inflight = null; });
|
||||
return inflight;
|
||||
}
|
||||
|
||||
// Resolve caps from a "provider/model" string or a bare model id.
|
||||
function resolveCaps(byFull, byId, key) {
|
||||
if (!key) return null;
|
||||
if (byFull[key]) return byFull[key];
|
||||
const bare = key.includes("/") ? key.slice(key.indexOf("/") + 1) : key;
|
||||
if (byId[bare]) return byId[bare];
|
||||
const provider = key.includes("/") ? key.slice(0, key.indexOf("/")) : null;
|
||||
const c = getCapabilitiesForModel(provider, bare);
|
||||
return { vision: c.vision, search: c.search, reasoning: c.reasoning };
|
||||
}
|
||||
|
||||
export function useModelCaps() {
|
||||
const [byFull, setByFull] = useState({});
|
||||
const [byId, setById] = useState({});
|
||||
const [byFull, setByFull] = useState(() => cache?.byFull || {});
|
||||
const [byId, setById] = useState(() => cache?.byId || {});
|
||||
|
||||
useEffect(() => {
|
||||
if (cache) {
|
||||
setByFull(cache.byFull);
|
||||
setById(cache.byId);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const full = {};
|
||||
const id = {};
|
||||
for (const m of data.models || []) {
|
||||
if (!m.caps) continue;
|
||||
if (m.fullModel) full[m.fullModel] = m.caps;
|
||||
if (m.model) id[m.model] = m.caps;
|
||||
}
|
||||
if (alive) { setByFull(full); setById(id); }
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
loadModelCaps().then((maps) => {
|
||||
if (alive) { setByFull(maps.byFull); setById(maps.byId); }
|
||||
});
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
// Resolve caps from a "provider/model" string or a bare model id.
|
||||
const getCaps = (key) => {
|
||||
if (!key) return null;
|
||||
if (byFull[key]) return byFull[key];
|
||||
const bare = key.includes("/") ? key.slice(key.indexOf("/") + 1) : key;
|
||||
if (byId[bare]) return byId[bare];
|
||||
// Fallback: compute caps for dynamic models (passthrough/custom/suggested) not in static list
|
||||
const provider = key.includes("/") ? key.slice(0, key.indexOf("/")) : null;
|
||||
const c = getCapabilitiesForModel(provider, bare);
|
||||
return { vision: c.vision, search: c.search, reasoning: c.reasoning };
|
||||
};
|
||||
const getCaps = useCallback(
|
||||
(key) => resolveCaps(byFull, byId, key),
|
||||
[byFull, byId],
|
||||
);
|
||||
|
||||
return { getCaps };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Shared Utils - Export all
|
||||
export { cn } from "./cn";
|
||||
export * as api from "./api";
|
||||
export { getProviderIconSrc, markProviderIconMissing, resolveProviderIconId } from "./providerIcon";
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
|
||||
40
src/shared/utils/providerIcon.js
Normal 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);
|
||||
}
|
||||