- Added installation guides for manual configuration in DroidToolCard.js and other tool cards to assist users in setting up the necessary CLI tools.
391 lines
18 KiB
JavaScript
391 lines
18 KiB
JavaScript
"use client";
|
|
|
|
import { useState, useEffect, useRef } from "react";
|
|
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
|
import Image from "next/image";
|
|
|
|
export default function OpenClawToolCard({
|
|
tool,
|
|
isExpanded,
|
|
onToggle,
|
|
baseUrl,
|
|
hasActiveProviders,
|
|
apiKeys,
|
|
activeProviders,
|
|
cloudEnabled,
|
|
initialStatus,
|
|
}) {
|
|
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";
|
|
const localMatch = currentProvider.baseUrl?.includes("localhost") || currentProvider.baseUrl?.includes("127.0.0.1") || currentProvider.baseUrl?.includes("0.0.0.0");
|
|
const tunnelMatch = baseUrl && currentProvider.baseUrl?.startsWith(baseUrl);
|
|
if (localMatch || tunnelMatch) return "configured";
|
|
return "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-center justify-between hover:cursor-pointer" onClick={onToggle}>
|
|
<div className="flex 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 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">
|
|
{/* Current Base URL */}
|
|
{openclawStatus?.settings?.models?.providers?.["9router"]?.baseUrl && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Current</span>
|
|
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
|
|
<span className="flex-1 px-2 py-1.5 text-xs text-text-muted truncate">
|
|
{openclawStatus.settings.models.providers["9router"].baseUrl}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Base URL */}
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Base URL</span>
|
|
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
|
|
<input
|
|
type="text"
|
|
value={getDisplayUrl()}
|
|
onChange={(e) => setCustomBaseUrl(e.target.value)}
|
|
placeholder="https://.../v1"
|
|
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
/>
|
|
{customBaseUrl && customBaseUrl !== baseUrl && (
|
|
<button onClick={() => setCustomBaseUrl("")} className="p-1 text-text-muted hover:text-primary rounded transition-colors" title="Reset to default">
|
|
<span className="material-symbols-outlined text-[14px]">restart_alt</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* API Key */}
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">API Key</span>
|
|
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
|
|
{apiKeys.length > 0 ? (
|
|
<select value={selectedApiKey} onChange={(e) => setSelectedApiKey(e.target.value)} className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50">
|
|
{apiKeys.map((key) => <option key={key.id} value={key.key}>{key.key}</option>)}
|
|
</select>
|
|
) : (
|
|
<span className="flex-1 text-xs text-text-muted px-2 py-1.5">
|
|
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Default Model */}
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Default Model</span>
|
|
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
|
|
<input type="text" value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} placeholder="provider/model-id" className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" />
|
|
<button onClick={() => { setAgentModalFor(null); setModalOpen(true); }} disabled={!hasActiveProviders} className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</button>
|
|
{selectedModel && <button onClick={() => setSelectedModel("")} className="p-1 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></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 text-text-muted text-[14px]">arrow_forward</span>
|
|
<input
|
|
type="text"
|
|
value={agentModels[agent.id] || ""}
|
|
onChange={(e) => setAgentModels(prev => ({ ...prev, [agent.id]: e.target.value }))}
|
|
placeholder={`default (${selectedModel || "provider/model-id"})`}
|
|
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
/>
|
|
<button onClick={() => { setAgentModalFor(agent.id); setModalOpen(true); }} disabled={!hasActiveProviders} className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</button>
|
|
{agentModels[agent.id] && <button onClick={() => setAgentModels(prev => ({ ...prev, [agent.id]: "" }))} className="p-1 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><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="flex items-center gap-2">
|
|
<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>
|
|
);
|
|
}
|