fix: update cli tools

This commit is contained in:
2026-07-12 22:44:11 +07:00
parent 43a0c90dac
commit 330eb4d936
37 changed files with 441 additions and 8073 deletions
@@ -1,45 +1,10 @@
"use client";
import { useState, useEffect } from "react";
import { CardSkeleton } from "@/shared/components";
import { CLI_TOOLS, MITM_TOOLS } from "@/shared/constants/cliTools";
import { MitmLinkCard } from "./components";
import ToolSummaryCard from "./components/ToolSummaryCard";
const ALL_STATUSES_URL = "/api/cli-tools/all-statuses";
export default function CLIToolsPageClient({ machineId }) {
const [loading, setLoading] = useState(true);
const [toolStatuses, setToolStatuses] = useState({});
useEffect(() => {
let mounted = true;
(async () => {
try {
const res = await fetch(ALL_STATUSES_URL);
if (res.ok && mounted) setToolStatuses(await res.json());
} catch (error) {
console.log("Error fetching tool statuses:", error);
} finally {
if (mounted) setLoading(false);
}
})();
return () => { mounted = false; };
}, []);
if (loading) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</div>
);
}
const regularTools = Object.entries(CLI_TOOLS);
const mitmTools = Object.entries(MITM_TOOLS);
@@ -47,7 +12,7 @@ export default function CLIToolsPageClient({ machineId }) {
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-1 sm:px-0">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
{regularTools.map(([toolId, tool]) => (
<ToolSummaryCard key={toolId} toolId={toolId} tool={tool} status={toolStatuses[toolId]} />
<ToolSummaryCard key={toolId} toolId={toolId} tool={tool} />
))}
</div>
<div className="flex flex-col gap-3 sm:gap-4">
@@ -1,16 +1,10 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect } from "react";
import Link from "next/link";
import { CardSkeleton } from "@/shared/components";
import { CLI_TOOLS } from "@/shared/constants/cliTools";
import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
import {
ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard,
HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard,
CopilotToolCard, ClineToolCard, KiloToolCard, DeepSeekTuiToolCard,
JcodeToolCard,
} from "../components";
import { ConfigGeneratorCard, DefaultToolCard } from "../components";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -18,7 +12,6 @@ export default function ToolDetailClient({ toolId, machineId }) {
const tool = CLI_TOOLS[toolId];
const [connections, setConnections] = useState([]);
const [loading, setLoading] = useState(true);
const [modelMappings, setModelMappings] = useState({});
const [cloudEnabled, setCloudEnabled] = useState(false);
const [tunnelEnabled, setTunnelEnabled] = useState(false);
const [tunnelPublicUrl, setTunnelPublicUrl] = useState("");
@@ -67,31 +60,6 @@ export default function ToolDetailClient({ toolId, machineId }) {
const getActiveProviders = () => connections.filter(c => c.isActive !== false);
const getAllAvailableModels = () => {
const activeProviders = getActiveProviders();
const models = [];
const seenModels = new Set();
activeProviders.forEach(conn => {
const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider;
const providerModels = getModelsByProviderId(conn.provider);
providerModels.forEach(m => {
const modelValue = `${alias}/${m.id}`;
if (!seenModels.has(modelValue)) {
seenModels.add(modelValue);
models.push({ value: modelValue, label: `${alias}/${m.id}`, provider: conn.provider, alias, connectionName: conn.name, modelId: m.id });
}
});
});
return models;
};
const handleModelMappingChange = useCallback((tId, alias, target) => {
setModelMappings(prev => {
if (prev[tId]?.[alias] === target) return prev;
return { ...prev, [tId]: { ...prev[tId], [alias]: target } };
});
}, []);
const getBaseUrl = () => {
if (tunnelEnabled && tunnelPublicUrl) return tunnelPublicUrl;
if (cloudEnabled && CLOUD_URL) return CLOUD_URL;
@@ -100,48 +68,21 @@ export default function ToolDetailClient({ toolId, machineId }) {
};
const renderToolCard = () => {
const availableModels = getAllAvailableModels();
const hasActiveProviders = availableModels.length > 0;
const commonProps = {
tool,
isExpanded: true,
onToggle: () => {},
toolId,
baseUrl: getBaseUrl(),
apiKeys,
tunnelEnabled,
tunnelPublicUrl,
tailscaleEnabled,
tailscaleUrl,
activeProviders: getActiveProviders(),
cloudEnabled,
};
switch (toolId) {
case "claude":
return <ClaudeToolCard {...commonProps} activeProviders={getActiveProviders()} modelMappings={modelMappings[toolId] || {}} onModelMappingChange={(a, t) => handleModelMappingChange(toolId, a, t)} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
case "codex":
return <CodexToolCard {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
case "opencode":
return <OpenCodeToolCard {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
case "cowork":
return <CoworkToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} cloudUrl={CLOUD_URL} tunnelEnabled={tunnelEnabled} tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} />;
case "droid":
return <DroidToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
case "openclaw":
return <OpenClawToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
case "hermes":
return <HermesToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
case "copilot":
return <CopilotToolCard {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
case "cline":
return <ClineToolCard {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
case "kilo":
return <KiloToolCard {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
case "deepseek-tui":
return <DeepSeekTuiToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
case "jcode":
return <JcodeToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
default:
return <DefaultToolCard toolId={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} tunnelEnabled={tunnelEnabled} />;
}
if (tool.configType === "guide") return <DefaultToolCard toolId={toolId} {...commonProps} />;
return <ConfigGeneratorCard {...commonProps} />;
};
// Guard removed/unknown tools (e.g. disabled Cowork) to avoid crash on direct URL.
@@ -1,390 +0,0 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal, Tooltip } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
export default function ClaudeToolCard({
tool,
isExpanded,
onToggle,
activeProviders,
modelMappings,
onModelMappingChange,
baseUrl,
hasActiveProviders,
apiKeys,
cloudEnabled,
initialStatus,
tunnelEnabled,
tunnelPublicUrl,
tailscaleEnabled,
tailscaleUrl,
}) {
const [claudeStatus, setClaudeStatus] = useState(initialStatus || null);
const [checkingClaude, setCheckingClaude] = useState(false);
const [applying, setApplying] = useState(false);
const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null);
const [showInstallGuide, setShowInstallGuide] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [currentEditingAlias, setCurrentEditingAlias] = useState(null);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState("");
const [ccFilterNaming, setCcFilterNaming] = useState(false);
const hasInitializedModels = useRef(false);
const getConfigStatus = () => {
if (!claudeStatus?.installed) return null;
const currentUrl = claudeStatus.settings?.env?.ANTHROPIC_BASE_URL;
if (!currentUrl) return "not_configured";
if (matchKnownEndpoint(currentUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl: cloudEnabled ? CLOUD_URL : null })) return "configured";
return "other";
};
const configStatus = getConfigStatus();
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
}
}, [apiKeys, selectedApiKey]);
useEffect(() => {
if (initialStatus) setClaudeStatus(initialStatus);
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !claudeStatus) {
checkClaudeStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
useEffect(() => {
fetch("/api/settings").then(r => r.json()).then(data => {
setCcFilterNaming(!!data.ccFilterNaming);
}).catch(() => {});
}, []);
const handleCcFilterNamingToggle = async (e) => {
const value = e.target.checked;
setCcFilterNaming(value);
await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ccFilterNaming: value }),
}).catch(() => {});
};
const fetchModelAliases = async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
if (res.ok) setModelAliases(data.aliases || {});
} catch (error) {
console.log("Error fetching model aliases:", error);
}
};
useEffect(() => {
if (claudeStatus?.installed && !hasInitializedModels.current) {
hasInitializedModels.current = true;
const env = claudeStatus.settings?.env || {};
tool.defaultModels.forEach((model) => {
if (model.envKey) {
const value = env[model.envKey] || model.defaultValue || "";
// Only sync initial values from file once
if (value) {
onModelMappingChange(model.alias, value);
}
}
});
// Only set selectedApiKey if it exists in apiKeys list
const tokenFromFile = env.ANTHROPIC_AUTH_TOKEN;
if (tokenFromFile && apiKeys?.some(k => k.key === tokenFromFile)) {
setSelectedApiKey(tokenFromFile);
}
}
}, [claudeStatus, apiKeys, tool.defaultModels, onModelMappingChange]);
const checkClaudeStatus = async () => {
setCheckingClaude(true);
try {
const res = await fetch("/api/cli-tools/claude-settings");
const data = await res.json();
setClaudeStatus(data);
} catch (error) {
setClaudeStatus({ installed: false, error: error.message });
} finally {
setCheckingClaude(false);
}
};
const getEffectiveBaseUrl = () => {
const url = customBaseUrl || baseUrl;
return url.endsWith("/v1") ? url : `${url}/v1`;
};
const getDisplayUrl = () => {
const url = customBaseUrl || baseUrl;
return url.endsWith("/v1") ? url : `${url}/v1`;
};
const handleApplySettings = async () => {
setApplying(true);
setMessage(null);
try {
const env = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl() };
// Get key from dropdown, fallback to first key or sk_9router for localhost
const keyToUse = selectedApiKey?.trim()
|| (apiKeys?.length > 0 ? apiKeys[0].key : null)
|| (!cloudEnabled ? "sk_9router" : null);
if (keyToUse) {
env.ANTHROPIC_AUTH_TOKEN = keyToUse;
}
tool.defaultModels.forEach((model) => {
const targetModel = modelMappings[model.alias];
if (targetModel && model.envKey) env[model.envKey] = targetModel;
});
const res = await fetch("/api/cli-tools/claude-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ env }),
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" });
setClaudeStatus(prev => ({ ...prev, hasBackup: true, settings: { ...prev?.settings, env } }));
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setApplying(false);
}
};
const handleResetSettings = async () => {
setRestoring(true);
setMessage(null);
try {
const res = await fetch("/api/cli-tools/claude-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
tool.defaultModels.forEach((model) => onModelMappingChange(model.alias, model.defaultValue || ""));
setSelectedApiKey("");
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setRestoring(false);
}
};
const openModelSelector = (alias) => {
setCurrentEditingAlias(alias);
setModalOpen(true);
};
const handleModelSelect = (model) => {
if (currentEditingAlias) onModelMappingChange(currentEditingAlias, model.value);
};
// Generate settings.json content for manual copy
const getManualConfigs = () => {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const env = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl(), ANTHROPIC_AUTH_TOKEN: keyToUse };
tool.defaultModels.forEach((model) => {
const targetModel = modelMappings[model.alias];
if (targetModel && model.envKey) env[model.envKey] = targetModel;
});
return [
{
filename: "~/.claude/settings.json",
content: JSON.stringify({ hasCompletedOnboarding: true, env }, null, 2),
},
];
};
return (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/claude.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checkingClaude && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Claude CLI...</span>
</div>
)}
{!checkingClaude && claudeStatus && !claudeStatus.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">Claude CLI not detected locally</p>
<p className="text-sm text-text-muted">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex items-center gap-2 pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
{showInstallGuide ? "Hide" : "How to Install"}
</Button>
</div>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">
<h4 className="font-medium mb-3">Installation Guide</h4>
<div className="space-y-3 text-sm">
<div>
<p className="text-text-muted mb-1">macOS / Linux / Windows:</p>
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">npm install -g @anthropic-ai/claude-code</code>
</div>
<p className="text-text-muted">After installation, run <code className="px-1 bg-black/5 dark:bg-white/5 rounded">claude</code> to verify.</p>
</div>
</div>
)}
</div>
)}
{!checkingClaude && claudeStatus?.installed && (
<>
<div className="flex flex-col gap-2">
{/* Endpoint (selector) */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getDisplayUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
{/* Current configured */}
{claudeStatus?.settings?.env?.ANTHROPIC_BASE_URL && (
<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">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{claudeStatus.settings.env.ANTHROPIC_BASE_URL}
</span>
</div>
)}
{/* API Key */}
<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">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
{/* Model Mappings */}
{tool.defaultModels.map((model) => (
<div key={model.alias} 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">{model.name}</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input type="text" value={modelMappings[model.alias] || ""} onChange={(e) => onModelMappingChange(model.alias, e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" />
{modelMappings[model.alias] && <button onClick={() => onModelMappingChange(model.alias, "")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
<button onClick={() => openModelSelector(model.alias)} disabled={!hasActiveProviders} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button>
</div>
))}
{/* CC Filter Naming */}
<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">Filter naming</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={ccFilterNaming} onChange={handleCcFilterNamingToggle} className="w-3.5 h-3.5 accent-primary cursor-pointer" />
<span className="text-xs text-text-muted">Filter naming requests</span>
<Tooltip text="Intercepts Claude Code's topic-naming requests and returns a fake response locally, saving API tokens.">
<span className="material-symbols-outlined text-text-muted text-[14px] cursor-help">info</span>
</Tooltip>
</label>
</div>
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="primary" size="sm" onClick={handleApplySettings} disabled={!hasActiveProviders} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!claudeStatus?.has9Router} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal isOpen={modalOpen} onClose={() => setModalOpen(false)} onSelect={handleModelSelect} selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} activeProviders={activeProviders} modelAliases={modelAliases} title={`Select model for ${currentEditingAlias}`} />
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Claude CLI - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}
@@ -1,301 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
const [status, setStatus] = useState(initialStatus || null);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null);
const [showInstallGuide, setShowInstallGuide] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState("");
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) setSelectedApiKey(apiKeys[0].key);
}, [apiKeys, selectedApiKey]);
useEffect(() => {
if (initialStatus) setStatus(initialStatus);
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !status) {
checkStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
useEffect(() => {
if (status?.settings?.openAiModelId) setSelectedModel(status.settings.openAiModelId);
}, [status]);
const fetchModelAliases = async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
if (res.ok) setModelAliases(data.aliases || {});
} catch (error) {
console.log("Error fetching model aliases:", error);
}
};
const getConfigStatus = () => {
if (!status?.installed) return null;
if (!status.has9Router) return "not_configured";
const url = status.settings?.openAiBaseUrl || "";
return matchKnownEndpoint(url, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
};
const configStatus = getConfigStatus();
const getEffectiveBaseUrl = () => {
const url = customBaseUrl || `${baseUrl}/v1`;
return url.endsWith("/v1") ? url : `${url}/v1`;
};
const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`;
const checkStatus = async () => {
setChecking(true);
try {
const res = await fetch("/api/cli-tools/cline-settings");
const data = await res.json();
setStatus(data);
} catch (error) {
setStatus({ installed: false, error: error.message });
} finally {
setChecking(false);
}
};
const handleApply = async () => {
setApplying(true);
setMessage(null);
try {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : selectedApiKey);
const res = await fetch("/api/cli-tools/cline-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, model: selectedModel }),
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" });
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setApplying(false);
}
};
const handleReset = async () => {
setRestoring(true);
setMessage(null);
try {
const res = await fetch("/api/cli-tools/cline-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setSelectedModel("");
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setRestoring(false);
}
};
const getManualConfigs = () => {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const effectiveUrl = getEffectiveBaseUrl();
const baseWithoutV1 = effectiveUrl.endsWith("/v1") ? effectiveUrl.slice(0, -3) : effectiveUrl;
return [
{
filename: "~/.cline/data/globalState.json",
content: JSON.stringify({
actModeApiProvider: "openai",
planModeApiProvider: "openai",
openAiBaseUrl: baseWithoutV1,
openAiModelId: selectedModel || "provider/model-id",
planModeOpenAiModelId: selectedModel || "provider/model-id",
}, null, 2),
},
{
filename: "~/.cline/data/secrets.json",
content: JSON.stringify({ openAiApiKey: keyToUse }, null, 2),
},
];
};
return (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/cline.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checking && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Cline...</span>
</div>
)}
{!checking && status && !status.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">Cline not detected locally</p>
<p className="text-sm text-text-muted">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex items-center gap-2 pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
{showInstallGuide ? "Hide" : "How to Install"}
</Button>
</div>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">
<h4 className="font-medium mb-3">Installation Guide</h4>
<div className="space-y-3 text-sm">
<p className="text-text-muted">Install Cline VS Code extension or CLI from <a className="text-primary underline" href="https://docs.cline.bot/" target="_blank" rel="noreferrer">docs.cline.bot</a>.</p>
</div>
</div>
)}
</div>
)}
{!checking && status?.installed && (
<>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getDisplayUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
{status?.settings?.openAiBaseUrl && (
<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">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{status.settings.openAiBaseUrl}
</span>
</div>
)}
<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">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
<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">Model</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input type="text" value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" />
{selectedModel && <button onClick={() => setSelectedModel("")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button>
</div>
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="primary" size="sm" onClick={handleApply} disabled={(!selectedApiKey && (cloudEnabled && apiKeys.length > 0)) || !selectedModel} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleReset} disabled={restoring} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Cline"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Cline - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}
@@ -1,402 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
const [codexStatus, setCodexStatus] = useState(initialStatus || null);
const [checkingCodex, setCheckingCodex] = useState(false);
const [applying, setApplying] = useState(false);
const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null);
const [showInstallGuide, setShowInstallGuide] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [subagentModel, setSubagentModel] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [subagentModalOpen, setSubagentModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState("");
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
}
}, [apiKeys, selectedApiKey]);
useEffect(() => {
if (initialStatus) setCodexStatus(initialStatus);
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !codexStatus) {
checkCodexStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
const fetchModelAliases = async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
if (res.ok) setModelAliases(data.aliases || {});
} catch (error) {
console.log("Error fetching model aliases:", error);
}
};
// Parse model and subagent settings from config content
useEffect(() => {
if (codexStatus?.config) {
const modelMatch = codexStatus.config.match(/^model\s*=\s*"([^"]+)"/m);
if (modelMatch) setSelectedModel(modelMatch[1]);
// Parse subagent settings
const subagentModelMatch = codexStatus.config.match(/\[agents\.subagent\]\s*\n\s*model\s*=\s*"([^"]+)"/m);
if (subagentModelMatch) setSubagentModel(subagentModelMatch[1]);
}
}, [codexStatus]);
const getConfigStatus = () => {
if (!codexStatus?.installed) return null;
if (!codexStatus.config) return "not_configured";
const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/);
const currentUrl = parsed ? parsed[1] : "";
return matchKnownEndpoint(currentUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
};
const configStatus = getConfigStatus();
const getEffectiveBaseUrl = () => {
const url = customBaseUrl || `${baseUrl}/v1`;
// Ensure URL ends with /v1
return url.endsWith("/v1") ? url : `${url}/v1`;
};
const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`;
const checkCodexStatus = async () => {
setCheckingCodex(true);
try {
const res = await fetch("/api/cli-tools/codex-settings");
const data = await res.json();
setCodexStatus(data);
} catch (error) {
setCodexStatus({ installed: false, error: error.message });
} finally {
setCheckingCodex(false);
}
};
const handleApplySettings = async () => {
setApplying(true);
setMessage(null);
try {
// Use sk_9router for localhost if no key, otherwise use selected key
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : selectedApiKey);
const res = await fetch("/api/cli-tools/codex-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
model: selectedModel,
subagentModel: subagentModel || selectedModel
}),
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" });
checkCodexStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setApplying(false);
}
};
const handleResetSettings = async () => {
setRestoring(true);
setMessage(null);
try {
const res = await fetch("/api/cli-tools/codex-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setSelectedModel("");
setSubagentModel("");
checkCodexStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setRestoring(false);
}
};
const handleModelSelect = (model) => {
setSelectedModel(model.value);
// Auto-set subagent model if not set
if (!subagentModel) {
setSubagentModel(model.value);
}
setModalOpen(false);
};
const getManualConfigs = () => {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const effectiveSubagentModel = subagentModel || selectedModel;
const configContent = `# 9Router Configuration for Codex CLI
model = "${selectedModel}"
model_provider = "9router"
[model_providers.9router]
name = "9Router"
base_url = "${getEffectiveBaseUrl()}"
wire_api = "responses"
[agents.subagent]
model = "${effectiveSubagentModel}"
`;
const authContent = JSON.stringify({
auth_mode: "apikey",
OPENAI_API_KEY: keyToUse
}, null, 2);
return [
{
filename: "~/.codex/config.toml",
content: configContent,
},
{
filename: "~/.codex/auth.json",
content: authContent,
},
];
};
return (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/codex.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checkingCodex && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Codex CLI...</span>
</div>
)}
{!checkingCodex && codexStatus && !codexStatus.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">Codex CLI not detected locally</p>
<p className="text-sm text-text-muted">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex items-center gap-2 pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
{showInstallGuide ? "Hide" : "How to Install"}
</Button>
</div>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">
<h4 className="font-medium mb-3">Installation Guide</h4>
<div className="space-y-3 text-sm">
<div>
<p className="text-text-muted mb-1">macOS / Linux / Windows:</p>
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">npm install -g @openai/codex</code>
</div>
<p className="text-text-muted">After installation, run <code className="px-1 bg-black/5 dark:bg-white/5 rounded">codex</code> to verify.</p>
<div className="pt-2 border-t border-border">
<p className="text-text-muted text-xs">
Codex uses <code className="px-1 bg-black/5 dark:bg-white/5 rounded">~/.codex/auth.json</code> with <code className="px-1 bg-black/5 dark:bg-white/5 rounded">OPENAI_API_KEY</code>.
Click &quot;Apply&quot; to auto-configure.
</p>
</div>
</div>
</div>
)}
</div>
)}
{!checkingCodex && codexStatus?.installed && (
<>
<div className="flex flex-col gap-2">
{/* Endpoint (selector) */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getDisplayUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
{/* Current configured */}
{codexStatus?.config && (() => {
const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/);
const currentBaseUrl = parsed ? parsed[1] : null;
return currentBaseUrl ? (
<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">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{currentBaseUrl}
</span>
</div>
) : null;
})()}
{/* API Key */}
<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">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
{/* Model */}
<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">Model</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input type="text" value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" />
{selectedModel && <button onClick={() => setSelectedModel("")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button>
</div>
{/* Subagent Model */}
<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">Subagent Model</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input
type="text"
value={subagentModel}
onChange={(e) => setSubagentModel(e.target.value)}
placeholder={selectedModel || "provider/model-id (defaults to main model)"}
className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
/>
{subagentModel && (
<button
onClick={() => setSubagentModel("")}
className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear (will use main model)"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
<button
onClick={() => setSubagentModalOpen(true)}
disabled={!activeProviders?.length}
className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select Model
</button>
</div>
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="primary" size="sm" onClick={handleApplySettings} disabled={(!selectedApiKey && (cloudEnabled && apiKeys.length > 0)) || !selectedModel} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={restoring} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Codex"
/>
<ModelSelectModal
isOpen={subagentModalOpen}
onClose={() => setSubagentModalOpen(false)}
onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }}
selectedModel={subagentModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Subagent Model for Codex"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Codex CLI - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}
@@ -0,0 +1,373 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Image from "next/image";
import { Button, Card, ManualConfigModal, ModelSelectModal } from "@/shared/components";
import { getThinkingLevels } from "open-sse/providers/thinkingLevels.js";
import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect";
const DEFAULT_MODEL = "provider/model-id";
const normalizeV1 = (url) => {
const trimmed = (url || "").replace(/\/+$/, "");
return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
};
const toJson = (value) => JSON.stringify(value, null, 2);
const withThinkingLevel = (model, thinkingLevel) => (
model && thinkingLevel ? `${model}(${thinkingLevel})` : model
);
function buildConfigs(toolId, { baseUrl, apiKey, models, claudeModels = {}, claudeThinking = {}, codexModel = "", codexThinking = "" }) {
const endpoint = normalizeV1(baseUrl);
const selectedModels = models.length ? models : [DEFAULT_MODEL];
const model = selectedModels[0];
switch (toolId) {
case "claude":
return [{
filename: "~/.claude/settings.json",
content: toJson({
hasCompletedOnboarding: true,
env: {
ANTHROPIC_BASE_URL: endpoint,
ANTHROPIC_AUTH_TOKEN: apiKey,
ANTHROPIC_DEFAULT_SONNET_MODEL: withThinkingLevel(claudeModels.sonnet || DEFAULT_MODEL, claudeThinking.sonnet),
ANTHROPIC_DEFAULT_OPUS_MODEL: withThinkingLevel(claudeModels.opus || DEFAULT_MODEL, claudeThinking.opus),
ANTHROPIC_DEFAULT_HAIKU_MODEL: withThinkingLevel(claudeModels.haiku || DEFAULT_MODEL, claudeThinking.haiku),
},
}),
}];
case "codex":
return [
{
filename: "~/.codex/config.toml",
content: `model = "${withThinkingLevel(codexModel || DEFAULT_MODEL, codexThinking)}"\nmodel_provider = "9router"\n\n[model_providers.9router]\nname = "9Router"\nbase_url = "${endpoint}"\nwire_api = "responses"\n`,
},
{ filename: "~/.codex/auth.json", content: toJson({ auth_mode: "apikey", OPENAI_API_KEY: apiKey }) },
];
case "openclaw":
return [{
filename: "~/.openclaw/openclaw.json",
content: toJson({
agents: { defaults: { model: { primary: `9router/${model}` } } },
models: { providers: { "9router": {
baseUrl: endpoint,
apiKey,
api: "openai-completions",
models: selectedModels.map((id) => ({ id, name: id.split("/").pop() })),
} } },
}),
}];
case "opencode": {
const modelEntries = Object.fromEntries(selectedModels.map((id) => [id, {
name: id,
modalities: { input: ["text", "image"], output: ["text"] },
}]));
return [{
filename: "~/.config/opencode/opencode.json",
content: toJson({
provider: { "9router": {
npm: "@ai-sdk/openai-compatible",
options: { baseURL: endpoint, apiKey },
models: modelEntries,
} },
model: `9router/${model}`,
}),
}];
}
case "copilot":
return [{
filename: "chatLanguageModels.json",
content: toJson(selectedModels.map((id) => ({
name: id,
vendor: "9Router",
model: id,
apiBase: endpoint,
apiKey,
}))),
}];
case "cline":
return [{
filename: "~/.cline/data/globalState.json",
content: toJson({
openAiModelId: model,
openAiBaseUrl: endpoint,
openAiApiKey: apiKey,
}),
}];
case "kilo":
return [{
filename: "~/.local/share/kilo/auth.json",
content: toJson({
"9router": { baseUrl: endpoint, apiKey, model },
}),
}];
case "deepseek-tui":
return [{
filename: "~/.deepseek/config.toml",
content: `[model]\nprovider = "openai"\nbase_url = "${endpoint}"\napi_key = "${apiKey}"\ndefault = "${model}"\n`,
}];
case "hermes":
return [{
filename: "~/.hermes/config.yaml",
content: `model:\n provider: openai\n base_url: ${endpoint}\n api_key: ${apiKey}\n default: ${model}\n`,
}];
case "droid":
return [{
filename: "~/.factory/settings.json",
content: toJson({ customModels: selectedModels.map((id, index) => ({
id: `custom:9Router-${index}`,
name: `9Router: ${id}`,
baseUrl: endpoint,
apiKey,
model: id,
})) }),
}];
case "jcode":
return [{
filename: "~/.config/jcode/config.json",
content: toJson({ provider: "openai", baseUrl: endpoint, apiKey, model }),
}];
case "cowork":
return [{
filename: "Claude Desktop third-party inference configuration.json",
content: toJson({ baseUrl: endpoint, apiKey, models: selectedModels }),
}];
default:
return [{ filename: "config.json", content: toJson({ baseUrl: endpoint, apiKey, model }) }];
}
}
export default function ConfigGeneratorCard({
tool,
toolId,
baseUrl,
apiKeys,
activeProviders,
cloudEnabled,
tunnelEnabled,
tunnelPublicUrl,
tailscaleEnabled,
tailscaleUrl,
}) {
const [selectedApiKey, setSelectedApiKey] = useState(() => apiKeys?.[0]?.key || "");
const [selectedModels, setSelectedModels] = useState([]);
const [claudeModels, setClaudeModels] = useState({ sonnet: "", opus: "", haiku: "" });
const [claudeThinking, setClaudeThinking] = useState({ sonnet: "", opus: "", haiku: "" });
const [claudeModelSlot, setClaudeModelSlot] = useState("");
const [codexModel, setCodexModel] = useState("");
const [codexThinking, setCodexThinking] = useState("");
const [connectedModels, setConnectedModels] = useState(null);
const [customBaseUrl, setCustomBaseUrl] = useState("");
const [modelModalOpen, setModelModalOpen] = useState(false);
const [configModalOpen, setConfigModalOpen] = useState(false);
const effectiveBaseUrl = customBaseUrl || baseUrl;
const apiKey = selectedApiKey.trim() || (cloudEnabled ? "<API_KEY_FROM_DASHBOARD>" : "sk_9router");
const configs = useMemo(
() => buildConfigs(toolId, { baseUrl: effectiveBaseUrl, apiKey, models: selectedModels, claudeModels, claudeThinking, codexModel, codexThinking }),
[toolId, effectiveBaseUrl, apiKey, selectedModels, claudeModels, claudeThinking, codexModel, codexThinking]
);
const getThinkingLevelsForModel = (fullModel) => {
const connectedModel = connectedModels?.find((model) => model.fullModel === fullModel);
if (!connectedModel?.provider?.id || !connectedModel.model) return null;
return getThinkingLevels(connectedModel.provider.id, connectedModel.model);
};
useEffect(() => {
if (toolId !== "claude" && toolId !== "codex") return;
let cancelled = false;
const loadConnectedModels = async () => {
try {
const response = await fetch("/api/models/connected", { cache: "no-store" });
if (!response.ok) throw new Error("Failed to load connected models");
const data = await response.json();
if (!cancelled) setConnectedModels(data.models || []);
} catch (error) {
console.log(`Error loading connected models for ${tool.name}:`, error);
if (!cancelled) setConnectedModels([]);
}
};
loadConnectedModels();
return () => { cancelled = true; };
}, [toolId, tool.name]);
const addModel = (selected) => {
if (!selected?.value || selectedModels.includes(selected.value)) return;
setSelectedModels((current) => [...current, selected.value]);
};
const selectClaudeModel = (selected) => {
if (!selected?.value || !claudeModelSlot) return;
setClaudeModels((current) => ({ ...current, [claudeModelSlot]: selected.value }));
setClaudeThinking((current) => ({ ...current, [claudeModelSlot]: "" }));
setClaudeModelSlot("");
};
const openClaudeModelSelector = (slot) => {
setClaudeModelSlot(slot);
setModelModalOpen(true);
};
const selectCodexModel = (selected) => {
if (!selected?.value) return;
setCodexModel(selected.value);
setCodexThinking("");
};
return (
<Card padding="sm" className="overflow-hidden">
<div className="flex items-start gap-3 sm:items-center">
<div className="size-9 shrink-0">
<Image src={tool.image} alt={tool.name} width={36} height={36} className="size-9 rounded-lg object-contain" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-medium text-text-main">{tool.name}</h3>
<p className="text-xs text-text-muted">Generate a configuration file to copy to your own machine.</p>
</div>
</div>
<div className="mt-5 flex flex-col gap-4 border-t border-border pt-4">
<label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted">
Endpoint
<BaseUrlSelect
value={customBaseUrl || baseUrl}
onChange={setCustomBaseUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
cloudEnabled={cloudEnabled}
cloudUrl={process.env.NEXT_PUBLIC_CLOUD_URL}
/>
</label>
<label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted">
API key
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</label>
{toolId === "claude" ? (
<div className="flex flex-col gap-3">
<span className="text-xs font-medium text-text-muted">Default Claude models</span>
{[
{ slot: "sonnet", label: "Sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL" },
{ slot: "opus", label: "Opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL" },
{ slot: "haiku", label: "Haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL" },
].map(({ slot, label, envKey }) => {
const thinkingLevels = getThinkingLevelsForModel(claudeModels[slot]);
return (
<div key={slot} className="flex flex-col gap-1.5">
<label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted">
<span>{label} <code className="font-normal">({envKey})</code></span>
<div className="flex gap-2">
<input
type="text"
readOnly
value={claudeModels[slot]}
placeholder={DEFAULT_MODEL}
aria-label={`Default ${label} model`}
onClick={() => openClaudeModelSelector(slot)}
className="min-w-0 flex-1 cursor-pointer rounded-lg border border-border bg-bg-secondary px-3 py-2 text-xs text-text-main outline-none transition-colors hover:border-primary/60 focus:border-primary"
/>
<Button type="button" variant="secondary" size="sm" onClick={() => openClaudeModelSelector(slot)}>Select</Button>
{claudeModels[slot] && <Button type="button" variant="ghost" size="sm" onClick={() => { setClaudeModels((current) => ({ ...current, [slot]: "" })); setClaudeThinking((current) => ({ ...current, [slot]: "" })); }}>Clear</Button>}
</div>
</label>
{thinkingLevels && (
<label className="flex items-center gap-2 text-xs font-medium text-text-muted">
Reasoning / thinking
<select
value={claudeThinking[slot]}
onChange={(event) => setClaudeThinking((current) => ({ ...current, [slot]: event.target.value }))}
className="min-w-36 rounded-lg border border-border bg-bg-secondary px-2 py-1.5 text-xs text-text-main outline-none focus:border-primary"
>
<option value="">Default</option>
{thinkingLevels.map((level) => <option key={level} value={level}>{level === "none" ? "Disabled" : level}</option>)}
</select>
</label>
)}
</div>
);
})}
<p className="text-xs text-text-muted">Choose a model for each Claude Code alias. Empty fields use <code>{DEFAULT_MODEL}</code> as a placeholder.</p>
</div>
) : toolId === "codex" ? (
<div className="flex flex-col gap-3">
<label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted">
Default model <code className="font-normal">(model)</code>
<div className="flex gap-2">
<input
type="text"
readOnly
value={codexModel}
placeholder={DEFAULT_MODEL}
aria-label="Default Codex model"
onClick={() => setModelModalOpen(true)}
className="min-w-0 flex-1 cursor-pointer rounded-lg border border-border bg-bg-secondary px-3 py-2 text-xs text-text-main outline-none transition-colors hover:border-primary/60 focus:border-primary"
/>
<Button type="button" variant="secondary" size="sm" onClick={() => setModelModalOpen(true)}>Select</Button>
{codexModel && <Button type="button" variant="ghost" size="sm" onClick={() => { setCodexModel(""); setCodexThinking(""); }}>Clear</Button>}
</div>
</label>
{getThinkingLevelsForModel(codexModel) && (
<label className="flex items-center gap-2 text-xs font-medium text-text-muted">
Reasoning / thinking
<select
value={codexThinking}
onChange={(event) => setCodexThinking(event.target.value)}
className="min-w-36 rounded-lg border border-border bg-bg-secondary px-2 py-1.5 text-xs text-text-main outline-none focus:border-primary"
>
<option value="">Default</option>
{getThinkingLevelsForModel(codexModel).map((level) => <option key={level} value={level}>{level === "none" ? "Disabled" : level}</option>)}
</select>
</label>
)}
<p className="text-xs text-text-muted">The selected reasoning level is appended to the model ID, for example <code>cx/gpt-5.6-sol(high)</code>.</p>
</div>
) : (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-medium text-text-muted">Models</span>
<Button variant="secondary" size="sm" onClick={() => setModelModalOpen(true)}>
<span className="material-symbols-outlined mr-1 text-[16px]">add</span>
Add model
</Button>
</div>
{selectedModels.length ? (
<div className="flex flex-wrap gap-2">
{selectedModels.map((model) => (
<button key={model} type="button" onClick={() => setSelectedModels((current) => current.filter((item) => item !== model))} className="inline-flex items-center gap-1 rounded-full border border-border bg-bg-secondary px-2 py-1 text-xs text-text-main hover:border-red-500/50" title="Remove model">
{model}<span className="material-symbols-outlined text-[14px]">close</span>
</button>
))}
</div>
) : <p className="text-xs text-text-muted">No model selected. The generated file uses <code>{DEFAULT_MODEL}</code> as a placeholder.</p>}
</div>
)}
<Button onClick={() => setConfigModalOpen(true)} className="w-full sm:w-auto sm:self-start">
<span className="material-symbols-outlined mr-1 text-[16px]">code</span>
Show configuration file
</Button>
<p className="text-xs text-text-muted">9Router cannot inspect, modify, or apply files on your device from a deployed dashboard.</p>
</div>
<ModelSelectModal
isOpen={modelModalOpen}
onClose={() => { setModelModalOpen(false); setClaudeModelSlot(""); }}
onSelect={toolId === "claude" ? selectClaudeModel : toolId === "codex" ? selectCodexModel : addModel}
selectedModel=""
activeProviders={activeProviders}
title={toolId === "claude" && claudeModelSlot ? `Select ${claudeModelSlot} model` : toolId === "codex" ? "Select Codex model" : `Add model for ${tool.name}`}
closeOnSelect={toolId === "claude" || toolId === "codex"}
addedModelValues={toolId === "claude" ? Object.values(claudeModels).filter(Boolean) : toolId === "codex" ? [codexModel].filter(Boolean) : selectedModels}
availableModels={toolId === "claude" || toolId === "codex" ? connectedModels : null}
/>
<ManualConfigModal isOpen={configModalOpen} onClose={() => setConfigModalOpen(false)} title={`${tool.name} configuration`} configs={configs} />
</Card>
);
}
@@ -1,323 +0,0 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
const [status, setStatus] = useState(initialStatus || null);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [customBaseUrl, setCustomBaseUrl] = useState("");
const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [selectedModels, setSelectedModels] = useState([]);
const [modalOpen, setModalOpen] = useState(false);
const selectedModelsRef = useRef([]);
useEffect(() => {
selectedModelsRef.current = selectedModels;
}, [selectedModels]);
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
}
}, [apiKeys, selectedApiKey]);
useEffect(() => {
if (initialStatus) setStatus(initialStatus);
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !status) {
checkStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
// Pre-fill from existing config
useEffect(() => {
if (status?.config && Array.isArray(status.config) && selectedModels.length === 0) {
const entry = status.config.find((e) => e.name === "9Router");
if (entry?.models?.length > 0) {
setSelectedModels(entry.models.map((m) => m.id));
}
}
}, [status]);
const fetchModelAliases = async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
if (res.ok) setModelAliases(data.aliases || {});
} catch (error) {
console.log("Error fetching model aliases:", error);
}
};
const saveModels = async (models) => {
try {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : selectedApiKey);
await fetch("/api/cli-tools/copilot-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, models }),
});
} catch (error) {
console.log("Error saving models:", error);
}
};
const getConfigStatus = () => {
if (!status) return null;
if (!status.has9Router) return "not_configured";
const url = status.currentUrl || "";
return matchKnownEndpoint(url, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
};
const configStatus = getConfigStatus();
const getEffectiveBaseUrl = () => {
const url = customBaseUrl || baseUrl;
return url.endsWith("/v1") ? url : `${url}/v1`;
};
const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`;
const removeModel = (id) => setSelectedModels((prev) => prev.filter((m) => m !== id));
const checkStatus = async () => {
setChecking(true);
try {
const res = await fetch("/api/cli-tools/copilot-settings");
const data = await res.json();
setStatus(data);
} catch (error) {
setStatus({ error: error.message });
} finally {
setChecking(false);
}
};
const handleApply = async () => {
setApplying(true);
setMessage(null);
try {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : selectedApiKey);
const res = await fetch("/api/cli-tools/copilot-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, models: selectedModels }),
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: data.message || "Settings applied! Reload VS Code." });
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setApplying(false);
}
};
const handleReset = async () => {
setRestoring(true);
setMessage(null);
try {
const res = await fetch("/api/cli-tools/copilot-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setSelectedModels([]);
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setRestoring(false);
}
};
const getManualConfigs = () => {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const effectiveBaseUrl = getEffectiveBaseUrl();
const modelsToShow = selectedModels.length > 0 ? selectedModels : ["provider/model-id"];
return [{
filename: "~/Library/Application Support/Code/User/chatLanguageModels.json",
content: JSON.stringify([{
name: "9Router",
vendor: "azure",
apiKey: keyToUse,
models: modelsToShow.map((id) => ({
id, name: id,
url: `${effectiveBaseUrl}/chat/completions#models.ai.azure.com`,
toolCalling: true, vision: false,
maxInputTokens: 128000, maxOutputTokens: 16000,
})),
}], null, 2),
}];
};
return (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/copilot.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checking && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Copilot config...</span>
</div>
)}
{!checking && (
<>
<div className="flex items-start gap-3 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
<span className="material-symbols-outlined text-blue-500 text-lg">info</span>
<div className="text-xs text-blue-700 dark:text-blue-300">
<p className="font-medium">Writes to <code className="px-1 bg-black/5 dark:bg-white/10 rounded">chatLanguageModels.json</code></p>
<p className="mt-0.5 opacity-80">Reload VS Code after applying for changes to take effect.</p>
</div>
</div>
<div className="flex flex-col gap-2">
{/* Endpoint */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getDisplayUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
{/* API Key */}
<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">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
{/* Models */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Models</span>
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
<div className="flex-1 flex flex-col gap-2">
<div className="flex flex-wrap gap-1.5 min-h-[28px] px-2 py-1.5 bg-surface rounded border border-border">
{selectedModels.length === 0 ? (
<span className="text-xs text-text-muted">No models selected</span>
) : (
selectedModels.map((model) => (
<span key={model} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-black/5 dark:bg-white/5 text-text-muted border border-transparent hover:border-border">
{model}
<button onClick={(e) => { e.stopPropagation(); removeModel(model); }} className="ml-0.5 hover:text-red-500">
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</span>
))
)}
</div>
<div>
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`px-2 py-1 rounded border text-xs transition-colors ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Add Model</button>
</div>
</div>
</div>
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="primary" size="sm" onClick={handleApply} disabled={selectedModels.length === 0} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleReset} disabled={!status?.has9Router} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)} disabled={selectedModels.length === 0}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => {
setModalOpen(false);
saveModels(selectedModelsRef.current);
}}
onSelect={(model) => {
if (!selectedModels.includes(model.value)) {
setSelectedModels([...selectedModels, model.value]);
}
}}
onDeselect={(model) => {
setSelectedModels(selectedModels.filter(m => m !== model.value));
}}
selectedModel={null}
activeProviders={activeProviders}
modelAliases={modelAliases}
addedModelValues={selectedModels}
closeOnSelect={false}
title="Add Model for GitHub Copilot"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="GitHub Copilot - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}
@@ -1,597 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Button, ManualConfigModal, ComboFormModal, McpMarketplaceModal, ModelSelectModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect";
const ENDPOINT = "/api/cli-tools/cowork-settings";
const stripV1 = (url) => (url || "").replace(/\/v1\/?$/, "");
const ensureV1 = (url) => {
const trimmed = (url || "").replace(/\/+$/, "");
if (!trimmed) return "";
return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`;
};
export default function CoworkToolCard({
tool,
isExpanded,
onToggle,
baseUrl,
apiKeys,
activeProviders,
hasActiveProviders,
cloudEnabled,
cloudUrl,
tunnelEnabled,
tunnelPublicUrl,
tailscaleEnabled,
tailscaleUrl,
initialStatus,
}) {
const [status, setStatus] = useState(initialStatus || null);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedModels, setSelectedModels] = useState([]);
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState("");
const [plugins, setPlugins] = useState([]);
const [localPlugins, setLocalPlugins] = useState([]);
const [customPlugins, setCustomPlugins] = useState([]);
const [modelAliases, setModelAliases] = useState({});
const [comboModalOpen, setComboModalOpen] = useState(false);
const [modelSelectOpen, setModelSelectOpen] = useState(false);
const [marketplaceOpen, setMarketplaceOpen] = useState(false);
const [addMcpOpen, setAddMcpOpen] = useState(false);
const [addMcpForm, setAddMcpForm] = useState({ name: "", url: "" });
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
}
}, [apiKeys, selectedApiKey]);
useEffect(() => {
if (initialStatus) setStatus(initialStatus);
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !status) checkStatus();
}, [isExpanded]);
useEffect(() => {
if (!isExpanded) return;
fetch("/api/models/alias")
.then((r) => r.ok ? r.json() : null)
.then((data) => {
if (data) setModelAliases(data.aliases || {});
})
.catch(() => {});
}, [isExpanded]);
useEffect(() => {
if (status?.cowork?.models?.length) {
setSelectedModels(status.cowork.models);
}
if (status?.cowork?.baseUrl && !customBaseUrl) {
setCustomBaseUrl(stripV1(status.cowork.baseUrl));
}
// Initialize plugins: from current config, fallback to defaultPlugins
if (Array.isArray(status?.cowork?.plugins) && status.cowork.plugins.length > 0) {
setPlugins(status.cowork.plugins);
} else if (plugins.length === 0 && Array.isArray(status?.defaultPlugins)) {
setPlugins(status.defaultPlugins);
}
if (Array.isArray(status?.cowork?.localPlugins)) {
setLocalPlugins(status.cowork.localPlugins);
}
if (Array.isArray(status?.cowork?.customPlugins) && status.cowork.customPlugins.length > 0) {
setCustomPlugins(status.cowork.customPlugins);
}
}, [status]);
const checkStatus = async () => {
setChecking(true);
try {
const res = await fetch(ENDPOINT);
const data = await res.json();
setStatus(data);
} catch (error) {
setStatus({ installed: false, error: error.message });
} finally {
setChecking(false);
}
};
const getEffectiveBaseUrl = () => ensureV1(customBaseUrl);
const getConfigStatus = () => {
if (!status?.installed) return null;
const url = status?.cowork?.baseUrl;
if (!url) return "not_configured";
return status.has9Router ? "configured" : "other";
};
const configStatus = getConfigStatus();
const handleApply = async () => {
setMessage(null);
const effectiveUrl = getEffectiveBaseUrl();
if (selectedModels.length === 0) {
setMessage({ type: "error", text: "Please select at least one model" });
return;
}
setApplying(true);
try {
const keyToUse = selectedApiKey?.trim()
|| (apiKeys?.length > 0 ? apiKeys[0].key : null)
|| (!cloudEnabled ? "sk_9router" : null);
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: effectiveUrl,
apiKey: keyToUse,
models: selectedModels,
plugins,
localPlugins,
customPlugins,
}),
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied. Quit & reopen Claude Desktop to load." });
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setApplying(false);
}
};
const handleCreateCombo = async ({ name, models }) => {
try {
const res = await fetch("/api/combos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, models }),
});
if (!res.ok) {
const err = await res.json();
setMessage({ type: "error", text: err.error || "Failed to create combo" });
return;
}
if (!selectedModels.includes(name)) {
setSelectedModels([...selectedModels, name]);
}
setComboModalOpen(false);
setMessage({ type: "success", text: `Combo "${name}" created and added.` });
} catch (error) {
setMessage({ type: "error", text: error.message });
}
};
const handleAddModel = (model) => {
const value = model?.value || model?.name || model;
if (!value || selectedModels.includes(value)) return;
setSelectedModels((prev) => [...prev, value]);
};
const handleRemoveModel = (model) => {
const value = model?.value || model?.name || model;
setSelectedModels((prev) => prev.filter((item) => item !== value));
};
const handleReset = async () => {
setRestoring(true);
setMessage(null);
try {
const res = await fetch(ENDPOINT, { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully" });
setSelectedModels([]);
setPlugins(status?.defaultPlugins || []);
setLocalPlugins([]);
setCustomPlugins([]);
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setRestoring(false);
}
};
const addPlugin = (p) => {
if (plugins.some((x) => x.name === p.name)) return;
setPlugins([...plugins, p]);
};
const removePlugin = (name) => {
setPlugins(plugins.filter((p) => p.name !== name));
};
const getManualConfigs = () => {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const modelsToShow = selectedModels.length > 0 ? selectedModels : ["provider/model-id"];
const cfg = {
inferenceProvider: "gateway",
inferenceGatewayBaseUrl: getEffectiveBaseUrl() || "https://your-public-host/v1",
inferenceGatewayApiKey: keyToUse,
inferenceModels: modelsToShow.map((name) => ({ name })),
};
return [{
filename: "~/Library/Application Support/Claude-3p/configLibrary/<appliedId>.json",
content: JSON.stringify(cfg, null, 2),
}];
};
return (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src={tool.image} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checking && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Claude Cowork...</span>
</div>
)}
{!checking && status && !status.installed && (
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">Claude Desktop (Cowork mode) not detected</p>
<p className="text-sm text-text-muted">Open Claude Desktop Help Troubleshooting Enable Developer mode Configure third-party inference, then return here.</p>
</div>
</div>
<div className="pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
</div>
</div>
)}
{!checking && status?.installed && (
<>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={getEffectiveBaseUrl()}
onChange={(url) => setCustomBaseUrl(stripV1(url))}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
cloudEnabled={cloudEnabled}
cloudUrl={cloudUrl}
/>
</div>
{status?.cowork?.baseUrl && (
<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">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{status.cowork.baseUrl}
</span>
</div>
)}
<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">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Models</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
<div className="flex-1 flex items-center gap-2">
<div className="flex-1 flex flex-wrap gap-1.5 min-h-[28px] px-2 py-1.5 bg-surface rounded border border-border">
{selectedModels.length === 0 ? (
<span className="text-xs text-text-muted">No models selected</span>
) : (
selectedModels.map((m) => (
<span key={m} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-black/5 dark:bg-white/5 text-text-muted border border-transparent hover:border-border">
{m}
<button onClick={() => handleRemoveModel(m)} className="ml-0.5 hover:text-red-500">
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</span>
))
)}
</div>
<button onClick={() => setComboModalOpen(true)} disabled={!hasActiveProviders} className={`shrink-0 px-2 py-1.5 rounded border text-xs whitespace-nowrap transition-colors ${hasActiveProviders ? "bg-primary/10 border-primary/40 text-primary hover:bg-primary/20 cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>+ Combo</button>
</div>
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-2">MCP</span>
<span className="material-symbols-outlined text-text-muted text-[14px] mt-2">arrow_forward</span>
<div className="flex-1 flex flex-col gap-1">
{/* Preset plugins */}
{plugins.filter((p) => p.name !== "exa").map((p) => (
<div key={p.name} className="flex items-center gap-2 px-2 py-1 bg-surface rounded border border-border">
<span className="text-xs font-medium min-w-0 truncate flex-shrink-0">{p.title || p.name}</span>
{p.oauth && <span className="text-[8px] text-amber-600 shrink-0">OAuth</span>}
<div className="flex-1 flex flex-wrap gap-1 overflow-hidden" style={{ maxHeight: "1.5rem" }}>
{Array.isArray(p.toolNames) && p.toolNames.slice(0, 6).map((t) => (
<span key={t} className="text-[9px] px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 text-text-muted whitespace-nowrap">{t}</span>
))}
{Array.isArray(p.toolNames) && p.toolNames.length > 6 && (
<span className="text-[9px] px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 text-text-muted whitespace-nowrap">+{p.toolNames.length - 6}</span>
)}
</div>
<button onClick={() => removePlugin(p.name)} className="shrink-0 hover:text-red-500 ml-auto">
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</div>
))}
{/* Custom plugins */}
{customPlugins.map((p) => (
<div key={p.name} className="flex items-center gap-2 px-2 py-1 bg-surface rounded border border-border">
<span className="text-xs font-medium min-w-0 truncate flex-shrink-0">{p.name}</span>
<span className="text-[8px] px-1 py-0.5 rounded bg-blue-500/10 text-blue-500 shrink-0">custom</span>
<span className="flex-1 text-[9px] text-text-muted truncate">{p.url}</span>
<button onClick={() => setCustomPlugins(customPlugins.filter((x) => x.name !== p.name))} className="shrink-0 hover:text-red-500 ml-auto">
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</div>
))}
{plugins.filter((p) => p.name !== "exa").length === 0 && customPlugins.length === 0 && (
<div className="px-2 py-1.5 bg-surface rounded border border-border text-xs text-text-muted">No MCPs added</div>
)}
{/* Actions row */}
<div className="flex items-center gap-2 mt-0.5">
<button onClick={() => setMarketplaceOpen(true)} className="px-2 py-1 rounded border text-xs bg-primary/10 border-primary/40 text-primary hover:bg-primary/20 cursor-pointer whitespace-nowrap">
+ Browse
</button>
<button onClick={() => { setAddMcpForm({ name: "", url: "" }); setAddMcpOpen(true); }} className="px-2 py-1 rounded border text-xs bg-surface border-border text-text-muted hover:border-primary hover:text-primary cursor-pointer whitespace-nowrap">
+ Custom
</button>
<a href="https://mcp.so" target="_blank" rel="noopener noreferrer" className="text-[10px] text-text-muted hover:text-primary underline ml-auto">Find MCPs </a>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Tools</span>
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
<div className="flex-1 flex flex-col gap-1.5">
{(() => {
const exaEnabled = plugins.some((p) => p.name === "exa");
const exaDef = (status?.defaultPlugins || []).find((d) => d.name === "exa");
return (
<label className="flex items-start gap-2 cursor-pointer px-2 py-1.5 bg-surface rounded border border-border">
<input
type="checkbox"
checked={exaEnabled}
onChange={(e) => {
if (e.target.checked && exaDef) setPlugins([...plugins.filter((p) => p.name !== "exa"), exaDef]);
else setPlugins(plugins.filter((p) => p.name !== "exa"));
}}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium">Web Search & Fetch (Exa)</div>
<p className="text-[10px] text-text-muted leading-snug">Replaces built-in WebSearch/WebFetch. Auto-strips duplicates from tool list.</p>
</div>
</label>
);
})()}
{(() => {
const browserDef = (status?.localStdioPlugins || []).find((p) => p.name === "browsermcp");
if (!browserDef) return null;
const browserEnabled = localPlugins.includes("browsermcp");
return (
<label className="flex items-start gap-2 cursor-pointer px-2 py-1.5 bg-surface rounded border border-border">
<input
type="checkbox"
checked={browserEnabled}
onChange={(e) => setLocalPlugins(e.target.checked ? [...localPlugins, "browsermcp"] : localPlugins.filter((n) => n !== "browsermcp"))}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium">Browser Control (Browser MCP)</div>
<p className="text-[10px] text-text-muted leading-snug">
Controls your running Chrome. Auto-strips Cowork&apos;s built-in browser tools.{" "}
<a href={browserDef.extensionUrl} target="_blank" rel="noopener noreferrer" className="text-primary underline">Install Chrome extension</a>
</p>
</div>
</label>
);
})()}
</div>
</div>
{Array.isArray(status?.localStdioPlugins) && status.localStdioPlugins.filter((p) => p.name !== "browsermcp").length > 0 && (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Local Plugins</span>
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
<div className="flex-1 flex flex-col gap-2">
<div className="flex flex-col gap-1.5 px-2 py-1.5 bg-surface rounded border border-border">
{status.localStdioPlugins.filter((p) => p.name !== "browsermcp").map((p) => {
const enabled = localPlugins.includes(p.name);
return (
<label key={p.name} className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setLocalPlugins(e.target.checked ? [...localPlugins, p.name] : localPlugins.filter((n) => n !== p.name))}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-medium">{p.title}</span>
<span className="text-[8px] text-amber-600">stdio</span>
</div>
<p className="text-[10px] text-text-muted leading-snug">{p.description}</p>
{p.extensionUrl && (
<a href={p.extensionUrl} target="_blank" rel="noopener noreferrer" className="text-[10px] text-primary underline">Install Chrome extension</a>
)}
</div>
</label>
);
})}
</div>
<p className="text-[10px] text-text-muted leading-snug">
Local plugins run as subprocess via <code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/5">npx</code>. Requires Node.js installed.
</p>
</div>
</div>
)}
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<Button variant="primary" size="sm" onClick={handleApply} disabled={selectedModels.length === 0} loading={applying} className="w-full sm:w-auto">
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleReset} disabled={!status.has9Router} loading={restoring} className="w-full sm:w-auto">
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)} className="w-full sm:w-auto">
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Claude Cowork - Manual Configuration"
configs={getManualConfigs()}
/>
<ComboFormModal
isOpen={comboModalOpen}
combo={null}
onClose={() => setComboModalOpen(false)}
onSave={handleCreateCombo}
activeProviders={activeProviders}
forcePrefix="claude-"
title="Create Cowork Combo"
/>
<ModelSelectModal
isOpen={modelSelectOpen}
onClose={() => setModelSelectOpen(false)}
onSelect={handleAddModel}
onDeselect={handleRemoveModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Cowork Model"
addedModelValues={selectedModels}
closeOnSelect={false}
/>
<McpMarketplaceModal
isOpen={marketplaceOpen}
onClose={() => setMarketplaceOpen(false)}
onAdd={addPlugin}
addedNames={plugins.map((p) => p.name)}
/>
{/* Add Custom MCP modal */}
{addMcpOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setAddMcpOpen(false)}>
<div className="bg-surface border border-border rounded-xl shadow-xl w-full max-w-sm mx-4 p-5 flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm">Add Custom MCP</h3>
<button onClick={() => setAddMcpOpen(false)} className="text-text-muted hover:text-text-main">
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<label className="text-[11px] text-text-muted font-medium">Name</label>
<input
type="text"
placeholder="my-mcp"
value={addMcpForm.name}
onChange={(e) => setAddMcpForm((f) => ({ ...f, name: e.target.value.replace(/\s+/g, "-").toLowerCase() }))}
className="px-2 py-1.5 rounded border border-border bg-surface text-xs outline-none focus:border-primary"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] text-text-muted font-medium">SSE URL</label>
<input
type="text"
placeholder="https://your-mcp-server.com/sse"
value={addMcpForm.url}
onChange={(e) => setAddMcpForm((f) => ({ ...f, url: e.target.value }))}
className="px-2 py-1.5 rounded border border-border bg-surface text-xs outline-none focus:border-primary"
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<button onClick={() => setAddMcpOpen(false)} className="px-3 py-1.5 rounded border border-border text-xs text-text-muted hover:bg-surface cursor-pointer">Cancel</button>
<button
onClick={() => {
const name = addMcpForm.name.trim();
if (!name || !addMcpForm.url.trim()) return;
setCustomPlugins((prev) => [...prev.filter((x) => x.name !== name), { name, url: addMcpForm.url.trim(), transport: "sse", custom: true }]);
setAddMcpOpen(false);
}}
className="px-3 py-1.5 rounded bg-primary text-white text-xs font-medium hover:opacity-90 cursor-pointer"
>Add</button>
</div>
</div>
</div>
)}
</Card>
);
}
@@ -1,338 +0,0 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
const ENDPOINT = "/api/cli-tools/deepseek-tui-settings";
export default function DeepSeekTuiToolCard({
tool,
isExpanded,
onToggle,
baseUrl,
hasActiveProviders,
apiKeys,
activeProviders,
cloudEnabled,
initialStatus,
tunnelEnabled,
tunnelPublicUrl,
tailscaleEnabled,
tailscaleUrl,
}) {
const [deepseekStatus, setDeepseekStatus] = useState(initialStatus || null);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState("");
const hasInitializedModel = useRef(false);
const getConfigStatus = () => {
if (!deepseekStatus?.installed) return null;
const openaiSection = deepseekStatus.settings?.["providers.openai"];
if (!openaiSection?.base_url) return "not_configured";
if (matchKnownEndpoint(openaiSection.base_url, { tunnelPublicUrl, tailscaleUrl })) return "configured";
return "other";
};
const configStatus = getConfigStatus();
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
}
}, [apiKeys, selectedApiKey]);
useEffect(() => {
if (initialStatus) setDeepseekStatus(initialStatus);
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !deepseekStatus) {
checkStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
const fetchModelAliases = async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
if (res.ok) setModelAliases(data.aliases || {});
} catch (error) {
console.log("Error fetching model aliases:", error);
}
};
useEffect(() => {
if (deepseekStatus?.installed && !hasInitializedModel.current) {
hasInitializedModel.current = true;
const openaiSection = deepseekStatus.settings?.["providers.openai"];
if (openaiSection?.model) setSelectedModel(openaiSection.model);
}
}, [deepseekStatus]);
const checkStatus = async () => {
setChecking(true);
try {
const res = await fetch(ENDPOINT);
const data = await res.json();
setDeepseekStatus(data);
} catch (error) {
setDeepseekStatus({ installed: false, error: error.message });
} finally {
setChecking(false);
}
};
const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1");
const getLocalBaseUrl = () => {
if (typeof window !== "undefined") {
return normalizeLocalhost(window.location.origin);
}
return "http://127.0.0.1:20128";
};
const getEffectiveBaseUrl = () => {
const url = customBaseUrl || getLocalBaseUrl();
return url.endsWith("/v1") ? url : `${url}/v1`;
};
const handleApply = async () => {
setApplying(true);
setMessage(null);
try {
const keyToUse = selectedApiKey?.trim()
|| (apiKeys?.length > 0 ? apiKeys[0].key : null)
|| (!cloudEnabled ? "sk_9router" : null);
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
model: selectedModel,
}),
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" });
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setApplying(false);
}
};
const handleReset = async () => {
setRestoring(true);
setMessage(null);
try {
const res = await fetch(ENDPOINT, { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setSelectedModel("");
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setRestoring(false);
}
};
const handleModelSelect = (model) => {
setSelectedModel(model.value);
setModalOpen(false);
};
const getManualConfigs = () => {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const tomlContent = `[providers.openai]
base_url = "${getEffectiveBaseUrl()}"
api_key = "${keyToUse}"
model = "${selectedModel || "provider/model-id"}"
`;
return [
{ filename: "~/.deepseek/config.toml", content: tomlContent },
];
};
return (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src={tool.image || "/providers/deepseek-tui.png"} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checking && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking DeepSeek TUI...</span>
</div>
)}
{!checking && deepseekStatus && !deepseekStatus.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">DeepSeek TUI not detected locally</p>
<p className="text-sm text-text-muted mt-1">Install via npm:</p>
<code className="block mt-2 p-2 bg-black/20 rounded text-xs font-mono">npm install -g deepseek-tui</code>
<p className="text-sm text-text-muted mt-2">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex items-center gap-2 pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
</div>
</div>
</div>
)}
{!checking && deepseekStatus?.installed && (
<>
<div className="flex flex-col gap-2">
{tool.notes && tool.notes.length > 0 && (
<div className="flex flex-col gap-2 mb-2">
{tool.notes.map((note, idx) => (
<div key={idx} className={`flex items-start gap-2 p-2 rounded text-xs ${
note.type === "warning" ? "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" :
note.type === "error" ? "bg-red-500/10 text-red-600 dark:text-red-400" :
"bg-blue-500/10 text-blue-600 dark:text-blue-400"
}`}>
<span className="material-symbols-outlined text-[14px] mt-0.5">
{note.type === "warning" ? "warning" : note.type === "error" ? "error" : "info"}
</span>
<span>{note.text}</span>
</div>
))}
</div>
)}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getEffectiveBaseUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
{deepseekStatus?.settings?.["providers.openai"]?.base_url && (
<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">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{deepseekStatus.settings["providers.openai"].base_url}
</span>
</div>
)}
<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">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
<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">Default Model</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input type="text" value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" />
{selectedModel && <button onClick={() => setSelectedModel("")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
<button onClick={() => setModalOpen(true)} disabled={!hasActiveProviders} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</button>
</div>
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="primary" size="sm" onClick={handleApply} disabled={!selectedModel} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleReset} disabled={!deepseekStatus?.has9Router} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for DeepSeek TUI"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="DeepSeek TUI - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}
@@ -1,410 +0,0 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
export default function DroidToolCard({
tool,
isExpanded,
onToggle,
baseUrl,
hasActiveProviders,
apiKeys,
activeProviders,
cloudEnabled,
initialStatus,
tunnelEnabled,
tunnelPublicUrl,
tailscaleEnabled,
tailscaleUrl,
}) {
const [droidStatus, setDroidStatus] = useState(initialStatus || null);
const [checkingDroid, setCheckingDroid] = useState(false);
const [applying, setApplying] = useState(false);
const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [modelList, setModelList] = useState([]);
const [modelInput, setModelInput] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [showInstallGuide, setShowInstallGuide] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState("");
const hasInitializedModel = useRef(false);
const getConfigStatus = () => {
if (!droidStatus?.installed) return null;
// Check for any 9Router model entry (support multi-model: custom:9Router-0, custom:9Router-1, ...)
const currentConfig = droidStatus.settings?.customModels?.find(m => m.id?.startsWith("custom:9Router"));
if (!currentConfig) return "not_configured";
return matchKnownEndpoint(currentConfig.baseUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl: cloudEnabled ? CLOUD_URL : null }) ? "configured" : "other";
};
const configStatus = getConfigStatus();
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
}
}, [apiKeys, selectedApiKey]);
useEffect(() => {
if (initialStatus) setDroidStatus(initialStatus);
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !droidStatus) {
checkDroidStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
const fetchModelAliases = async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
if (res.ok) setModelAliases(data.aliases || {});
} catch (error) {
console.log("Error fetching model aliases:", error);
}
};
// Pre-fill model list from existing config (supports multi-model)
useEffect(() => {
if (droidStatus?.installed && !hasInitializedModel.current) {
hasInitializedModel.current = true;
const existingModels = (droidStatus.settings?.customModels || [])
.filter(m => m.id?.startsWith("custom:9Router"))
.sort((a, b) => (a.index || 0) - (b.index || 0))
.map(m => m.model);
if (existingModels.length > 0) {
setModelList(existingModels);
} else {
// Legacy: single model stored as custom:9Router-0
const legacy = droidStatus.settings?.customModels?.find(m => m.id === "custom:9Router-0");
if (legacy?.model) {
setModelList([legacy.model]);
}
}
}
}, [droidStatus]);
const checkDroidStatus = async () => {
setCheckingDroid(true);
try {
const res = await fetch("/api/cli-tools/droid-settings");
const data = await res.json();
setDroidStatus(data);
} catch (error) {
setDroidStatus({ installed: false, error: error.message });
} finally {
setCheckingDroid(false);
}
};
const getEffectiveBaseUrl = () => {
const url = customBaseUrl || baseUrl;
return url.endsWith("/v1") ? url : `${url}/v1`;
};
const getDisplayUrl = () => {
const url = customBaseUrl || baseUrl;
return url.endsWith("/v1") ? url : `${url}/v1`;
};
const addModel = () => {
const val = modelInput.trim();
if (!val || modelList.includes(val)) return;
setModelList((prev) => [...prev, val]);
setModelInput("");
};
const removeModel = (id) => setModelList((prev) => prev.filter((m) => m !== id));
const handleModelSelect = (model) => {
if (!model.value || modelList.includes(model.value)) return;
setModelList((prev) => [...prev, model.value]);
setModalOpen(false);
};
const handleApplySettings = async () => {
setApplying(true);
setMessage(null);
try {
const keyToUse = selectedApiKey?.trim()
|| (apiKeys?.length > 0 ? apiKeys[0].key : null)
|| (!cloudEnabled ? "sk_9router" : null);
const res = await fetch("/api/cli-tools/droid-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
models: modelList,
activeModel: modelList[0] || "",
}),
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" });
checkDroidStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setApplying(false);
}
};
const handleResetSettings = async () => {
setRestoring(true);
setMessage(null);
try {
const res = await fetch("/api/cli-tools/droid-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setModelList([]);
checkDroidStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setRestoring(false);
}
};
const getManualConfigs = () => {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const settingsContent = {
customModels: modelList.map((m, i) => ({
model: m,
id: `custom:9Router-${i}`,
index: i,
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
displayName: m,
maxOutputTokens: 131072,
noImageSupport: false,
provider: "openai",
})),
};
const platform = typeof navigator !== "undefined" && navigator.platform;
const isWindows = platform?.toLowerCase().includes("win");
const settingsPath = isWindows
? "%USERPROFILE%\\.factory\\settings.json"
: "~/.factory/settings.json";
return [
{
filename: settingsPath,
content: JSON.stringify(settingsContent, null, 2),
},
];
};
return (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/droid.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checkingDroid && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Factory Droid CLI...</span>
</div>
)}
{!checkingDroid && droidStatus && !droidStatus.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">Factory Droid CLI not detected locally</p>
<p className="text-sm text-text-muted">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex items-center gap-2 pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
{showInstallGuide ? "Hide" : "How to Install"}
</Button>
</div>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">
<h4 className="font-medium mb-3">Installation Guide</h4>
<div className="space-y-3 text-sm">
<div>
<p className="text-text-muted mb-1">macOS / Linux / Windows:</p>
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">curl -fsSL https://app.factory.ai/cli | sh</code>
</div>
<p className="text-text-muted">After installation, run <code className="px-1 bg-black/5 dark:bg-white/5 rounded">droid</code> to verify.</p>
</div>
</div>
)}
</div>
)}
{!checkingDroid && droidStatus?.installed && (
<>
<div className="flex flex-col gap-2">
{/* Endpoint (selector) */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getDisplayUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
{/* Current configured */}
{droidStatus?.settings?.customModels?.find(m => m.id?.startsWith("custom:9Router"))?.baseUrl && (
<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">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{droidStatus.settings.customModels.find(m => m.id?.startsWith("custom:9Router")).baseUrl}
</span>
</div>
)}
{/* API Key */}
<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">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
{/* Models */}
<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">
Models {modelList.length > 0 && <span className="text-primary">({modelList.length})</span>}
</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="flex-1 flex flex-col gap-1">
{/* Model list */}
{modelList.length > 0 && (
<div className="flex flex-col gap-0.5 mb-1">
{modelList.map((id) => (
<div key={id} className="flex items-center gap-1.5 px-2 py-1 bg-bg-secondary rounded border border-border">
<span className="flex-1 text-xs font-mono truncate">{id}</span>
<button onClick={() => removeModel(id)} className="text-text-muted hover:text-red-500 transition-colors shrink-0" title="Remove">
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</div>
))}
</div>
)}
{/* Model input row */}
<div className="flex items-center gap-1.5">
<input
type="text"
value={modelInput}
onChange={(e) => setModelInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addModel(); } }}
placeholder="provider/model-id"
className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
/>
<button
onClick={() => setModalOpen(true)}
disabled={!hasActiveProviders}
className={`px-2 py-1.5 rounded border text-xs shrink-0 ${hasActiveProviders ? "bg-surface border-border hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select
</button>
<button onClick={addModel} disabled={!modelInput.trim()} className="px-2 py-1.5 rounded border bg-surface border-border hover:border-primary text-xs shrink-0 disabled:opacity-50" title="Add model">
<span className="material-symbols-outlined text-[14px]">add</span>
</button>
</div>
</div>
</div>
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="primary" size="sm" onClick={handleApplySettings} disabled={modelList.length === 0} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!droidStatus?.has9Router} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={null}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Factory Droid"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Factory Droid - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}
@@ -1,317 +0,0 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
const ENDPOINT = "/api/cli-tools/hermes-settings";
export default function HermesToolCard({
tool,
isExpanded,
onToggle,
baseUrl,
hasActiveProviders,
apiKeys,
activeProviders,
cloudEnabled,
initialStatus,
tunnelEnabled,
tunnelPublicUrl,
tailscaleEnabled,
tailscaleUrl,
}) {
const [hermesStatus, setHermesStatus] = useState(initialStatus || null);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState("");
const hasInitializedModel = useRef(false);
const getConfigStatus = () => {
if (!hermesStatus?.installed) return null;
const cfg = hermesStatus.settings?.model;
if (!cfg?.base_url) return "not_configured";
if (matchKnownEndpoint(cfg.base_url, { tunnelPublicUrl, tailscaleUrl })) return "configured";
return "other";
};
const configStatus = getConfigStatus();
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
}
}, [apiKeys, selectedApiKey]);
useEffect(() => {
if (initialStatus) setHermesStatus(initialStatus);
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !hermesStatus) {
checkStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
const fetchModelAliases = async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
if (res.ok) setModelAliases(data.aliases || {});
} catch (error) {
console.log("Error fetching model aliases:", error);
}
};
useEffect(() => {
if (hermesStatus?.installed && !hasInitializedModel.current) {
hasInitializedModel.current = true;
const cfg = hermesStatus.settings?.model;
if (cfg?.default) setSelectedModel(cfg.default);
}
}, [hermesStatus]);
const checkStatus = async () => {
setChecking(true);
try {
const res = await fetch(ENDPOINT);
const data = await res.json();
setHermesStatus(data);
} catch (error) {
setHermesStatus({ installed: false, error: error.message });
} finally {
setChecking(false);
}
};
const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1");
const getLocalBaseUrl = () => {
if (typeof window !== "undefined") {
return normalizeLocalhost(window.location.origin);
}
return "http://127.0.0.1:20128";
};
const getEffectiveBaseUrl = () => {
const url = customBaseUrl || getLocalBaseUrl();
return url.endsWith("/v1") ? url : `${url}/v1`;
};
const handleApply = async () => {
setApplying(true);
setMessage(null);
try {
const keyToUse = selectedApiKey?.trim()
|| (apiKeys?.length > 0 ? apiKeys[0].key : null)
|| (!cloudEnabled ? "sk_9router" : null);
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
model: selectedModel,
}),
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" });
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setApplying(false);
}
};
const handleReset = async () => {
setRestoring(true);
setMessage(null);
try {
const res = await fetch(ENDPOINT, { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setSelectedModel("");
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setRestoring(false);
}
};
const handleModelSelect = (model) => {
setSelectedModel(model.value);
setModalOpen(false);
};
const getManualConfigs = () => {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n`;
const envContent = `OPENAI_API_KEY=${keyToUse}\n`;
return [
{ filename: "~/.hermes/config.yaml", content: yamlContent },
{ filename: "~/.hermes/.env", content: envContent },
];
};
return (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/hermes.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checking && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Hermes Agent...</span>
</div>
)}
{!checking && hermesStatus && !hermesStatus.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">Hermes Agent not detected locally</p>
<p className="text-sm text-text-muted">Install: curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash</p>
</div>
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 pl-0 sm:pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="w-full sm:w-auto !bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
</div>
</div>
</div>
)}
{!checking && hermesStatus?.installed && (
<>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getEffectiveBaseUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
{hermesStatus?.settings?.model?.base_url && (
<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">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{hermesStatus.settings.model.base_url}
</span>
</div>
)}
<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">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
<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">Default Model</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input type="text" value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" />
{selectedModel && <button onClick={() => setSelectedModel("")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
<button onClick={() => setModalOpen(true)} disabled={!hasActiveProviders} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</button>
</div>
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<Button variant="primary" size="sm" onClick={handleApply} disabled={!selectedModel} loading={applying} className="w-full sm:w-auto">
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleReset} disabled={!hermesStatus?.has9Router} loading={restoring} className="w-full sm:w-auto">
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)} className="w-full sm:w-auto">
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Hermes Agent"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Hermes Agent - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}
@@ -1,380 +0,0 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
export default function JcodeToolCard({
tool,
isExpanded,
onToggle,
baseUrl,
hasActiveProviders,
apiKeys,
activeProviders,
cloudEnabled,
initialStatus,
tunnelEnabled,
tunnelPublicUrl,
tailscaleEnabled,
tailscaleUrl,
}) {
const [jcodeStatus, setJcodeStatus] = useState(initialStatus || null);
const [checkingJcode, setCheckingJcode] = useState(false);
const [applying, setApplying] = useState(false);
const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState("");
const hasInitializedModel = useRef(false);
const getConfigStatus = () => {
if (!jcodeStatus?.installed) return null;
if (!jcodeStatus?.has9Router) return "not_configured";
const currentProvider = jcodeStatus.config?.providers?.["9router"];
if (!currentProvider) return "not_configured";
return matchKnownEndpoint(currentProvider.base_url, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
};
const configStatus = getConfigStatus();
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
}
}, [apiKeys, selectedApiKey]);
useEffect(() => {
if (initialStatus) setJcodeStatus(initialStatus);
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !jcodeStatus) {
checkJcodeStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
const fetchModelAliases = async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
if (res.ok) setModelAliases(data.aliases || {});
} catch (error) {
console.log("Error fetching model aliases:", error);
}
};
useEffect(() => {
if (jcodeStatus?.installed && !hasInitializedModel.current) {
hasInitializedModel.current = true;
const provider = jcodeStatus.config?.providers?.["9router"];
if (provider) {
if (provider.default_model) {
setSelectedModel(provider.default_model);
}
// Try to match API key from env file
const envApiKey = jcodeStatus.envApiKey;
if (envApiKey && apiKeys?.some(k => k.key === envApiKey)) {
setSelectedApiKey(envApiKey);
}
}
}
}, [jcodeStatus, apiKeys]);
const checkJcodeStatus = async () => {
setCheckingJcode(true);
try {
const res = await fetch("/api/cli-tools/jcode-settings");
const data = await res.json();
setJcodeStatus(data);
} catch (error) {
setJcodeStatus({ installed: false, error: error.message });
} finally {
setCheckingJcode(false);
}
};
const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1");
const getLocalBaseUrl = () => {
if (typeof window !== "undefined") {
return normalizeLocalhost(window.location.origin);
}
return "http://127.0.0.1:20128";
};
const getEffectiveBaseUrl = () => {
const url = customBaseUrl || getLocalBaseUrl();
return url.endsWith("/v1") ? url : `${url}/v1`;
};
const getDisplayUrl = () => {
const url = customBaseUrl || getLocalBaseUrl();
return url.endsWith("/v1") ? url : `${url}/v1`;
};
const handleApplySettings = async () => {
setApplying(true);
setMessage(null);
try {
const keyToUse = selectedApiKey?.trim()
|| (apiKeys?.length > 0 ? apiKeys[0].key : null)
|| (!cloudEnabled ? "sk_9router" : null);
const res = await fetch("/api/cli-tools/jcode-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
models: selectedModel ? [selectedModel] : [],
}),
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" });
checkJcodeStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setApplying(false);
}
};
const handleResetSettings = async () => {
setRestoring(true);
setMessage(null);
try {
const res = await fetch("/api/cli-tools/jcode-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setSelectedModel("");
setSelectedApiKey("");
checkJcodeStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setRestoring(false);
}
};
const handleModelSelect = (model) => {
setSelectedModel(model.value);
setModalOpen(false);
};
const getManualConfigs = () => {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const configToml = `[providers.9router]
type = "openai-compatible"
base_url = "${getEffectiveBaseUrl()}"
auth = "bearer"
api_key_env = "JCODE_9ROUTER_API_KEY"
env_file = "provider-9router.env"
default_model = "${selectedModel || "cc/claude-opus-4-7"}"
requires_api_key = true
[[providers.9router.models]]
id = "${selectedModel || "cc/claude-opus-4-7"}"`;
const envContent = `JCODE_9ROUTER_API_KEY="${keyToUse}"`;
return [
{
filename: "~/.jcode/config.toml",
content: configToml,
},
{
filename: "~/.config/jcode/provider-9router.env",
content: envContent,
},
];
};
return (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src={tool.image || "/providers/jcode.png"} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checkingJcode && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking jcode CLI...</span>
</div>
)}
{!checkingJcode && jcodeStatus && !jcodeStatus.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">jcode CLI not detected locally</p>
<p className="text-sm text-text-muted mt-1">Install jcode to enable automatic configuration:</p>
<code className="block mt-2 p-2 bg-black/20 rounded text-xs font-mono">
curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash
</code>
<p className="text-sm text-text-muted mt-2">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex items-center gap-2 pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
</div>
</div>
</div>
)}
{!checkingJcode && jcodeStatus?.installed && (
<>
<div className="flex flex-col gap-2">
{/* Info notes */}
{tool.notes && tool.notes.length > 0 && (
<div className="flex flex-col gap-2 mb-2">
{tool.notes.map((note, idx) => (
<div key={idx} className={`flex items-start gap-2 p-2 rounded text-xs ${
note.type === "info" ? "bg-blue-500/10 text-blue-600 dark:text-blue-400" :
note.type === "warning" ? "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" :
"bg-gray-500/10 text-text-muted"
}`}>
<span className="material-symbols-outlined text-[14px] mt-0.5">
{note.type === "info" ? "info" : note.type === "warning" ? "warning" : "help"}
</span>
<span>{note.text}</span>
</div>
))}
</div>
)}
{/* Endpoint (selector) */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getDisplayUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
{/* Current configured */}
{jcodeStatus?.config?.providers?.["9router"]?.base_url && (
<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">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{jcodeStatus.config.providers["9router"].base_url}
</span>
</div>
)}
{/* API Key */}
<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">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
{/* Default Model */}
<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">Default Model</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input type="text" value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} placeholder="cc/claude-opus-4-7" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" />
{selectedModel && <button onClick={() => setSelectedModel("")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
<button onClick={() => setModalOpen(true)} disabled={!hasActiveProviders} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</button>
</div>
{/* Usage hint */}
<div className="flex flex-col gap-1 p-3 bg-blue-500/5 border border-blue-500/20 rounded-lg">
<p className="text-xs font-medium text-blue-600 dark:text-blue-400">Usage:</p>
<code className="text-xs font-mono text-text-muted">jcode --provider-profile 9router</code>
<code className="text-xs font-mono text-text-muted">jcode --provider-profile 9router --model {selectedModel || "cc/claude-opus-4-7"}</code>
</div>
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="primary" size="sm" onClick={handleApplySettings} disabled={!selectedModel} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!jcodeStatus?.has9Router} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for jcode"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="jcode - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}
@@ -1,275 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
export default function KiloToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
const [status, setStatus] = useState(initialStatus || null);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null);
const [showInstallGuide, setShowInstallGuide] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState("");
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) setSelectedApiKey(apiKeys[0].key);
}, [apiKeys, selectedApiKey]);
useEffect(() => {
if (initialStatus) setStatus(initialStatus);
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !status) {
checkStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
const fetchModelAliases = async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
if (res.ok) setModelAliases(data.aliases || {});
} catch (error) {
console.log("Error fetching model aliases:", error);
}
};
const getConfigStatus = () => {
if (!status?.installed) return null;
return status.has9Router ? "configured" : "not_configured";
};
const configStatus = getConfigStatus();
const getEffectiveBaseUrl = () => {
const url = customBaseUrl || `${baseUrl}/v1`;
return url.endsWith("/v1") ? url : `${url}/v1`;
};
const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`;
const checkStatus = async () => {
setChecking(true);
try {
const res = await fetch("/api/cli-tools/kilo-settings");
const data = await res.json();
setStatus(data);
} catch (error) {
setStatus({ installed: false, error: error.message });
} finally {
setChecking(false);
}
};
const handleApply = async () => {
setApplying(true);
setMessage(null);
try {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : selectedApiKey);
const res = await fetch("/api/cli-tools/kilo-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, model: selectedModel }),
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" });
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setApplying(false);
}
};
const handleReset = async () => {
setRestoring(true);
setMessage(null);
try {
const res = await fetch("/api/cli-tools/kilo-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setSelectedModel("");
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setRestoring(false);
}
};
const getManualConfigs = () => {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
return [{
filename: "~/.local/share/kilo/auth.json",
content: JSON.stringify({
"openai-compatible": {
type: "api-key",
apiKey: keyToUse,
baseUrl: getEffectiveBaseUrl(),
model: selectedModel || "provider/model-id",
},
}, null, 2),
}];
};
return (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/kilocode.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checking && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Kilo Code...</span>
</div>
)}
{!checking && status && !status.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">Kilo Code not detected locally</p>
<p className="text-sm text-text-muted">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex items-center gap-2 pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
{showInstallGuide ? "Hide" : "How to Install"}
</Button>
</div>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">
<h4 className="font-medium mb-3">Installation Guide</h4>
<p className="text-sm text-text-muted">Install Kilo Code from <a className="text-primary underline" href="https://kilocode.ai" target="_blank" rel="noreferrer">kilocode.ai</a> or VS Code extension marketplace.</p>
</div>
)}
</div>
)}
{!checking && status?.installed && (
<>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getDisplayUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
<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">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
<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">Model</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input type="text" value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" />
{selectedModel && <button onClick={() => setSelectedModel("")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button>
</div>
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="primary" size="sm" onClick={handleApply} disabled={(!selectedApiKey && (cloudEnabled && apiKeys.length > 0)) || !selectedModel} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleReset} disabled={restoring} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Kilo Code"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Kilo Code - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}
@@ -1,388 +0,0 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
export default function OpenClawToolCard({
tool,
isExpanded,
onToggle,
baseUrl,
hasActiveProviders,
apiKeys,
activeProviders,
cloudEnabled,
initialStatus,
tunnelEnabled,
tunnelPublicUrl,
tailscaleEnabled,
tailscaleUrl,
}) {
const [openclawStatus, setOpenclawStatus] = useState(initialStatus || null);
const [checkingOpenclaw, setCheckingOpenclaw] = useState(false);
const [applying, setApplying] = useState(false);
const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [agentModels, setAgentModels] = useState({}); // { [agentId]: modelId }
const [agentModalFor, setAgentModalFor] = useState(null); // agentId opening modal
const [modalOpen, setModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState("");
const hasInitializedModel = useRef(false);
const getConfigStatus = () => {
if (!openclawStatus?.installed) return null;
const currentProvider = openclawStatus.settings?.models?.providers?.["9router"];
if (!currentProvider) return "not_configured";
return matchKnownEndpoint(currentProvider.baseUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
};
const configStatus = getConfigStatus();
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
}
}, [apiKeys, selectedApiKey]);
useEffect(() => {
if (initialStatus) setOpenclawStatus(initialStatus);
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !openclawStatus) {
checkOpenclawStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
const fetchModelAliases = async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
if (res.ok) setModelAliases(data.aliases || {});
} catch (error) {
console.log("Error fetching model aliases:", error);
}
};
useEffect(() => {
if (openclawStatus?.installed && !hasInitializedModel.current) {
hasInitializedModel.current = true;
const provider = openclawStatus.settings?.models?.providers?.["9router"];
if (provider) {
const primaryModel = openclawStatus.settings?.agents?.defaults?.model?.primary;
if (primaryModel) setSelectedModel(primaryModel.replace("9router/", ""));
if (provider.apiKey && apiKeys?.some(k => k.key === provider.apiKey)) {
setSelectedApiKey(provider.apiKey);
}
}
// Init per-agent models from enriched agents list
const agentList = openclawStatus.agents || [];
const initAgentModels = {};
agentList.forEach((agent) => {
if (agent.currentModel) initAgentModels[agent.id] = agent.currentModel;
});
setAgentModels(initAgentModels);
}
}, [openclawStatus, apiKeys]);
const checkOpenclawStatus = async () => {
setCheckingOpenclaw(true);
try {
const res = await fetch("/api/cli-tools/openclaw-settings");
const data = await res.json();
setOpenclawStatus(data);
} catch (error) {
setOpenclawStatus({ installed: false, error: error.message });
} finally {
setCheckingOpenclaw(false);
}
};
const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1");
const getLocalBaseUrl = () => {
if (typeof window !== "undefined") {
return normalizeLocalhost(window.location.origin);
}
return "http://127.0.0.1:20128";
};
const getEffectiveBaseUrl = () => {
const url = customBaseUrl || getLocalBaseUrl();
return url.endsWith("/v1") ? url : `${url}/v1`;
};
const getDisplayUrl = () => {
const url = customBaseUrl || getLocalBaseUrl();
return url.endsWith("/v1") ? url : `${url}/v1`;
};
const handleApplySettings = async () => {
setApplying(true);
setMessage(null);
try {
const keyToUse = selectedApiKey?.trim()
|| (apiKeys?.length > 0 ? apiKeys[0].key : null)
|| (!cloudEnabled ? "sk_9router" : null);
const res = await fetch("/api/cli-tools/openclaw-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
model: selectedModel,
agentModels,
}),
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" });
checkOpenclawStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setApplying(false);
}
};
const handleResetSettings = async () => {
setRestoring(true);
setMessage(null);
try {
const res = await fetch("/api/cli-tools/openclaw-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setSelectedModel("");
setSelectedApiKey("");
checkOpenclawStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setRestoring(false);
}
};
const handleModelSelect = (model) => {
if (agentModalFor) {
setAgentModels(prev => ({ ...prev, [agentModalFor]: model.value }));
setAgentModalFor(null);
} else {
setSelectedModel(model.value);
}
setModalOpen(false);
};
const getManualConfigs = () => {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const settingsContent = {
agents: {
defaults: {
model: {
primary: `9router/${selectedModel || "provider/model-id"}`,
},
},
},
models: {
providers: {
"9router": {
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
api: "openai-completions",
models: [
{
id: selectedModel || "provider/model-id",
name: (selectedModel || "provider/model-id").split("/").pop(),
},
],
},
},
},
};
return [
{
filename: "~/.openclaw/openclaw.json",
content: JSON.stringify(settingsContent, null, 2),
},
];
};
return (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/openclaw.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checkingOpenclaw && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Open Claw CLI...</span>
</div>
)}
{!checkingOpenclaw && openclawStatus && !openclawStatus.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">Open Claw CLI not detected locally</p>
<p className="text-sm text-text-muted">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex items-center gap-2 pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
</div>
</div>
</div>
)}
{!checkingOpenclaw && openclawStatus?.installed && (
<>
<div className="flex flex-col gap-2">
{/* Endpoint (selector) */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getDisplayUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
{/* Current configured */}
{openclawStatus?.settings?.models?.providers?.["9router"]?.baseUrl && (
<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">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{openclawStatus.settings.models.providers["9router"].baseUrl}
</span>
</div>
)}
{/* API Key */}
<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">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
{/* Default Model */}
<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">Default Model</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input type="text" value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" />
{selectedModel && <button onClick={() => setSelectedModel("")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
<button onClick={() => { setAgentModalFor(null); setModalOpen(true); }} disabled={!hasActiveProviders} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</button>
</div>
{/* Per-agent model overrides */}
{(openclawStatus.agents || []).filter(a => a.agentDir).map((agent) => (
<div key={agent.id} className="flex items-center gap-2 pl-4">
<span className="w-32 shrink-0 text-xs text-primary text-right truncate" title={agent.name || agent.id}>Agent {agent.name || agent.id}</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input
type="text"
value={agentModels[agent.id] || ""}
onChange={(e) => setAgentModels(prev => ({ ...prev, [agent.id]: e.target.value }))}
placeholder={`default (${selectedModel || "provider/model-id"})`}
className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
/>
{agentModels[agent.id] && <button onClick={() => setAgentModels(prev => ({ ...prev, [agent.id]: "" }))} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
<button onClick={() => { setAgentModalFor(agent.id); setModalOpen(true); }} disabled={!hasActiveProviders} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</button>
</div>
))}
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="primary" size="sm" onClick={handleApplySettings} disabled={!selectedModel} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!openclawStatus?.has9Router} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Open Claw"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Open Claw - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}
@@ -1,500 +0,0 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
const [status, setStatus] = useState(initialStatus || null);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null);
const [showInstallGuide, setShowInstallGuide] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [subagentModel, setSubagentModel] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [subagentModalOpen, setSubagentModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState("");
const [selectedModels, setSelectedModels] = useState([]);
const [activeModel, setActiveModel] = useState("");
const selectedModelsRef = useRef([]);
useEffect(() => {
selectedModelsRef.current = selectedModels;
}, [selectedModels]);
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
}
}, [apiKeys, selectedApiKey]);
useEffect(() => {
if (initialStatus) setStatus(initialStatus);
}, [initialStatus]);
useEffect(() => {
if (isExpanded && !status) {
checkStatus();
fetchModelAliases();
}
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
// Sync models from existing config
useEffect(() => {
if (status?.opencode?.models) {
setSelectedModels(status.opencode.models);
}
if (status?.opencode?.activeModel) {
setActiveModel(status.opencode.activeModel);
}
// Parse subagent settings from agent.explorer if exists
if (status?.config?.agent?.explorer?.model?.startsWith("9router/")) {
setSubagentModel(status.config.agent.explorer.model.replace("9router/", ""));
}
}, [status]);
const fetchModelAliases = async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
if (res.ok) setModelAliases(data.aliases || {});
} catch (error) {
console.log("Error fetching model aliases:", error);
}
};
const saveModels = async (models) => {
try {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : selectedApiKey);
const validActiveModel = models.includes(activeModel) ? activeModel : (models[0] || "");
await fetch("/api/cli-tools/opencode-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
models,
activeModel: validActiveModel,
subagentModel,
}),
});
} catch (error) {
console.log("Error saving models:", error);
}
};
const getConfigStatus = () => {
if (!status?.installed) return null;
if (!status.config) return "not_configured";
if (!status.has9Router) return "not_configured";
const url = status.config?.provider?.["9router"]?.options?.baseURL || "";
return matchKnownEndpoint(url, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other";
};
const configStatus = getConfigStatus();
const getEffectiveBaseUrl = () => {
const url = customBaseUrl || baseUrl;
return url.endsWith("/v1") ? url : `${url}/v1`;
};
const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`;
const checkStatus = async () => {
setChecking(true);
try {
const res = await fetch("/api/cli-tools/opencode-settings");
const data = await res.json();
setStatus(data);
} catch (error) {
setStatus({ installed: false, error: error.message });
} finally {
setChecking(false);
}
};
const handleApply = async () => {
setApplying(true);
setMessage(null);
try {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : selectedApiKey);
const res = await fetch("/api/cli-tools/opencode-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
models: selectedModels,
activeModel: activeModel === "" ? "" : (activeModel || selectedModels[0]),
subagentModel: subagentModel
}),
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" });
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setApplying(false);
}
};
const handleReset = async () => {
setRestoring(true);
setMessage(null);
try {
const res = await fetch("/api/cli-tools/opencode-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setSelectedModel("");
setSubagentModel("");
setSelectedModels([]);
setActiveModel("");
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setRestoring(false);
}
};
const getManualConfigs = () => {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const modelsToShow = selectedModels.length > 0 ? selectedModels : ["provider/model-id"];
const activeModelToShow = activeModel || selectedModels[0] || modelsToShow[0];
const effectiveSubagentModel = subagentModel || activeModelToShow;
const modelsObj = {};
modelsToShow.forEach(m => {
modelsObj[m] = { name: m, modalities: { input: ["text", "image"], output: ["text"] } };
});
return [{
filename: "~/.config/opencode/opencode.json",
content: JSON.stringify({
provider: {
"9router": {
npm: "@ai-sdk/openai-compatible",
options: { baseURL: getEffectiveBaseUrl(), apiKey: keyToUse },
models: modelsObj,
},
},
model: `9router/${activeModelToShow}`,
agent: {
explorer: {
description: "Fast explorer subagent for codebase exploration",
mode: "subagent",
model: `9router/${effectiveSubagentModel}`
}
}
}, null, 2),
}];
};
return (
<Card padding="xs" className="overflow-hidden">
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image src="/providers/opencode.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other</span>}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checking && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking OpenCode CLI...</span>
</div>
)}
{!checking && status && !status.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">OpenCode CLI not detected locally</p>
<p className="text-sm text-text-muted">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex items-center gap-2 pl-9">
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30">
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
{showInstallGuide ? "Hide" : "How to Install"}
</Button>
</div>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">
<h4 className="font-medium mb-3">Installation Guide</h4>
<div className="space-y-3 text-sm">
<div>
<p className="text-text-muted mb-1">macOS / Linux:</p>
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">npm install -g opencode-ai</code>
</div>
<p className="text-text-muted">After installation, run <code className="px-1 bg-black/5 dark:bg-white/5 rounded">opencode</code> to verify.</p>
</div>
</div>
)}
</div>
)}
{!checking && status?.installed && (
<>
<div className="flex flex-col gap-2">
{/* Current base URL */}
{/* Endpoint (selector) */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getDisplayUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div>
{/* Current configured */}
{status?.config?.provider?.["9router"]?.options?.baseURL && (
<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">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{status.config.provider["9router"].options.baseURL}
</span>
</div>
)}
{/* API Key */}
<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">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
{/* Models */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Models</span>
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
<div className="flex-1 flex flex-col gap-2">
<div className="flex flex-wrap gap-1.5 min-h-[28px] px-2 py-1.5 bg-surface rounded border border-border">
{selectedModels.length === 0 ? (
<span className="text-xs text-text-muted">No models selected</span>
) : (
selectedModels.map((model) => (
<span
key={model}
onClick={async () => {
if (model === activeModel) {
try {
const res = await fetch("/api/cli-tools/opencode-settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clearActiveModel: true }),
});
if (res.ok) {
setActiveModel("");
checkStatus();
}
} catch (error) {
console.log("Error clearing active model:", error);
}
} else {
setActiveModel(model);
}
}}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs cursor-pointer transition-colors ${
model === activeModel
? "bg-primary/10 text-primary border border-primary"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-transparent hover:border-border"
}`}
title={model === activeModel ? "Click to clear active model" : "Click to set as active"}
>
{model === activeModel && <span className="material-symbols-outlined text-[10px]">star</span>}
{model}
<button
onClick={async (e) => {
e.stopPropagation();
try {
const res = await fetch(`/api/cli-tools/opencode-settings?model=${encodeURIComponent(model)}`, { method: "DELETE" });
if (res.ok) {
const newModels = selectedModels.filter((m) => m !== model);
setSelectedModels(newModels);
if (activeModel === model) {
setActiveModel("");
}
checkStatus();
}
} catch (error) {
console.log("Error removing model:", error);
}
}}
className="ml-0.5 hover:text-red-500"
>
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</span>
))
)}
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`px-2 py-1 rounded border text-xs transition-colors ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Add Model</button>
<span className="text-xs text-text-muted">
{selectedModels.length > 0 && activeModel ? (
<>Active: <span className="text-primary">{activeModel}</span></>
) : selectedModels.length > 0 ? (
<span className="text-yellow-500">Click a model to set/clear active</span>
) : (
"Select models to add"
)}
</span>
</div>
</div>
</div>
{/* Subagent Model */}
<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">Subagent Model</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<input
type="text"
value={subagentModel}
onChange={(e) => setSubagentModel(e.target.value)}
placeholder={selectedModel || "provider/model-id (defaults to main model)"}
className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
/>
<button
onClick={() => setSubagentModalOpen(true)}
disabled={!activeProviders?.length}
className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select Model
</button>
{subagentModel && (
<button
onClick={() => setSubagentModel("")}
className="p-1 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear (will use main model)"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
<Button variant="primary" size="sm" onClick={handleApply} disabled={selectedModels.length === 0} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleReset} disabled={!status.has9Router} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => {
setModalOpen(false);
saveModels(selectedModelsRef.current);
}}
onSelect={(model) => {
if (!selectedModels.includes(model.value)) {
setSelectedModels([...selectedModels, model.value]);
if (!activeModel) setActiveModel(model.value);
}
}}
onDeselect={(model) => {
const remaining = selectedModels.filter(m => m !== model.value);
setSelectedModels(remaining);
if (activeModel === model.value) {
setActiveModel(remaining[0] || "");
}
}}
selectedModel={null}
activeProviders={activeProviders}
modelAliases={modelAliases}
addedModelValues={selectedModels}
closeOnSelect={false}
title="Add Model for OpenCode"
/>
<ModelSelectModal
isOpen={subagentModalOpen}
onClose={() => setSubagentModalOpen(false)}
onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }}
selectedModel={subagentModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Subagent Model for OpenCode"
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="OpenCode - Manual Configuration"
configs={getManualConfigs()}
/>
</Card>
);
}
@@ -4,16 +4,7 @@ import Link from "next/link";
import Image from "next/image";
import { Card } from "@/shared/components";
// Derive simple connected/configured/not-installed status from API payload
function getStatus(status) {
if (!status) return { label: "Unknown", cls: "bg-gray-500/10 text-gray-500" };
if (!status.installed) return { label: "Not installed", cls: "bg-gray-500/10 text-gray-500" };
if (status.has9Router) return { label: "Connected", cls: "bg-green-500/10 text-green-600 dark:text-green-400" };
return { label: "Not configured", cls: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" };
}
export default function ToolSummaryCard({ toolId, tool, status }) {
const s = getStatus(status);
export default function ToolSummaryCard({ toolId, tool }) {
return (
<Link href={`/dashboard/cli-tools/${toolId}`} className="block">
<Card padding="sm" className="h-full overflow-hidden hover:border-primary/50 transition-colors cursor-pointer">
@@ -28,7 +19,7 @@ export default function ToolSummaryCard({ toolId, tool, status }) {
</div>
<div className="min-w-0 flex-1">
<h3 className="font-medium text-sm truncate">{tool.name}</h3>
<span className={`inline-block mt-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full ${s.cls}`}>{s.label}</span>
<p className="mt-1 truncate text-xs text-text-muted">Generate a copyable configuration file</p>
</div>
<span className="material-symbols-outlined text-text-muted text-[18px] shrink-0">chevron_right</span>
</div>
@@ -1,13 +0,0 @@
// Match a configured CLI base URL against all known endpoints (local/tunnel/tailscale/cloud)
const stripTrailingSlash = (s) => (s || "").replace(/\/+$/, "");
export function matchKnownEndpoint(currentUrl, opts = {}) {
if (!currentUrl) return false;
const url = stripTrailingSlash(currentUrl);
const { tunnelPublicUrl, tailscaleUrl, cloudUrl } = opts;
if (/localhost|127\.0\.0\.1|0\.0\.0\.0/.test(url)) return true;
if (tunnelPublicUrl && url.startsWith(stripTrailingSlash(tunnelPublicUrl))) return true;
if (tailscaleUrl && url.startsWith(stripTrailingSlash(tailscaleUrl))) return true;
if (cloudUrl && url.startsWith(stripTrailingSlash(cloudUrl))) return true;
return false;
}
@@ -1,19 +1,8 @@
export { default as ClaudeToolCard } from "./ClaudeToolCard";
export { default as CodexToolCard } from "./CodexToolCard";
export { default as DroidToolCard } from "./DroidToolCard";
export { default as OpenClawToolCard } from "./OpenClawToolCard";
export { default as HermesToolCard } from "./HermesToolCard";
export { default as DefaultToolCard } from "./DefaultToolCard";
export { default as AntigravityToolCard } from "./AntigravityToolCard";
export { default as OpenCodeToolCard } from "./OpenCodeToolCard";
export { default as CoworkToolCard } from "./CoworkToolCard";
export { default as CopilotToolCard } from "./CopilotToolCard";
export { default as ClineToolCard } from "./ClineToolCard";
export { default as KiloToolCard } from "./KiloToolCard";
export { default as DeepSeekTuiToolCard } from "./DeepSeekTuiToolCard";
export { default as JcodeToolCard } from "./JcodeToolCard";
export { default as MitmServerCard } from "./MitmServerCard";
export { default as MitmToolCard } from "./MitmToolCard";
export { default as MitmLinkCard } from "./MitmLinkCard";
export { default as EndpointPresetControl } from "./EndpointPresetControl";
export { default as BaseUrlSelect } from "./BaseUrlSelect";
export { default as ConfigGeneratorCard } from "./ConfigGeneratorCard";