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>
|
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 |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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";
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||