feat(grokbuild): add first-class Grok Build support (#5453)

* feat(grokbuild): add backend integration

* feat(grokbuild): add provider and management UI

* test(grokbuild): cover configuration and integrations

* fix(grokbuild): address backend review feedback

* fix(grokbuild): complete UI review feedback

* feat(grokbuild): add CLI lifecycle management

* fix(grokbuild): align provider icon fallback

* test(grokbuild): cover provider icon persistence
This commit is contained in:
Thefool
2026-07-17 15:50:50 +08:00
committed by GitHub
parent f6e37ed994
commit 1c0ee0c58a
112 changed files with 4530 additions and 246 deletions
+3
View File
@@ -23,6 +23,7 @@ const ALL_APPS: AppId[] = [
"claude-desktop",
"codex",
"gemini",
"grokbuild",
"opencode",
"openclaw",
"hermes",
@@ -46,6 +47,7 @@ export function AppSwitcher({
"claude-desktop": "claude",
codex: "openai",
gemini: "gemini",
grokbuild: "grok",
opencode: "opencode",
openclaw: "openclaw",
hermes: "hermes",
@@ -55,6 +57,7 @@ export function AppSwitcher({
"claude-desktop": "Claude Desktop",
codex: "Codex",
gemini: "Gemini",
grokbuild: "Grok Build",
opencode: "OpenCode",
openclaw: "OpenClaw",
hermes: "Hermes",
+10
View File
@@ -14,6 +14,7 @@ import {
extractCodexBaseUrl,
extractCodexExperimentalBearerToken,
} from "@/utils/providerConfigUtils";
import { parseGrokBuildConfig } from "@/utils/grokBuildConfig";
import JsonEditor from "./JsonEditor";
import * as prettier from "prettier/standalone";
import * as parserBabel from "prettier/parser-babel";
@@ -262,6 +263,15 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
apiKey: env.GEMINI_API_KEY || env.GOOGLE_API_KEY,
baseUrl: env.GOOGLE_GEMINI_BASE_URL,
};
} else if (appId === "grokbuild") {
const grokConfig = parseGrokBuildConfig(
(config as any).config,
provider.name,
);
return {
apiKey: grokConfig.apiKey,
baseUrl: grokConfig.baseUrl,
};
} else if (appId === "hermes") {
// Hermes: settingsConfig 顶层扁平(snake_case,对应 config.yaml
return {
+23 -2
View File
@@ -42,7 +42,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
onClose,
existingIds = [],
defaultFormat = "json",
defaultEnabledApps = ["claude", "codex", "gemini"],
defaultEnabledApps = ["claude", "codex", "gemini", "grokbuild"],
}) => {
const { t } = useTranslation();
const { formatTomlError, validateTomlConfig, validateJsonConfig } =
@@ -65,17 +65,22 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
claude: boolean;
codex: boolean;
gemini: boolean;
grokbuild: boolean;
opencode: boolean;
openclaw: boolean;
hermes: boolean;
}>(() => {
if (initialData?.apps) {
return { ...initialData.apps };
return {
...initialData.apps,
grokbuild: initialData.apps.grokbuild ?? false,
};
}
return {
claude: defaultEnabledApps.includes("claude"),
codex: defaultEnabledApps.includes("codex"),
gemini: defaultEnabledApps.includes("gemini"),
grokbuild: defaultEnabledApps.includes("grokbuild"),
opencode: defaultEnabledApps.includes("opencode"),
openclaw: defaultEnabledApps.includes("openclaw"),
hermes: defaultEnabledApps.includes("hermes"),
@@ -566,6 +571,22 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="enable-grokbuild"
checked={enabledApps.grokbuild}
onCheckedChange={(checked: boolean) =>
setEnabledApps({ ...enabledApps, grokbuild: checked })
}
/>
<label
htmlFor="enable-grokbuild"
className="text-sm text-foreground cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.grokbuild")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="enable-opencode"
+1
View File
@@ -61,6 +61,7 @@ const UnifiedMcpPanel = React.forwardRef<
"claude-desktop": 0,
codex: 0,
gemini: 0,
grokbuild: 0,
opencode: 0,
openclaw: 0,
hermes: 0,
@@ -35,6 +35,7 @@ const PromptFormModal: React.FC<PromptFormModalProps> = ({
"claude-desktop": "CLAUDE.md",
codex: "AGENTS.md",
gemini: "GEMINI.md",
grokbuild: "AGENTS.md",
opencode: "AGENTS.md",
hermes: "AGENTS.md",
};
@@ -29,6 +29,7 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
"claude-desktop": "CLAUDE.md",
codex: "AGENTS.md",
gemini: "GEMINI.md",
grokbuild: "AGENTS.md",
opencode: "AGENTS.md",
openclaw: "AGENTS.md",
hermes: "AGENTS.md",
@@ -19,6 +19,7 @@ import { codexProviderPresets } from "@/config/codexProviderPresets";
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
import { claudeDesktopProviderPresets } from "@/config/claudeDesktopProviderPresets";
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
import { extractGrokBuildBaseUrl } from "@/utils/grokBuildConfig";
import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
@@ -48,6 +49,7 @@ export function AddProviderDialog({
appId !== "opencode" &&
appId !== "openclaw" &&
appId !== "hermes" &&
appId !== "grokbuild" &&
appId !== "claude-desktop";
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
"app-specific",
@@ -255,6 +257,11 @@ export function AddProviderDialog({
if (env?.GOOGLE_GEMINI_BASE_URL) {
addUrl(env.GOOGLE_GEMINI_BASE_URL);
}
} else if (appId === "grokbuild") {
const config = parsedConfig.config as string | undefined;
if (config) {
addUrl(extractGrokBuildBaseUrl(config));
}
} else if (appId === "opencode") {
const options = parsedConfig.options as
| Record<string, any>
+6 -1
View File
@@ -28,6 +28,7 @@ import {
import { supportsOfficialProxyTakeover } from "@/utils/providerCapabilities";
import { useProviderHealth } from "@/lib/query/failover";
import { useUsageQuery } from "@/lib/query/queries";
import { resolveProviderIcon } from "@/utils/providerIcon";
interface DragHandleProps {
attributes: DraggableAttributes;
@@ -350,7 +351,11 @@ export function ProviderCard({
<div className="h-8 w-8 flex-shrink-0 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
<ProviderIcon
icon={provider.icon}
icon={resolveProviderIcon(
appId,
provider.icon,
provider.iconColor,
)}
name={provider.name}
color={provider.iconColor}
size={20}
@@ -43,12 +43,14 @@ import type {
PromptCacheRoutingMode,
ProviderCategory,
} from "@/types";
import type { AppId } from "@/lib/api";
interface EndpointCandidate {
url: string;
}
interface CodexFormFieldsProps {
appId?: AppId;
providerId?: string;
// API Key
codexApiKey: string;
@@ -155,6 +157,7 @@ function catalogRowsMatchModels(
}
export function CodexFormFields({
appId = "codex",
providerId,
codexApiKey,
onApiKeyChange,
@@ -1007,7 +1010,7 @@ export function CodexFormFields({
{/* 端点测速弹窗 - Codex */}
{shouldShowSpeedTest && isEndpointModalOpen && (
<EndpointSpeedTest
appId="codex"
appId={appId}
providerId={providerId}
value={codexBaseUrl}
onChange={onBaseUrlChange}
@@ -14,6 +14,7 @@ const ENDPOINT_TIMEOUT_SECS: Record<AppId, number> = {
claude: 8,
"claude-desktop": 8,
gemini: 8,
grokbuild: 12,
opencode: 8,
openclaw: 8,
hermes: 8,
@@ -0,0 +1,576 @@
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import JsonEditor from "@/components/JsonEditor";
import { useDarkMode } from "@/hooks/useDarkMode";
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
import {
buildLocalProxyRequestOverrides,
formatRequestOverrideObject,
} from "@/lib/requestOverrides";
import type {
ClaudeApiKeyField,
CodexApiFormat,
CodexChatReasoning,
PromptCacheRoutingMode,
ProviderCategory,
ProviderMeta,
} from "@/types";
import type { ProviderFormProps, ProviderFormValues } from "./ProviderForm";
import { BasicFormFields } from "./BasicFormFields";
import { CodexFormFields } from "./CodexFormFields";
import { ProviderPresetSelector } from "./ProviderPresetSelector";
import {
codexProviderPresets,
type CodexProviderPreset,
} from "@/config/codexProviderPresets";
import {
codexApiFormatFromWireApi,
extractCodexBaseUrl,
extractCodexModelName,
extractCodexWireApi,
} from "@/utils/providerConfigUtils";
import {
buildGrokBuildConfig,
parseGrokBuildConfig,
updateGrokBuildConfig,
validateGrokBuildConfig,
} from "@/utils/grokBuildConfig";
import { resolveProviderIcon } from "@/utils/providerIcon";
type GrokBuildProviderFormProps = Omit<ProviderFormProps, "appId">;
const grokPresetEntries: Array<{
id: string;
preset: CodexProviderPreset;
}> = codexProviderPresets
.map((preset, index) => ({ id: `grokbuild-${index}`, preset }))
.filter(({ preset }) => preset.category !== "official" && !preset.isOfficial);
export const grokApiBackendFromApiFormat = (format: CodexApiFormat): string => {
if (format === "openai_chat") return "chat_completions";
if (format === "anthropic") return "messages";
return "responses";
};
export function GrokBuildProviderForm({
providerId,
submitLabel,
onSubmit,
onCancel,
onSubmittingChange,
initialData,
showButtons = true,
}: GrokBuildProviderFormProps) {
const { t } = useTranslation();
const isDarkMode = useDarkMode();
const initialConfigText =
typeof initialData?.settingsConfig?.config === "string"
? initialData.settingsConfig.config
: undefined;
const initialConfig = useMemo(
() => parseGrokBuildConfig(initialConfigText, initialData?.name),
[initialConfigText, initialData?.name],
);
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(
initialData ? null : "custom",
);
const [category, setCategory] = useState<ProviderCategory | undefined>(
initialData?.category ?? "custom",
);
const [isPartner, setIsPartner] = useState(
initialData?.meta?.isPartner ?? false,
);
const [partnerPromotionKey, setPartnerPromotionKey] = useState<string>();
const [profile, setProfile] = useState(initialConfig.model);
const [upstreamModel, setUpstreamModel] = useState(
initialConfig.upstreamModel ?? initialConfig.model,
);
const [baseUrl, setBaseUrl] = useState(initialConfig.baseUrl);
const [apiKey, setApiKey] = useState(initialConfig.apiKey);
const [apiBackend, setApiBackend] = useState(initialConfig.apiBackend);
const [contextWindow, setContextWindow] = useState(
String(initialConfig.contextWindow),
);
const [rawConfig, setRawConfig] = useState(
initialConfigText ?? buildGrokBuildConfig(initialConfig),
);
const [apiFormat, setApiFormat] = useState<CodexApiFormat>(
(initialData?.meta?.apiFormat as CodexApiFormat | undefined) ??
"openai_responses",
);
const [anthropicAuthField, setAnthropicAuthField] =
useState<ClaudeApiKeyField>(
initialData?.meta?.apiKeyField ?? "ANTHROPIC_AUTH_TOKEN",
);
const [impersonateClaudeCode, setImpersonateClaudeCode] = useState(
initialData?.meta?.impersonateClaudeCode === true,
);
const [maxOutputTokens, setMaxOutputTokens] = useState(
initialData?.meta?.maxOutputTokens
? String(initialData.meta.maxOutputTokens)
: "",
);
const [codexChatReasoning, setCodexChatReasoning] =
useState<CodexChatReasoning>(initialData?.meta?.codexChatReasoning ?? {});
const [promptCacheRouting, setPromptCacheRouting] =
useState<PromptCacheRoutingMode>(
initialData?.meta?.promptCacheRouting ?? "auto",
);
const [isFullUrl, setIsFullUrl] = useState(
initialData?.meta?.isFullUrl ?? false,
);
const [customUserAgent, setCustomUserAgent] = useState(
initialData?.meta?.customUserAgent ?? "",
);
const [headersOverride, setHeadersOverride] = useState(
formatRequestOverrideObject(
initialData?.meta?.localProxyRequestOverrides?.headers,
),
);
const [bodyOverride, setBodyOverride] = useState(
formatRequestOverrideObject(
initialData?.meta?.localProxyRequestOverrides?.body,
),
);
const [endpointAutoSelect, setEndpointAutoSelect] = useState(
initialData?.meta?.endpointAutoSelect ?? true,
);
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
const [presetEndpoints, setPresetEndpoints] = useState<string[]>([]);
const [draftCustomEndpoints, setDraftCustomEndpoints] = useState<string[]>(
[],
);
const form = useForm<ProviderFormData>({
resolver: zodResolver(providerSchema),
defaultValues: {
name: initialData?.name ?? initialConfig.name,
websiteUrl: initialData?.websiteUrl ?? "",
notes: initialData?.notes ?? "",
settingsConfig: JSON.stringify({ config: rawConfig }),
icon:
resolveProviderIcon(
"grokbuild",
initialData?.icon,
initialData?.iconColor,
) ?? "",
iconColor: initialData?.iconColor ?? "",
},
mode: "onSubmit",
});
const { isSubmitting } = form.formState;
const websiteUrl = form.watch("websiteUrl") ?? "";
useEffect(() => {
onSubmittingChange?.(isSubmitting);
}, [isSubmitting, onSubmittingChange]);
const presetCategoryLabels = useMemo(
() => ({
official: t("providerForm.categoryOfficial", { defaultValue: "官方" }),
cn_official: t("providerForm.categoryCnOfficial", {
defaultValue: "国内官方",
}),
aggregator: t("providerForm.categoryAggregation", {
defaultValue: "聚合服务",
}),
third_party: t("providerForm.categoryThirdParty", {
defaultValue: "第三方",
}),
}),
[t],
);
const speedTestEndpoints = useMemo(() => {
const urls = new Set<string>();
const add = (url?: string) => {
const normalized = url?.trim().replace(/\/+$/, "");
if (normalized) urls.add(normalized);
};
add(baseUrl);
presetEndpoints.forEach(add);
draftCustomEndpoints.forEach(add);
return Array.from(urls).map((url) => ({ url }));
}, [baseUrl, draftCustomEndpoints, presetEndpoints]);
const syncStructuredConfig = (
overrides: Partial<ReturnType<typeof parseGrokBuildConfig>>,
) => {
const next = {
model: profile,
upstreamModel,
baseUrl,
name: form.getValues("name") || initialConfig.name,
apiKey,
apiBackend,
contextWindow: Number.parseInt(contextWindow, 10),
...overrides,
};
setRawConfig((current) => updateGrokBuildConfig(current, next));
};
const handlePresetChange = (presetId: string) => {
setSelectedPresetId(presetId);
if (presetId === "custom") {
setCategory("custom");
setIsPartner(false);
setPartnerPromotionKey(undefined);
setPresetEndpoints([]);
return;
}
const entry = grokPresetEntries.find(
(candidate) => candidate.id === presetId,
);
if (!entry) return;
const preset = entry.preset;
const presetName = preset.nameKey ? String(t(preset.nameKey)) : preset.name;
const presetBaseUrl = extractCodexBaseUrl(preset.config) ?? "";
const presetModel = extractCodexModelName(preset.config) ?? profile;
const presetApiFormat =
preset.apiFormat ??
codexApiFormatFromWireApi(extractCodexWireApi(preset.config)) ??
"openai_responses";
const presetApiKey =
"auth" in preset && typeof preset.auth?.OPENAI_API_KEY === "string"
? preset.auth.OPENAI_API_KEY
: "";
const presetApiBackend = grokApiBackendFromApiFormat(presetApiFormat);
form.setValue("name", presetName);
form.setValue("websiteUrl", preset.websiteUrl ?? "");
form.setValue("icon", preset.icon ?? "");
form.setValue("iconColor", preset.iconColor ?? "");
setCategory(preset.category ?? "custom");
setIsPartner(preset.isPartner ?? false);
setPartnerPromotionKey(preset.partnerPromotionKey);
setBaseUrl(presetBaseUrl);
setApiKey(presetApiKey);
setUpstreamModel(presetModel);
setApiFormat(presetApiFormat);
setApiBackend(presetApiBackend);
setPresetEndpoints(preset.endpointCandidates ?? []);
setRawConfig(
buildGrokBuildConfig({
model: profile,
upstreamModel: presetModel,
baseUrl: presetBaseUrl,
name: presetName,
apiKey: presetApiKey,
apiBackend: presetApiBackend,
contextWindow: Number.parseInt(contextWindow, 10),
}),
);
};
const handleRawConfigChange = (value: string) => {
setRawConfig(value);
if (validateGrokBuildConfig(value)) return;
const parsed = parseGrokBuildConfig(value, form.getValues("name"));
setProfile(parsed.model);
setUpstreamModel(parsed.upstreamModel ?? parsed.model);
setBaseUrl(parsed.baseUrl);
setApiKey(parsed.apiKey);
setApiBackend(parsed.apiBackend);
setContextWindow(String(parsed.contextWindow));
if (parsed.name) form.setValue("name", parsed.name);
};
const handleSubmit = async (values: ProviderFormData) => {
const name = values.name.trim();
const parsedContextWindow = Number.parseInt(contextWindow, 10);
const envKey = parseGrokBuildConfig(rawConfig).envKey?.trim();
if (
!name ||
!baseUrl.trim() ||
(!apiKey.trim() && !envKey) ||
!profile.trim()
) {
toast.error(
t("providerForm.requiredFields", {
defaultValue: "请填写供应商名称、API 地址、API Key 和模型",
}),
);
return;
}
if (!Number.isInteger(parsedContextWindow) || parsedContextWindow <= 0) {
toast.error(
t("grokBuild.contextWindowInvalid", {
defaultValue: "上下文窗口必须是正整数",
}),
);
return;
}
const finalConfig = updateGrokBuildConfig(rawConfig, {
model: profile,
upstreamModel,
baseUrl,
name,
apiKey,
apiBackend,
contextWindow: parsedContextWindow,
});
const configError = validateGrokBuildConfig(finalConfig);
if (configError) {
toast.error(
t("grokBuild.invalidToml", {
error: configError,
defaultValue: `config.toml 格式错误: ${configError}`,
}),
);
return;
}
const requestOverrides = buildLocalProxyRequestOverrides(
headersOverride,
bodyOverride,
);
if (requestOverrides.error) {
toast.error(requestOverrides.error);
return;
}
const customEndpoints = Object.fromEntries(
draftCustomEndpoints.map((url) => [
url,
{ url, addedAt: Date.now(), lastUsed: undefined },
]),
);
const parsedMaxOutputTokens = Number.parseInt(maxOutputTokens, 10);
const initialMeta = { ...(initialData?.meta ?? {}) };
delete initialMeta.custom_endpoints;
const meta: ProviderMeta = {
...initialMeta,
apiFormat,
apiKeyField: anthropicAuthField,
isFullUrl,
endpointAutoSelect,
isPartner,
partnerPromotionKey,
impersonateClaudeCode,
promptCacheRouting,
codexChatReasoning,
customUserAgent: customUserAgent.trim() || undefined,
localProxyRequestOverrides: requestOverrides.overrides,
maxOutputTokens:
Number.isInteger(parsedMaxOutputTokens) && parsedMaxOutputTokens > 0
? parsedMaxOutputTokens
: undefined,
};
if (!providerId && Object.keys(customEndpoints).length > 0) {
meta.custom_endpoints = customEndpoints;
}
const payload: ProviderFormValues = {
...values,
name,
websiteUrl: values.websiteUrl?.trim() ?? "",
notes: values.notes?.trim() ?? "",
settingsConfig: JSON.stringify({ config: finalConfig }),
presetId: selectedPresetId ?? undefined,
presetCategory: category ?? "custom",
isPartner,
meta,
};
await onSubmit(payload);
};
const rawConfigError = validateGrokBuildConfig(rawConfig);
return (
<Form {...form}>
<form
id="provider-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-6"
>
{!initialData && (
<ProviderPresetSelector
selectedPresetId={selectedPresetId}
presetEntries={grokPresetEntries}
presetCategoryLabels={presetCategoryLabels}
onPresetChange={handlePresetChange}
category={category}
/>
)}
<BasicFormFields form={form} />
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<FormItem>
<FormLabel htmlFor="grokbuild-profile">
{t("grokBuild.profile", { defaultValue: "客户端模型档位" })}
</FormLabel>
<Input
id="grokbuild-profile"
value={profile}
onChange={(event) => {
const value = event.target.value;
setProfile(value);
syncStructuredConfig({ model: value });
}}
placeholder="grok-4.5"
autoComplete="off"
/>
</FormItem>
<FormItem>
<FormLabel htmlFor="grokbuild-api-backend">
{t("grokBuild.apiBackend", { defaultValue: "API Backend" })}
</FormLabel>
<Input
id="grokbuild-api-backend"
value={apiBackend}
onChange={(event) => {
const value = event.target.value;
setApiBackend(value);
syncStructuredConfig({ apiBackend: value });
}}
placeholder="responses"
autoComplete="off"
/>
</FormItem>
<FormItem>
<FormLabel htmlFor="grokbuild-context-window">
{t("grokBuild.contextWindow", { defaultValue: "上下文窗口" })}
</FormLabel>
<Input
id="grokbuild-context-window"
type="number"
min={1}
step={1}
value={contextWindow}
onChange={(event) => {
const value = event.target.value;
setContextWindow(value);
syncStructuredConfig({
contextWindow: Number.parseInt(value, 10),
});
}}
/>
</FormItem>
</div>
<CodexFormFields
appId="grokbuild"
providerId={providerId}
codexApiKey={apiKey}
onApiKeyChange={(value) => {
setApiKey(value);
syncStructuredConfig({ apiKey: value });
}}
category={category}
shouldShowApiKeyLink={Boolean(websiteUrl)}
websiteUrl={websiteUrl}
isPartner={isPartner}
partnerPromotionKey={partnerPromotionKey}
shouldShowSpeedTest
codexBaseUrl={baseUrl}
onBaseUrlChange={(value) => {
setBaseUrl(value);
syncStructuredConfig({ baseUrl: value });
}}
isFullUrl={isFullUrl}
onFullUrlChange={setIsFullUrl}
isEndpointModalOpen={isEndpointModalOpen}
onEndpointModalToggle={setIsEndpointModalOpen}
onCustomEndpointsChange={setDraftCustomEndpoints}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
codexModel={upstreamModel}
onModelChange={(value) => {
setUpstreamModel(value);
syncStructuredConfig({ upstreamModel: value });
}}
apiFormat={apiFormat}
onApiFormatChange={(value) => {
const backend = grokApiBackendFromApiFormat(value);
setApiFormat(value);
setApiBackend(backend);
syncStructuredConfig({ apiBackend: backend });
}}
anthropicAuthField={anthropicAuthField}
onAnthropicAuthFieldChange={setAnthropicAuthField}
impersonateClaudeCode={impersonateClaudeCode}
onImpersonateClaudeCodeChange={setImpersonateClaudeCode}
maxOutputTokens={maxOutputTokens}
onMaxOutputTokensChange={setMaxOutputTokens}
codexChatReasoning={codexChatReasoning}
onCodexChatReasoningChange={setCodexChatReasoning}
promptCacheRouting={promptCacheRouting}
onPromptCacheRoutingChange={setPromptCacheRouting}
speedTestEndpoints={speedTestEndpoints}
customUserAgent={customUserAgent}
onCustomUserAgentChange={setCustomUserAgent}
localProxyHeadersOverride={headersOverride}
onLocalProxyHeadersOverrideChange={setHeadersOverride}
localProxyBodyOverride={bodyOverride}
onLocalProxyBodyOverrideChange={setBodyOverride}
/>
<div className="space-y-2">
<FormLabel htmlFor="grokbuild-config-toml">
{t("grokBuild.rawConfig", { defaultValue: "config.toml" })}
</FormLabel>
<JsonEditor
value={rawConfig}
onChange={handleRawConfigChange}
placeholder=""
darkMode={isDarkMode}
rows={12}
showValidation={false}
language="javascript"
/>
{rawConfigError && (
<p className="text-xs text-destructive">
{t("grokBuild.invalidToml", {
error: rawConfigError,
defaultValue: `Invalid config.toml: ${rawConfigError}`,
})}
</p>
)}
</div>
<FormField
control={form.control}
name="settingsConfig"
render={() => (
<FormItem className="hidden">
<FormControl>
<Input type="hidden" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{showButtons && (
<div className="flex justify-end gap-2">
<Button variant="outline" type="button" onClick={onCancel}>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={isSubmitting}>
{submitLabel}
</Button>
</div>
)}
</form>
</Form>
);
}
@@ -77,6 +77,7 @@ import { ProviderPresetSelector } from "./ProviderPresetSelector";
import { BasicFormFields } from "./BasicFormFields";
import { ClaudeFormFields } from "./ClaudeFormFields";
import { ClaudeDesktopProviderForm } from "./ClaudeDesktopProviderForm";
import { GrokBuildProviderForm } from "./GrokBuildProviderForm";
import { CodexFormFields } from "./CodexFormFields";
import { GeminiFormFields } from "./GeminiFormFields";
import { OmoFormFields } from "./OmoFormFields";
@@ -243,6 +244,9 @@ export function ProviderForm(props: ProviderFormProps) {
if (props.appId === "claude-desktop") {
return <ClaudeDesktopProviderForm {...props} />;
}
if (props.appId === "grokbuild") {
return <GrokBuildProviderForm {...props} />;
}
return <ProviderFormFull {...props} />;
}
@@ -25,7 +25,10 @@ export function ClaudeDesktopRouteToggle({
const isBusy = isStarting || isStoppingServer;
const otherTakeoverActive = Boolean(
takeoverStatus?.claude || takeoverStatus?.codex || takeoverStatus?.gemini,
takeoverStatus?.claude ||
takeoverStatus?.codex ||
takeoverStatus?.gemini ||
takeoverStatus?.grokbuild,
);
const routeAddress = status?.address ?? "127.0.0.1";
const routePort = status?.port ?? 15721;
+3 -1
View File
@@ -38,7 +38,9 @@ export function FailoverToggle({ className, activeApp }: FailoverToggleProps) {
? "Claude"
: activeApp === "codex"
? "Codex"
: "Gemini";
: activeApp === "grokbuild"
? "Grok Build"
: "Gemini";
const tooltipText = !takeoverEnabled
? t("failover.tooltip.takeoverRequired", {
+41 -25
View File
@@ -72,6 +72,7 @@ export function ProxyPanel({
const { data: claudeQueue = [] } = useFailoverQueue("claude");
const { data: codexQueue = [] } = useFailoverQueue("codex");
const { data: geminiQueue = [] } = useFailoverQueue("gemini");
const { data: grokQueue = [] } = useFailoverQueue("grokbuild");
const handleTakeoverChange = async (appType: string, enabled: boolean) => {
try {
@@ -272,30 +273,32 @@ export function ProxyPanel({
defaultValue: "应用接管",
})}
</p>
<div className="grid gap-2 sm:grid-cols-3">
{(["claude", "codex", "gemini"] as const).map((appType) => {
const isEnabled =
takeoverStatus?.[
appType as keyof typeof takeoverStatus
] ?? false;
return (
<div
key={appType}
className="flex items-center justify-between rounded-md border border-primary/20 bg-background/60 px-3 py-2"
>
<span className="text-sm font-medium capitalize">
{appType}
</span>
<Switch
checked={isEnabled}
onCheckedChange={(checked) =>
handleTakeoverChange(appType, checked)
}
disabled={setTakeoverForApp.isPending}
/>
</div>
);
})}
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
{(["claude", "codex", "gemini", "grokbuild"] as const).map(
(appType) => {
const isEnabled =
takeoverStatus?.[
appType as keyof typeof takeoverStatus
] ?? false;
return (
<div
key={appType}
className="flex items-center justify-between rounded-md border border-primary/20 bg-background/60 px-3 py-2"
>
<span className="text-sm font-medium capitalize">
{appType === "grokbuild" ? "Grok Build" : appType}
</span>
<Switch
checked={isEnabled}
onCheckedChange={(checked) =>
handleTakeoverChange(appType, checked)
}
disabled={setTakeoverForApp.isPending}
/>
</div>
);
},
)}
</div>
<p className="text-xs text-muted-foreground">
{t("proxy.takeover.hint", {
@@ -415,7 +418,8 @@ export function ProxyPanel({
{/* [6] Provider queues */}
{(claudeQueue.length > 0 ||
codexQueue.length > 0 ||
geminiQueue.length > 0) && (
geminiQueue.length > 0 ||
grokQueue.length > 0) && (
<div className="pt-3 border-t border-border space-y-3">
<div className="flex items-center gap-2">
<ListOrdered className="h-3.5 w-3.5 text-muted-foreground" />
@@ -459,6 +463,18 @@ export function ProxyPanel({
status={status}
/>
)}
{grokQueue.length > 0 && (
<ProviderQueueGroup
appType="grokbuild"
appLabel="Grok Build"
targets={grokQueue.map((item) => ({
id: item.providerId,
name: item.providerName,
}))}
status={status}
/>
)}
</div>
)}
</div>
+3 -1
View File
@@ -39,7 +39,9 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
? "Codex"
: activeApp === "gemini"
? "Gemini"
: "OpenCode";
: activeApp === "grokbuild"
? "Grok Build"
: "OpenCode";
const tooltipText = takeoverEnabled
? isRunning
@@ -83,6 +83,7 @@ const SESSION_GROUP_EXPANSION_STORAGE_KEY =
type ProviderFilter =
| "all"
| "codex"
| "grokbuild"
| "claude"
| "opencode"
| "openclaw"
@@ -1077,6 +1078,16 @@ export function SessionManagerPage({ appId }: { appId: string }) {
<span>Codex</span>
</div>
</SelectItem>
<SelectItem value="grokbuild">
<div className="flex items-center gap-2">
<ProviderIcon
icon="grok"
name="grokbuild"
size={14}
/>
<span>Grok Build</span>
</div>
</SelectItem>
<SelectItem value="claude">
<div className="flex items-center gap-2">
<ProviderIcon
+1
View File
@@ -121,6 +121,7 @@ export const getProviderLabel = (
// 根据 providerId 获取对应的图标名称
export const getProviderIconName = (providerId: string) => {
if (providerId === "codex") return "openai";
if (providerId === "grokbuild") return "grok";
if (providerId === "claude") return "claude";
if (providerId === "opencode") return "opencode";
if (providerId === "openclaw") return "openclaw";
+7
View File
@@ -63,6 +63,7 @@ const TOOL_NAMES = [
"claude",
"codex",
"gemini",
"grok",
"opencode",
"openclaw",
"hermes",
@@ -130,6 +131,8 @@ ${posixScriptInstallCommand("https://claude.ai/install.sh")} || npm i -g @anthro
npm i -g @openai/codex@latest
# Gemini CLI
npm i -g @google/gemini-cli@latest
# Grok Build
npm i -g @xai-official/grok@latest
# OpenCode
${posixScriptInstallCommand("https://opencode.ai/install")} || npm i -g opencode-ai@latest
# OpenClaw
@@ -143,6 +146,8 @@ npm i -g @anthropic-ai/claude-code@latest
npm i -g @openai/codex@latest
# Gemini CLI
npm i -g @google/gemini-cli@latest
# Grok Build
npm i -g @xai-official/grok@latest
# OpenCode
npm i -g opencode-ai@latest
# OpenClaw
@@ -158,6 +163,7 @@ const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
claude: "Claude Code",
codex: "Codex",
gemini: "Gemini CLI",
grok: "Grok Build",
opencode: "OpenCode",
openclaw: "OpenClaw",
hermes: "Hermes",
@@ -173,6 +179,7 @@ const TOOL_APP_IDS: Record<ToolName, AppId> = {
claude: "claude",
codex: "codex",
gemini: "gemini",
grok: "grokbuild",
opencode: "opencode",
openclaw: "openclaw",
hermes: "hermes",
@@ -26,6 +26,7 @@ const APP_CONFIG: Array<{
},
{ id: "codex", icon: "openai", nameKey: "apps.codex" },
{ id: "gemini", icon: "gemini", nameKey: "apps.gemini" },
{ id: "grokbuild", icon: "grok", nameKey: "apps.grokbuild" },
{ id: "opencode", icon: "opencode", nameKey: "apps.opencode" },
{ id: "openclaw", icon: "openclaw", nameKey: "apps.openclaw" },
{ id: "hermes", icon: "hermes", nameKey: "apps.hermes" },
@@ -42,6 +43,7 @@ export function AppVisibilitySettings({
"claude-desktop": true,
codex: true,
gemini: true,
grokbuild: true,
opencode: true,
openclaw: true,
hermes: true,
@@ -73,7 +75,7 @@ export function AppVisibilitySettings({
{t("settings.appVisibility.description")}
</p>
</header>
<div className="inline-flex gap-1 rounded-md border border-border-default bg-background p-1">
<div className="flex flex-wrap gap-1 rounded-md border border-border-default bg-background p-1">
{APP_CONFIG.map((app) => {
const isVisible = visibleApps[app.id];
// Disable button if this is the last visible app
@@ -17,6 +17,7 @@ interface DirectorySettingsProps {
claudeDir?: string;
codexDir?: string;
geminiDir?: string;
grokDir?: string;
opencodeDir?: string;
openclawDir?: string;
hermesDir?: string;
@@ -34,6 +35,7 @@ export function DirectorySettings({
claudeDir,
codexDir,
geminiDir,
grokDir,
opencodeDir,
openclawDir,
hermesDir,
@@ -126,6 +128,17 @@ export function DirectorySettings({
onReset={() => onResetDirectory("gemini")}
/>
<DirectoryInput
label={t("settings.grokConfigDir")}
description={undefined}
value={grokDir}
resolvedValue={resolvedDirs.grokbuild}
placeholder={t("settings.browsePlaceholderGrok")}
onChange={(val) => onDirectoryChange("grokbuild", val)}
onBrowse={() => onBrowseDirectory("grokbuild")}
onReset={() => onResetDirectory("grokbuild")}
/>
<DirectoryInput
label={t("settings.opencodeConfigDir")}
description={undefined}
+1
View File
@@ -352,6 +352,7 @@ export function SettingsPage({
claudeDir={settings.claudeConfigDir}
codexDir={settings.codexConfigDir}
geminiDir={settings.geminiConfigDir}
grokDir={settings.grokConfigDir}
opencodeDir={settings.opencodeConfigDir}
openclawDir={settings.openclawConfigDir}
hermesDir={settings.hermesConfigDir}
@@ -119,6 +119,7 @@ const UnifiedSkillsPanel = React.forwardRef<
"claude-desktop": 0,
codex: 0,
gemini: 0,
grokbuild: 0,
opencode: 0,
openclaw: 0,
hermes: 0,
@@ -746,6 +747,7 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
claude: skill.foundIn.includes("claude"),
codex: skill.foundIn.includes("codex"),
gemini: skill.foundIn.includes("gemini"),
grokbuild: skill.foundIn.includes("grokbuild"),
opencode: skill.foundIn.includes("opencode"),
openclaw: false,
hermes: skill.foundIn.includes("hermes"),
@@ -772,6 +774,7 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
openclaw: false,
hermes: false,
@@ -815,6 +818,7 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
openclaw: false,
hermes: false,
@@ -828,6 +832,7 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
openclaw: false,
hermes: false,
+3 -1
View File
@@ -33,7 +33,7 @@ import { Plus, Pencil, Trash2, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { proxyApi } from "@/lib/api/proxy";
const PRICING_APPS = ["claude", "codex", "gemini"] as const;
const PRICING_APPS = ["claude", "codex", "gemini", "grokbuild"] as const;
type PricingApp = (typeof PRICING_APPS)[number];
type PricingModelSource = "request" | "response";
@@ -57,6 +57,7 @@ export function PricingConfigPanel() {
claude: { multiplier: "1", source: "response" },
codex: { multiplier: "1", source: "response" },
gemini: { multiplier: "1", source: "response" },
grokbuild: { multiplier: "1", source: "response" },
});
const [originalConfigs, setOriginalConfigs] = useState<AppConfigState | null>(
null,
@@ -102,6 +103,7 @@ export function PricingConfigPanel() {
claude: { multiplier: "1", source: "response" },
codex: { multiplier: "1", source: "response" },
gemini: { multiplier: "1", source: "response" },
grokbuild: { multiplier: "1", source: "response" },
};
for (const result of results) {
newState[result.app] = {
+1
View File
@@ -63,6 +63,7 @@ const APP_FILTER_ICON: Record<AppType, string> = {
claude: "claude",
codex: "openai",
gemini: "gemini",
grokbuild: "grok",
opencode: "opencode",
};
+4
View File
@@ -61,6 +61,10 @@ const TITLE_THEMES: Record<AppType | "all", TitleTheme> = {
accent: "text-sky-600 dark:text-sky-400",
iconBg: "bg-sky-500/10",
},
grokbuild: {
accent: "text-rose-600 dark:text-rose-400",
iconBg: "bg-rose-500/10",
},
opencode: {
accent: "text-purple-600 dark:text-purple-400",
iconBg: "bg-purple-500/10",