mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-23 04:09:49 +00:00
fix: update cli tools
This commit is contained in:
@@ -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 "Apply" 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'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";
|
||||
|
||||
Reference in New Issue
Block a user