Files
9router/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js
T
decoluaandClaude Fable 5 a68ada1c83 feat(cli-tools): share endpoint presets across every tool card
Each card kept its own copy of the localStorage preset logic inside
BaseUrlSelect, so an endpoint saved on one card was invisible to the
others until a reload, and a URL typed into the custom field was
forgotten the moment the card collapsed.

Move the store into cliEndpointPresets.js and publish a change event
so open cards resync live. Applying settings now remembers the
endpoint unless it matches a built-in option, and each card passes
its configured URL as currentUrl so BaseUrlSelect can preselect the
matching preset instead of always falling back to 127.0.0.1.
Deleting a preset falls back to the first real option rather than
clearing the field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 18:54:44 +07:00

388 lines
19 KiB
JavaScript

"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import { useModelCaps } from "@/shared/hooks/useModelCaps";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
const ENDPOINT = "/api/cli-tools/grok-build-settings";
const MODEL_SLOT = "9router";
const SUBAGENT_TYPES = [
{ id: "general-purpose", label: "General-purpose", help: "Implementation, testing, and full-capability delegated tasks" },
{ id: "explore", label: "Explore", help: "Read-only codebase research and investigation" },
{ id: "plan", label: "Plan", help: "Architecture and implementation planning" },
];
function ModelField({ label, value, placeholder, onChange, onSelect, disabled, help }) {
return (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<div className="sm:text-right">
<span className="text-xs font-semibold text-text-main sm:text-sm">{label}</span>
{help && <p className="mt-0.5 text-[10px] leading-tight text-text-muted">{help}</p>}
</div>
<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={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
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"
/>
{value && (
<button
type="button"
onClick={() => onChange("")}
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 (inherit main model for subagents)"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
<button
type="button"
onClick={onSelect}
disabled={disabled}
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 ${
!disabled
? "bg-surface border-border text-text-main hover:border-primary cursor-pointer"
: "opacity-50 cursor-not-allowed border-border"
}`}
>
Select
</button>
</div>
);
}
export default function GrokBuildToolCard({
tool,
isExpanded,
onToggle,
hasActiveProviders,
apiKeys,
activeProviders,
cloudEnabled,
initialStatus,
tunnelEnabled,
tunnelPublicUrl,
tailscaleEnabled,
tailscaleUrl,
}) {
const { getCaps } = useModelCaps();
const getContextWindow = (model) => getCaps(model)?.contextWindow || null;
const initialModel = initialStatus?.settings?.model?.model || "";
const initialSubagents = Object.fromEntries(
SUBAGENT_TYPES
.map((type) => [type.id, initialStatus?.settings?.subagentModels?.[type.id]?.model])
.filter(([, model]) => Boolean(model)),
);
const [grokStatus, setGrokStatus] = 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(apiKeys?.[0]?.key || "");
const [selectedModel, setSelectedModel] = useState(initialModel);
const [subagentModels, setSubagentModels] = useState(initialSubagents);
const [modelTarget, setModelTarget] = useState(null); // "main" or subagent type
const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState("");
const hasFetchedStatus = useRef(Boolean(initialStatus));
const configuredModel = grokStatus?.settings?.model;
const currentBaseUrl = configuredModel?.base_url || "";
const configStatus = !grokStatus?.installed
? null
: !configuredModel?.base_url
? "not_configured"
: matchKnownEndpoint(configuredModel.base_url, { tunnelPublicUrl, tailscaleUrl })
? "configured"
: "other";
const hydrateForm = useCallback((status) => {
const mainModel = status?.settings?.model?.model || "";
const configuredSubagents = Object.fromEntries(
SUBAGENT_TYPES
.map((type) => [type.id, status?.settings?.subagentModels?.[type.id]?.model])
.filter(([, model]) => Boolean(model)),
);
setSelectedModel(mainModel);
setSubagentModels(configuredSubagents);
}, []);
const fetchModelAliases = useCallback(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 checkStatus = useCallback(async ({ hydrate = false } = {}) => {
setChecking(true);
try {
const res = await fetch(ENDPOINT);
const status = await res.json();
setGrokStatus(status);
hasFetchedStatus.current = true;
if (hydrate) hydrateForm(status);
} catch (error) {
setGrokStatus({ installed: false, error: error.message });
} finally {
setChecking(false);
}
}, [hydrateForm]);
useEffect(() => {
if (!isExpanded) return;
let cancelled = false;
const synchronize = async () => {
if (!hasFetchedStatus.current) await checkStatus({ hydrate: true });
if (!cancelled) await fetchModelAliases();
};
synchronize();
return () => { cancelled = true; };
}, [isExpanded, checkStatus, fetchModelAliases]);
const getEffectiveBaseUrl = () => {
const url = customBaseUrl || (typeof window !== "undefined"
? window.location.origin.replace("://localhost", "://127.0.0.1")
: "http://127.0.0.1:20128");
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 mappedSubagents = {};
for (const type of SUBAGENT_TYPES) {
const model = subagentModels[type.id]?.trim();
if (model) mappedSubagents[type.id] = { model, contextWindow: getContextWindow(model) };
}
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
model: selectedModel,
contextWindow: getContextWindow(selectedModel),
subagentModels: mappedSubagents,
}),
});
const data = await res.json();
if (res.ok) {
// Remember the endpoint so it stays selectable next time
rememberEndpoint(getEffectiveBaseUrl(), { tunnelPublicUrl, tailscaleUrl });
setMessage({ type: "success", text: "Main and subagent models 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("");
setSubagentModels({});
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) => {
if (modelTarget === "main") {
setSelectedModel(model.value);
} else if (modelTarget) {
setSubagentModels((current) => ({ ...current, [modelTarget]: model.value }));
}
setModelTarget(null);
};
const getManualConfigs = () => {
const keyToUse = selectedApiKey?.trim()
|| (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const baseUrl = getEffectiveBaseUrl();
const mainModel = selectedModel || "provider/model-id";
const blocks = [
`[models]\ndefault = "${MODEL_SLOT}"`,
`[model.${MODEL_SLOT}]\nmodel = "${mainModel}"\nbase_url = "${baseUrl}"\nname = "9Router"\ndescription = "Routed via 9Router gateway"\napi_backend = "chat_completions"\napi_key = "${keyToUse}"\ncontext_window = ${getContextWindow(mainModel) || 200000}`,
];
const mappings = [];
for (const type of SUBAGENT_TYPES) {
const model = subagentModels[type.id]?.trim();
if (!model) continue;
const slot = `${MODEL_SLOT}-${type.id}`;
mappings.push(`${type.id} = "${slot}"`);
blocks.push(`[model.${slot}]\nmodel = "${model}"\nbase_url = "${baseUrl}"\nname = "9Router ${type.id}"\ndescription = "Routed via 9Router gateway"\napi_backend = "chat_completions"\napi_key = "${keyToUse}"\ncontext_window = ${getContextWindow(model) || 200000}`);
}
if (mappings.length) blocks.splice(1, 0, `[subagents.models]\n${mappings.join("\n")}`);
return [{ filename: "~/.grok/config.toml", content: `${blocks.join("\n\n")}\n` }];
};
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/grok-cli.png"}
alt={tool.name}
width={32}
height={32}
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => { e.target.style.display = "none"; }}
loading="lazy"
decoding="async"
/>
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<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 Grok Build...</span></div>}
{!checking && grokStatus && !grokStatus.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">Grok Build not detected locally</p>
<code className="block mt-2 p-2 bg-black/20 rounded text-xs font-mono">curl -fsSL https://x.ai/cli/install.sh | bash</code>
</div>
</div>
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="w-full sm:w-auto"><span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>Manual Config</Button>
</div>
)}
{!checking && grokStatus?.installed && (
<>
<div className="flex flex-col gap-2">
{tool.notes?.length > 0 && (
<div className="mb-2 flex flex-col gap-2">
{tool.notes.map((note, index) => (
<div key={index} className={`flex items-start gap-2 rounded p-2 text-xs ${note.type === "warning" ? "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" : "bg-blue-500/10 text-blue-600 dark:text-blue-400"}`}>
<span className="material-symbols-outlined mt-0.5 text-[14px]">{note.type === "warning" ? "warning" : "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} currentUrl={currentBaseUrl} />
</div>
{configuredModel?.base_url && (
<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">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">{configuredModel.base_url} · {configuredModel.model}{configuredModel.context_window ? ` · ${(configuredModel.context_window / 1000).toLocaleString()}K ctx` : ""}</span>
</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">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>
<ModelField label="Main Model" value={selectedModel} onChange={setSelectedModel} placeholder="provider/model-id" onSelect={() => setModelTarget("main")} disabled={!hasActiveProviders} />
<div className="my-1 border-t border-border pt-3">
<div className="mb-2 flex items-start gap-2">
<span className="material-symbols-outlined text-primary text-[16px]">account_tree</span>
<div>
<p className="text-xs font-semibold text-text-main">Subagent model overrides</p>
<p className="text-[10px] text-text-muted">Leave blank to inherit Main Model. Each override keeps its own context window.</p>
</div>
</div>
</div>
{SUBAGENT_TYPES.map((type) => (
<ModelField
key={type.id}
label={type.label}
help={type.help}
value={subagentModels[type.id] || ""}
onChange={(value) => setSubagentModels((current) => ({ ...current, [type.id]: value }))}
placeholder={`${selectedModel || "Main Model"} (inherit)`}
onSelect={() => setModelTarget(type.id)}
disabled={!hasActiveProviders}
/>
))}
</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={!grokStatus?.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>
)}
{modelTarget && (
<ModelSelectModal
isOpen={Boolean(modelTarget)}
onClose={() => setModelTarget(null)}
onSelect={handleModelSelect}
selectedModel={modelTarget === "main" ? selectedModel : subagentModels[modelTarget] || ""}
activeProviders={activeProviders}
modelAliases={modelAliases}
title={modelTarget === "main" ? "Select Main Model for Grok Build" : `Select ${SUBAGENT_TYPES.find((type) => type.id === modelTarget)?.label || "Subagent"} Model`}
/>
)}
<ManualConfigModal isOpen={showManualConfigModal} onClose={() => setShowManualConfigModal(false)} title="Grok Build - Manual Configuration" configs={getManualConfigs()} />
</Card>
);
}