feat(cli-tools): configure Grok Build subagent models

Add separate model selectors for Grok Build main, general-purpose,
explore, and plan agents. Each override gets an independent 9Router
custom-model slot and context_window derived from 9Router model
capabilities. Preserve and restore pre-existing config on reset.
This commit is contained in:
rixzkiye
2026-07-19 13:35:38 +07:00
parent 0513bf393f
commit e0ba667450
7 changed files with 679 additions and 337 deletions
@@ -1,7 +1,8 @@
"use client";
import { useState, useEffect, useRef } from "react";
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 ApiKeySelect from "./ApiKeySelect";
@@ -9,12 +10,59 @@ 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,
baseUrl,
hasActiveProviders,
apiKeys,
activeProviders,
@@ -25,47 +73,49 @@ export default function GrokBuildToolCard({
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("");
const [selectedModel, setSelectedModel] = useState("");
const [modalOpen, setModalOpen] = useState(false);
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 hasInitializedModel = useRef(false);
const hasFetchedStatus = useRef(Boolean(initialStatus));
const getConfigStatus = () => {
if (!grokStatus?.installed) return null;
const cfg = grokStatus.settings?.model;
if (!cfg?.base_url) return "not_configured";
if (matchKnownEndpoint(cfg.base_url, { tunnelPublicUrl, tailscaleUrl })) return "configured";
return "other";
};
const configuredModel = grokStatus?.settings?.model;
const configStatus = !grokStatus?.installed
? null
: !configuredModel?.base_url
? "not_configured"
: matchKnownEndpoint(configuredModel.base_url, { tunnelPublicUrl, tailscaleUrl })
? "configured"
: "other";
const configStatus = getConfigStatus();
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);
}, []);
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
}
}, [apiKeys, selectedApiKey]);
useEffect(() => {
if (initialStatus) setGrokStatus(initialStatus);
}, [initialStatus]);
useEffect(() => {
if (isExpanded) {
if (!grokStatus) checkStatus();
fetchModelAliases();
}
}, [isExpanded]);
const fetchModelAliases = async () => {
const fetchModelAliases = useCallback(async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
@@ -73,40 +123,38 @@ export default function GrokBuildToolCard({
} catch (error) {
console.log("Error fetching model aliases:", error);
}
};
}, []);
useEffect(() => {
if (grokStatus?.installed && !hasInitializedModel.current) {
hasInitializedModel.current = true;
const cfg = grokStatus.settings?.model;
if (cfg?.model) setSelectedModel(cfg.model);
}
}, [grokStatus]);
const checkStatus = async () => {
const checkStatus = useCallback(async ({ hydrate = false } = {}) => {
setChecking(true);
try {
const res = await fetch(ENDPOINT);
const data = await res.json();
setGrokStatus(data);
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]);
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";
};
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 || getLocalBaseUrl();
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`;
};
@@ -117,6 +165,11 @@ export default function GrokBuildToolCard({
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",
@@ -125,11 +178,13 @@ export default function GrokBuildToolCard({
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
model: selectedModel,
contextWindow: getContextWindow(selectedModel),
subagentModels: mappedSubagents,
}),
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" });
setMessage({ type: "success", text: "Main and subagent models applied successfully!" });
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
@@ -150,6 +205,7 @@ export default function GrokBuildToolCard({
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setSelectedModel("");
setSubagentModels({});
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
@@ -162,31 +218,33 @@ export default function GrokBuildToolCard({
};
const handleModelSelect = (model) => {
setSelectedModel(model.value);
setModalOpen(false);
if (modelTarget === "main") {
setSelectedModel(model.value);
} else if (modelTarget) {
setSubagentModels((current) => ({ ...current, [modelTarget]: model.value }));
}
setModelTarget(null);
};
const getManualConfigs = () => {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const modelId = selectedModel || "provider/model-id";
const tomlContent = `[models]
default = "${MODEL_SLOT}"
[model.${MODEL_SLOT}]
model = "${modelId}"
base_url = "${getEffectiveBaseUrl()}"
name = "9Router"
description = "Routed via 9Router gateway"
api_backend = "chat_completions"
api_key = "${keyToUse}"
`;
return [
{ filename: "~/.grok/config.toml", content: tomlContent },
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 (
@@ -202,8 +260,8 @@ api_key = "${keyToUse}"
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => { e.target.style.display = "none"; }}
loading="lazy"
decoding="async"
loading="lazy"
decoding="async"
/>
</div>
<div className="min-w-0">
@@ -221,170 +279,105 @@ api_key = "${keyToUse}"
{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 && <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-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">Grok Build not detected locally</p>
<p className="text-sm text-text-muted mt-1">Install:</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>
<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 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 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 && 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>
{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}
/>
<BaseUrlSelect value={customBaseUrl || getEffectiveBaseUrl()} onChange={setCustomBaseUrl} requiresExternalUrl={tool.requiresExternalUrl} tunnelEnabled={tunnelEnabled} tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} />
</div>
{grokStatus?.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">
{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">
{grokStatus.settings.model.base_url}
{grokStatus.settings.model.model ? ` · ${grokStatus.settings.model.model}` : ""}
</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_auto] sm:items-center sm: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">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>
)}
<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>
<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>
{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>
)}
{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>
<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>
)}
{modalOpen && (
{modelTarget && (
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
isOpen={Boolean(modelTarget)}
onClose={() => setModelTarget(null)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
selectedModel={modelTarget === "main" ? selectedModel : subagentModels[modelTarget] || ""}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Grok Build"
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()}
/>
<ManualConfigModal isOpen={showManualConfigModal} onClose={() => setShowManualConfigModal(false)} title="Grok Build - Manual Configuration" configs={getManualConfigs()} />
</Card>
);
}