mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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:
+8
-1
@@ -127,6 +127,7 @@ const VALID_APPS: AppId[] = [
|
||||
"claude-desktop",
|
||||
"codex",
|
||||
"gemini",
|
||||
"grokbuild",
|
||||
"opencode",
|
||||
"openclaw",
|
||||
"hermes",
|
||||
@@ -194,6 +195,7 @@ function App() {
|
||||
"claude-desktop": true,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
grokbuild: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
hermes: true,
|
||||
@@ -204,6 +206,7 @@ function App() {
|
||||
if (visibleApps["claude-desktop"]) return "claude-desktop";
|
||||
if (visibleApps.codex) return "codex";
|
||||
if (visibleApps.gemini) return "gemini";
|
||||
if (visibleApps.grokbuild) return "grokbuild";
|
||||
if (visibleApps.opencode) return "opencode";
|
||||
if (visibleApps.openclaw) return "openclaw";
|
||||
if (visibleApps.hermes) return "hermes";
|
||||
@@ -222,6 +225,7 @@ function App() {
|
||||
currentView === "sessions" &&
|
||||
sharedFeatureApp !== "claude" &&
|
||||
sharedFeatureApp !== "codex" &&
|
||||
sharedFeatureApp !== "grokbuild" &&
|
||||
sharedFeatureApp !== "opencode" &&
|
||||
sharedFeatureApp !== "openclaw" &&
|
||||
sharedFeatureApp !== "gemini" &&
|
||||
@@ -291,6 +295,7 @@ function App() {
|
||||
const hasSessionSupport =
|
||||
sharedFeatureApp === "claude" ||
|
||||
sharedFeatureApp === "codex" ||
|
||||
sharedFeatureApp === "grokbuild" ||
|
||||
sharedFeatureApp === "opencode" ||
|
||||
sharedFeatureApp === "openclaw" ||
|
||||
sharedFeatureApp === "gemini" ||
|
||||
@@ -1405,7 +1410,9 @@ function App() {
|
||||
? "openclaw"
|
||||
: activeApp === "hermes"
|
||||
? "hermes"
|
||||
: "default"
|
||||
: activeApp === "grokbuild"
|
||||
? "grokbuild"
|
||||
: "default"
|
||||
}
|
||||
className="flex items-center gap-1"
|
||||
initial={{ opacity: 0 }}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -63,6 +63,7 @@ const APP_FILTER_ICON: Record<AppType, string> = {
|
||||
claude: "claude",
|
||||
codex: "openai",
|
||||
gemini: "gemini",
|
||||
grokbuild: "grok",
|
||||
opencode: "opencode",
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -20,6 +20,7 @@ export const APP_IDS: AppId[] = [
|
||||
"claude-desktop",
|
||||
"codex",
|
||||
"gemini",
|
||||
"grokbuild",
|
||||
"opencode",
|
||||
"openclaw",
|
||||
"hermes",
|
||||
@@ -30,6 +31,7 @@ export const SKILLS_APP_IDS: AppId[] = [
|
||||
"claude",
|
||||
"codex",
|
||||
"gemini",
|
||||
"grokbuild",
|
||||
"opencode",
|
||||
"hermes",
|
||||
];
|
||||
@@ -70,6 +72,21 @@ export const APP_ICON_MAP: Record<AppId, AppConfig> = {
|
||||
badgeClass:
|
||||
"bg-blue-500/10 text-blue-700 dark:text-blue-300 hover:bg-blue-500/20 border-0 gap-1.5",
|
||||
},
|
||||
grokbuild: {
|
||||
label: "Grok Build",
|
||||
icon: (
|
||||
<ProviderIcon
|
||||
icon="grok"
|
||||
name="Grok Build"
|
||||
size={14}
|
||||
showFallback={false}
|
||||
/>
|
||||
),
|
||||
activeClass:
|
||||
"bg-cyan-500/10 ring-1 ring-cyan-500/20 hover:bg-cyan-500/20 text-cyan-700 dark:text-cyan-300",
|
||||
badgeClass:
|
||||
"bg-cyan-500/10 text-cyan-700 dark:text-cyan-300 hover:bg-cyan-500/20 border-0 gap-1.5",
|
||||
},
|
||||
opencode: {
|
||||
label: "OpenCode",
|
||||
icon: (
|
||||
|
||||
@@ -10,6 +10,7 @@ type AppDirectoryKey =
|
||||
| "claude"
|
||||
| "codex"
|
||||
| "gemini"
|
||||
| "grokbuild"
|
||||
| "opencode"
|
||||
| "openclaw"
|
||||
| "hermes";
|
||||
@@ -20,6 +21,7 @@ export interface ResolvedDirectories {
|
||||
claude: string;
|
||||
codex: string;
|
||||
gemini: string;
|
||||
grokbuild: string;
|
||||
opencode: string;
|
||||
openclaw: string;
|
||||
hermes: string;
|
||||
@@ -33,6 +35,7 @@ const APP_DIRECTORY_META: Record<
|
||||
claude: { key: "claude", defaultFolder: ".claude" },
|
||||
codex: { key: "codex", defaultFolder: ".codex" },
|
||||
gemini: { key: "gemini", defaultFolder: ".gemini" },
|
||||
grokbuild: { key: "grokbuild", defaultFolder: ".grok" },
|
||||
opencode: { key: "opencode", defaultFolder: ".config/opencode" },
|
||||
openclaw: { key: "openclaw", defaultFolder: ".openclaw" },
|
||||
hermes: { key: "hermes", defaultFolder: ".hermes" },
|
||||
@@ -45,6 +48,7 @@ const DIRECTORY_KEY_TO_SETTINGS_FIELD: Record<
|
||||
claude: "claudeConfigDir",
|
||||
codex: "codexConfigDir",
|
||||
gemini: "geminiConfigDir",
|
||||
grokbuild: "grokConfigDir",
|
||||
opencode: "opencodeConfigDir",
|
||||
openclaw: "openclawConfigDir",
|
||||
hermes: "hermesConfigDir",
|
||||
@@ -130,6 +134,7 @@ export function useDirectorySettings({
|
||||
claude: "",
|
||||
codex: "",
|
||||
gemini: "",
|
||||
grokbuild: "",
|
||||
opencode: "",
|
||||
openclaw: "",
|
||||
hermes: "",
|
||||
@@ -141,6 +146,7 @@ export function useDirectorySettings({
|
||||
claude: "",
|
||||
codex: "",
|
||||
gemini: "",
|
||||
grokbuild: "",
|
||||
opencode: "",
|
||||
openclaw: "",
|
||||
hermes: "",
|
||||
@@ -159,6 +165,7 @@ export function useDirectorySettings({
|
||||
claudeDir,
|
||||
codexDir,
|
||||
geminiDir,
|
||||
grokDir,
|
||||
opencodeDir,
|
||||
openclawDir,
|
||||
hermesDir,
|
||||
@@ -166,6 +173,7 @@ export function useDirectorySettings({
|
||||
defaultClaudeDir,
|
||||
defaultCodexDir,
|
||||
defaultGeminiDir,
|
||||
defaultGrokDir,
|
||||
defaultOpencodeDir,
|
||||
defaultOpenclawDir,
|
||||
defaultHermesDir,
|
||||
@@ -174,6 +182,7 @@ export function useDirectorySettings({
|
||||
settingsApi.getConfigDir("claude"),
|
||||
settingsApi.getConfigDir("codex"),
|
||||
settingsApi.getConfigDir("gemini"),
|
||||
settingsApi.getConfigDir("grokbuild"),
|
||||
settingsApi.getConfigDir("opencode"),
|
||||
settingsApi.getConfigDir("openclaw"),
|
||||
settingsApi.getConfigDir("hermes"),
|
||||
@@ -181,6 +190,7 @@ export function useDirectorySettings({
|
||||
computeDefaultConfigDir("claude"),
|
||||
computeDefaultConfigDir("codex"),
|
||||
computeDefaultConfigDir("gemini"),
|
||||
computeDefaultConfigDir("grokbuild"),
|
||||
computeDefaultConfigDir("opencode"),
|
||||
computeDefaultConfigDir("openclaw"),
|
||||
computeDefaultConfigDir("hermes"),
|
||||
@@ -195,6 +205,7 @@ export function useDirectorySettings({
|
||||
claude: defaultClaudeDir ?? "",
|
||||
codex: defaultCodexDir ?? "",
|
||||
gemini: defaultGeminiDir ?? "",
|
||||
grokbuild: defaultGrokDir ?? "",
|
||||
opencode: defaultOpencodeDir ?? "",
|
||||
openclaw: defaultOpenclawDir ?? "",
|
||||
hermes: defaultHermesDir ?? "",
|
||||
@@ -208,6 +219,7 @@ export function useDirectorySettings({
|
||||
claude: claudeDir || defaultsRef.current.claude,
|
||||
codex: codexDir || defaultsRef.current.codex,
|
||||
gemini: geminiDir || defaultsRef.current.gemini,
|
||||
grokbuild: grokDir || defaultsRef.current.grokbuild,
|
||||
opencode: opencodeDir || defaultsRef.current.opencode,
|
||||
openclaw: openclawDir || defaultsRef.current.openclaw,
|
||||
hermes: hermesDir || defaultsRef.current.hermes,
|
||||
@@ -349,6 +361,7 @@ export function useDirectorySettings({
|
||||
claude: overrides?.claude ?? defaultsRef.current.claude,
|
||||
codex: overrides?.codex ?? defaultsRef.current.codex,
|
||||
gemini: overrides?.gemini ?? defaultsRef.current.gemini,
|
||||
grokbuild: overrides?.grokbuild ?? defaultsRef.current.grokbuild,
|
||||
opencode: overrides?.opencode ?? defaultsRef.current.opencode,
|
||||
openclaw: overrides?.openclaw ?? defaultsRef.current.openclaw,
|
||||
hermes: overrides?.hermes ?? defaultsRef.current.hermes,
|
||||
|
||||
@@ -159,7 +159,7 @@ export function useProviderActions(
|
||||
activeApp === "claude" &&
|
||||
provider.meta?.providerType === "github_copilot";
|
||||
const isCodexChatFormat =
|
||||
activeApp === "codex" &&
|
||||
(activeApp === "codex" || activeApp === "grokbuild") &&
|
||||
(provider.meta?.apiFormat === "openai_chat" ||
|
||||
(typeof (provider.settingsConfig as Record<string, any>)?.config ===
|
||||
"string" &&
|
||||
@@ -169,7 +169,7 @@ export function useProviderActions(
|
||||
),
|
||||
)));
|
||||
const isCodexAnthropicFormat =
|
||||
activeApp === "codex" &&
|
||||
(activeApp === "codex" || activeApp === "grokbuild") &&
|
||||
(provider.meta?.apiFormat === "anthropic" ||
|
||||
(typeof (provider.settingsConfig as Record<string, any>)?.config ===
|
||||
"string" &&
|
||||
@@ -220,7 +220,9 @@ export function useProviderActions(
|
||||
});
|
||||
} else if (
|
||||
provider.meta?.isFullUrl &&
|
||||
(activeApp === "claude" || activeApp === "codex")
|
||||
(activeApp === "claude" ||
|
||||
activeApp === "codex" ||
|
||||
activeApp === "grokbuild")
|
||||
) {
|
||||
proxyRequiredReason = t("notifications.proxyReasonFullUrl", {
|
||||
defaultValue: "开启了完整 URL 连接模式",
|
||||
@@ -281,6 +283,9 @@ export function useProviderActions(
|
||||
if (activeApp === "codex") {
|
||||
messageKey = "notifications.codexRestartRequired";
|
||||
defaultMessage = "切换成功,请重启客户端以生效";
|
||||
} else if (activeApp === "grokbuild") {
|
||||
messageKey = "notifications.grokBuildRestartRequired";
|
||||
defaultMessage = "切换成功,请重启 Grok Build 以生效";
|
||||
} else if (activeApp === "claude-desktop") {
|
||||
if (provider.meta?.claudeDesktopMode === "proxy") {
|
||||
messageKey = "notifications.claudeDesktopProxyRestartRequired";
|
||||
|
||||
@@ -132,7 +132,9 @@ export function useProxyStatus() {
|
||||
? "Codex"
|
||||
: variables.appType === "gemini"
|
||||
? "Gemini"
|
||||
: "OpenCode";
|
||||
: variables.appType === "grokbuild"
|
||||
? "Grok Build"
|
||||
: "OpenCode";
|
||||
|
||||
toast.success(
|
||||
variables.enabled
|
||||
@@ -215,6 +217,7 @@ export function useProxyStatus() {
|
||||
takeoverStatus?.claude ||
|
||||
takeoverStatus?.codex ||
|
||||
takeoverStatus?.gemini ||
|
||||
takeoverStatus?.grokbuild ||
|
||||
false,
|
||||
|
||||
// 启动/停止(总开关)
|
||||
|
||||
@@ -110,6 +110,7 @@ export function useSettings(): UseSettingsResult {
|
||||
claude: sanitizeDir(data?.claudeConfigDir),
|
||||
codex: sanitizeDir(data?.codexConfigDir),
|
||||
gemini: sanitizeDir(data?.geminiConfigDir),
|
||||
grokbuild: sanitizeDir(data?.grokConfigDir),
|
||||
opencode: sanitizeDir(data?.opencodeConfigDir),
|
||||
openclaw: sanitizeDir(data?.openclawConfigDir),
|
||||
hermes: sanitizeDir(data?.hermesConfigDir),
|
||||
@@ -187,6 +188,7 @@ export function useSettings(): UseSettingsResult {
|
||||
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
|
||||
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
|
||||
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
|
||||
const sanitizedGrokDir = sanitizeDir(mergedSettings.grokConfigDir);
|
||||
const sanitizedOpencodeDir = sanitizeDir(
|
||||
mergedSettings.opencodeConfigDir,
|
||||
);
|
||||
@@ -204,6 +206,7 @@ export function useSettings(): UseSettingsResult {
|
||||
claudeConfigDir: sanitizedClaudeDir,
|
||||
codexConfigDir: sanitizedCodexDir,
|
||||
geminiConfigDir: sanitizedGeminiDir,
|
||||
grokConfigDir: sanitizedGrokDir,
|
||||
opencodeConfigDir: sanitizedOpencodeDir,
|
||||
openclawConfigDir: sanitizedOpenclawDir,
|
||||
language: mergedSettings.language,
|
||||
@@ -318,6 +321,7 @@ export function useSettings(): UseSettingsResult {
|
||||
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
|
||||
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
|
||||
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
|
||||
const sanitizedGrokDir = sanitizeDir(mergedSettings.grokConfigDir);
|
||||
const sanitizedOpencodeDir = sanitizeDir(
|
||||
mergedSettings.opencodeConfigDir,
|
||||
);
|
||||
@@ -328,6 +332,7 @@ export function useSettings(): UseSettingsResult {
|
||||
const previousClaudeDir = sanitizeDir(data?.claudeConfigDir);
|
||||
const previousCodexDir = sanitizeDir(data?.codexConfigDir);
|
||||
const previousGeminiDir = sanitizeDir(data?.geminiConfigDir);
|
||||
const previousGrokDir = sanitizeDir(data?.grokConfigDir);
|
||||
const previousOpencodeDir = sanitizeDir(data?.opencodeConfigDir);
|
||||
const previousOpenclawDir = sanitizeDir(data?.openclawConfigDir);
|
||||
const {
|
||||
@@ -341,6 +346,7 @@ export function useSettings(): UseSettingsResult {
|
||||
claudeConfigDir: sanitizedClaudeDir,
|
||||
codexConfigDir: sanitizedCodexDir,
|
||||
geminiConfigDir: sanitizedGeminiDir,
|
||||
grokConfigDir: sanitizedGrokDir,
|
||||
opencodeConfigDir: sanitizedOpencodeDir,
|
||||
openclawConfigDir: sanitizedOpenclawDir,
|
||||
language: mergedSettings.language,
|
||||
@@ -427,6 +433,7 @@ export function useSettings(): UseSettingsResult {
|
||||
const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir;
|
||||
const codexDirChanged = sanitizedCodexDir !== previousCodexDir;
|
||||
const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir;
|
||||
const grokDirChanged = sanitizedGrokDir !== previousGrokDir;
|
||||
const opencodeDirChanged = sanitizedOpencodeDir !== previousOpencodeDir;
|
||||
const openclawDirChanged = sanitizedOpenclawDir !== previousOpenclawDir;
|
||||
if (
|
||||
@@ -434,6 +441,7 @@ export function useSettings(): UseSettingsResult {
|
||||
(claudeDirChanged ||
|
||||
codexDirChanged ||
|
||||
geminiDirChanged ||
|
||||
grokDirChanged ||
|
||||
opencodeDirChanged ||
|
||||
openclawDirChanged)
|
||||
) {
|
||||
|
||||
@@ -122,6 +122,7 @@ export function useSettingsForm(): UseSettingsFormResult {
|
||||
claudeConfigDir: sanitizeDir(data.claudeConfigDir),
|
||||
codexConfigDir: sanitizeDir(data.codexConfigDir),
|
||||
geminiConfigDir: sanitizeDir(data.geminiConfigDir),
|
||||
grokConfigDir: sanitizeDir(data.grokConfigDir),
|
||||
opencodeConfigDir: sanitizeDir(data.opencodeConfigDir),
|
||||
openclawConfigDir: sanitizeDir(data.openclawConfigDir),
|
||||
language: normalizedLanguage,
|
||||
@@ -188,6 +189,7 @@ export function useSettingsForm(): UseSettingsFormResult {
|
||||
claudeConfigDir: sanitizeDir(serverData.claudeConfigDir),
|
||||
codexConfigDir: sanitizeDir(serverData.codexConfigDir),
|
||||
geminiConfigDir: sanitizeDir(serverData.geminiConfigDir),
|
||||
grokConfigDir: sanitizeDir(serverData.grokConfigDir),
|
||||
opencodeConfigDir: sanitizeDir(serverData.opencodeConfigDir),
|
||||
openclawConfigDir: sanitizeDir(serverData.openclawConfigDir),
|
||||
language: normalizedLanguage,
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"providerDeleted": "Provider deleted successfully",
|
||||
"switchSuccess": "Switch successful!",
|
||||
"codexRestartRequired": "Switched successfully. Restart the client to apply changes.",
|
||||
"grokBuildRestartRequired": "Switched successfully. Restart Grok Build to apply changes.",
|
||||
"claudeDesktopRestartRequired": "Switched successfully. Restart Claude Desktop to apply changes.",
|
||||
"claudeDesktopProxyRestartRequired": "Switched successfully. Keep CC Switch running and restart Claude Desktop to apply changes.",
|
||||
"addToConfigSuccess": "Added to config",
|
||||
@@ -756,6 +757,8 @@
|
||||
"codexConfigDirDescription": "Override Codex configuration directory.",
|
||||
"geminiConfigDir": "Gemini Configuration Directory",
|
||||
"geminiConfigDirDescription": "Override Gemini configuration directory (.env).",
|
||||
"grokConfigDir": "Grok Build Configuration Directory",
|
||||
"grokConfigDirDescription": "Override Grok Build configuration directory (config.toml).",
|
||||
"opencodeConfigDir": "OpenCode Configuration Directory",
|
||||
"opencodeConfigDirDescription": "Override OpenCode configuration directory (opencode.json).",
|
||||
"openclawConfigDir": "OpenClaw Configuration Directory",
|
||||
@@ -765,6 +768,7 @@
|
||||
"browsePlaceholderClaude": "e.g., /home/<your-username>/.claude",
|
||||
"browsePlaceholderCodex": "e.g., /home/<your-username>/.codex",
|
||||
"browsePlaceholderGemini": "e.g., /home/<your-username>/.gemini",
|
||||
"browsePlaceholderGrok": "e.g., /home/<your-username>/.grok",
|
||||
"browsePlaceholderOpencode": "e.g., /home/<your-username>/.config/opencode",
|
||||
"browsePlaceholderOpenclaw": "e.g., /home/<your-username>/.openclaw",
|
||||
"browsePlaceholderHermes": "e.g., /home/<your-username>/.hermes",
|
||||
@@ -875,13 +879,22 @@
|
||||
"claude-desktop": "Claude Desktop",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"grokbuild": "Grok Build",
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw",
|
||||
"hermes": "Hermes"
|
||||
},
|
||||
"grokBuild": {
|
||||
"apiBackend": "API backend",
|
||||
"contextWindow": "Context window",
|
||||
"contextWindowInvalid": "Context window must be a positive integer",
|
||||
"profile": "Client model profile",
|
||||
"rawConfig": "config.toml",
|
||||
"invalidToml": "Invalid config.toml: {{error}}"
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "Session Manager",
|
||||
"subtitle": "Manage Claude Code, Codex, OpenCode, OpenClaw, Hermes and Gemini CLI sessions",
|
||||
"subtitle": "Manage Claude Code, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw and Hermes sessions",
|
||||
"searchPlaceholder": "Search by content, directory, or ID",
|
||||
"searchSessions": "Search sessions",
|
||||
"providerFilterAll": "All",
|
||||
@@ -1719,6 +1732,7 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"grokbuild": "Grok Build",
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw",
|
||||
"hermes": "Hermes"
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"providerDeleted": "プロバイダーを削除しました",
|
||||
"switchSuccess": "切り替え成功!",
|
||||
"codexRestartRequired": "切り替えました。反映するにはクライアントを再起動してください",
|
||||
"grokBuildRestartRequired": "切り替えました。反映するには Grok Build を再起動してください",
|
||||
"claudeDesktopRestartRequired": "切り替えました。反映するには Claude Desktop を再起動してください",
|
||||
"claudeDesktopProxyRestartRequired": "切り替えました。CC Switch を起動したまま Claude Desktop を再起動してください",
|
||||
"addToConfigSuccess": "設定に追加しました",
|
||||
@@ -756,6 +757,8 @@
|
||||
"codexConfigDirDescription": "Codex の設定ディレクトリを上書きします。",
|
||||
"geminiConfigDir": "Gemini 設定ディレクトリ",
|
||||
"geminiConfigDirDescription": "Gemini の設定ディレクトリ(.env)を上書きします。",
|
||||
"grokConfigDir": "Grok Build 設定ディレクトリ",
|
||||
"grokConfigDirDescription": "Grok Build の設定ディレクトリ(config.toml)を上書きします。",
|
||||
"opencodeConfigDir": "OpenCode 設定ディレクトリ",
|
||||
"opencodeConfigDirDescription": "OpenCode の設定ディレクトリ(opencode.json)を上書きします。",
|
||||
"openclawConfigDir": "OpenClaw 設定ディレクトリ",
|
||||
@@ -765,6 +768,7 @@
|
||||
"browsePlaceholderClaude": "例: /home/<your-username>/.claude",
|
||||
"browsePlaceholderCodex": "例: /home/<your-username>/.codex",
|
||||
"browsePlaceholderGemini": "例: /home/<your-username>/.gemini",
|
||||
"browsePlaceholderGrok": "例: /home/<your-username>/.grok",
|
||||
"browsePlaceholderOpencode": "例: /home/<your-username>/.config/opencode",
|
||||
"browsePlaceholderOpenclaw": "例: /home/<your-username>/.openclaw",
|
||||
"browsePlaceholderHermes": "例: /home/<your-username>/.hermes",
|
||||
@@ -875,13 +879,22 @@
|
||||
"claude-desktop": "Claude Desktop",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"grokbuild": "Grok Build",
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw",
|
||||
"hermes": "Hermes"
|
||||
},
|
||||
"grokBuild": {
|
||||
"apiBackend": "API Backend",
|
||||
"contextWindow": "コンテキストウィンドウ",
|
||||
"contextWindowInvalid": "コンテキストウィンドウは正の整数で指定してください",
|
||||
"profile": "クライアントモデルプロファイル",
|
||||
"rawConfig": "config.toml",
|
||||
"invalidToml": "config.toml の形式が不正です: {{error}}"
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "セッション管理",
|
||||
"subtitle": "Claude Code / Codex / OpenCode / OpenClaw / Hermes / Gemini CLI のセッションを管理",
|
||||
"subtitle": "Claude Code / Codex / Gemini CLI / Grok Build / OpenCode / OpenClaw / Hermes のセッションを管理",
|
||||
"searchPlaceholder": "内容・ディレクトリ・ID で検索",
|
||||
"searchSessions": "セッションを検索",
|
||||
"providerFilterAll": "すべて",
|
||||
@@ -1719,6 +1732,7 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"grokbuild": "Grok Build",
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw",
|
||||
"hermes": "Hermes"
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"providerDeleted": "供應商刪除成功",
|
||||
"switchSuccess": "切換成功!",
|
||||
"codexRestartRequired": "切換成功,請重新啟動客戶端以套用",
|
||||
"grokBuildRestartRequired": "切換成功,請重新啟動 Grok Build 以套用",
|
||||
"claudeDesktopRestartRequired": "切換成功,重新啟動 Claude Desktop 後生效",
|
||||
"claudeDesktopProxyRestartRequired": "切換成功,請保持 CC Switch 執行,並重新啟動 Claude Desktop 後生效",
|
||||
"addToConfigSuccess": "已新增至設定",
|
||||
@@ -756,6 +757,8 @@
|
||||
"codexConfigDirDescription": "覆寫 Codex 設定目錄。",
|
||||
"geminiConfigDir": "Gemini 設定目錄",
|
||||
"geminiConfigDirDescription": "覆寫 Gemini 設定目錄 (.env)。",
|
||||
"grokConfigDir": "Grok Build 設定目錄",
|
||||
"grokConfigDirDescription": "覆寫 Grok Build 設定目錄 (config.toml)。",
|
||||
"opencodeConfigDir": "OpenCode 設定目錄",
|
||||
"opencodeConfigDirDescription": "覆寫 OpenCode 設定目錄 (opencode.json)。",
|
||||
"openclawConfigDir": "OpenClaw 設定目錄",
|
||||
@@ -765,6 +768,7 @@
|
||||
"browsePlaceholderClaude": "例如:/home/<您的帳號>/.claude",
|
||||
"browsePlaceholderCodex": "例如:/home/<您的帳號>/.codex",
|
||||
"browsePlaceholderGemini": "例如:/home/<您的帳號>/.gemini",
|
||||
"browsePlaceholderGrok": "例如:/home/<您的帳號>/.grok",
|
||||
"browsePlaceholderOpencode": "例如:/home/<您的帳號>/.config/opencode",
|
||||
"browsePlaceholderOpenclaw": "例如:/home/<您的帳號>/.openclaw",
|
||||
"browsePlaceholderHermes": "例如:/home/<您的帳號>/.hermes",
|
||||
@@ -846,13 +850,22 @@
|
||||
"claude-desktop": "Claude Desktop",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"grokbuild": "Grok Build",
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw",
|
||||
"hermes": "Hermes"
|
||||
},
|
||||
"grokBuild": {
|
||||
"apiBackend": "API Backend",
|
||||
"contextWindow": "上下文視窗",
|
||||
"contextWindowInvalid": "上下文視窗必須是正整數",
|
||||
"profile": "用戶端模型檔位",
|
||||
"rawConfig": "config.toml",
|
||||
"invalidToml": "config.toml 格式錯誤:{{error}}"
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "工作階段管理",
|
||||
"subtitle": "管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 與 Gemini CLI 工作階段紀錄",
|
||||
"subtitle": "管理 Claude Code、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw 與 Hermes 工作階段紀錄",
|
||||
"searchPlaceholder": "搜尋對話內容、目錄或 ID",
|
||||
"searchSessions": "搜尋工作階段",
|
||||
"providerFilterAll": "全部",
|
||||
@@ -1691,6 +1704,7 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"grokbuild": "Grok Build",
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw",
|
||||
"hermes": "Hermes"
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"providerDeleted": "供应商删除成功",
|
||||
"switchSuccess": "切换成功!",
|
||||
"codexRestartRequired": "切换成功,请重启客户端以生效",
|
||||
"grokBuildRestartRequired": "切换成功,请重启 Grok Build 以生效",
|
||||
"claudeDesktopRestartRequired": "切换成功,重启 Claude Desktop 后生效",
|
||||
"claudeDesktopProxyRestartRequired": "切换成功,请保持 CC Switch 运行,并重启 Claude Desktop 后生效",
|
||||
"addToConfigSuccess": "已添加到配置",
|
||||
@@ -756,6 +757,8 @@
|
||||
"codexConfigDirDescription": "覆盖 Codex 配置目录。",
|
||||
"geminiConfigDir": "Gemini 配置目录",
|
||||
"geminiConfigDirDescription": "覆盖 Gemini 配置目录 (.env)。",
|
||||
"grokConfigDir": "Grok Build 配置目录",
|
||||
"grokConfigDirDescription": "覆盖 Grok Build 配置目录 (config.toml)。",
|
||||
"opencodeConfigDir": "OpenCode 配置目录",
|
||||
"opencodeConfigDirDescription": "覆盖 OpenCode 配置目录 (opencode.json)。",
|
||||
"openclawConfigDir": "OpenClaw 配置目录",
|
||||
@@ -765,6 +768,7 @@
|
||||
"browsePlaceholderClaude": "例如:/home/<你的用户名>/.claude",
|
||||
"browsePlaceholderCodex": "例如:/home/<你的用户名>/.codex",
|
||||
"browsePlaceholderGemini": "例如:/home/<你的用户名>/.gemini",
|
||||
"browsePlaceholderGrok": "例如:/home/<你的用户名>/.grok",
|
||||
"browsePlaceholderOpencode": "例如:/home/<你的用户名>/.config/opencode",
|
||||
"browsePlaceholderOpenclaw": "例如:/home/<你的用户名>/.openclaw",
|
||||
"browsePlaceholderHermes": "例如:/home/<你的用户名>/.hermes",
|
||||
@@ -875,13 +879,22 @@
|
||||
"claude-desktop": "Claude Desktop",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"grokbuild": "Grok Build",
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw",
|
||||
"hermes": "Hermes"
|
||||
},
|
||||
"grokBuild": {
|
||||
"apiBackend": "API Backend",
|
||||
"contextWindow": "上下文窗口",
|
||||
"contextWindowInvalid": "上下文窗口必须是正整数",
|
||||
"profile": "客户端模型档位",
|
||||
"rawConfig": "config.toml",
|
||||
"invalidToml": "config.toml 格式错误:{{error}}"
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "会话管理",
|
||||
"subtitle": "管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 与 Gemini CLI 会话记录",
|
||||
"subtitle": "管理 Claude Code、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw 与 Hermes 会话记录",
|
||||
"searchPlaceholder": "搜索会话内容、目录或 ID",
|
||||
"searchSessions": "搜索会话",
|
||||
"providerFilterAll": "全部",
|
||||
@@ -1719,6 +1732,7 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"grokbuild": "Grok Build",
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw",
|
||||
"hermes": "Hermes"
|
||||
|
||||
@@ -7,6 +7,7 @@ export type AppType =
|
||||
| "claude-desktop"
|
||||
| "codex"
|
||||
| "gemini"
|
||||
| "grokbuild"
|
||||
| "opencode"
|
||||
| "openclaw"
|
||||
| "hermes";
|
||||
@@ -17,6 +18,7 @@ export interface SkillApps {
|
||||
"claude-desktop"?: boolean;
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
grokbuild?: boolean;
|
||||
opencode: boolean;
|
||||
openclaw: boolean;
|
||||
hermes: boolean;
|
||||
|
||||
@@ -4,6 +4,7 @@ export type AppId =
|
||||
| "claude-desktop"
|
||||
| "codex"
|
||||
| "gemini"
|
||||
| "grokbuild"
|
||||
| "opencode"
|
||||
| "openclaw"
|
||||
| "hermes";
|
||||
|
||||
@@ -227,7 +227,9 @@ export function useSetAutoFailoverEnabled() {
|
||||
? "Claude"
|
||||
: variables.appType === "codex"
|
||||
? "Codex"
|
||||
: "Gemini";
|
||||
: variables.appType === "grokbuild"
|
||||
? "Grok Build"
|
||||
: "Gemini";
|
||||
|
||||
toast.success(
|
||||
variables.enabled
|
||||
|
||||
@@ -24,6 +24,7 @@ export const settingsSchema = z.object({
|
||||
claudeConfigDir: directorySchema.nullable().optional(),
|
||||
codexConfigDir: directorySchema.nullable().optional(),
|
||||
geminiConfigDir: directorySchema.nullable().optional(),
|
||||
grokConfigDir: directorySchema.nullable().optional(),
|
||||
opencodeConfigDir: directorySchema.nullable().optional(),
|
||||
openclawConfigDir: directorySchema.nullable().optional(),
|
||||
|
||||
|
||||
@@ -280,6 +280,7 @@ export interface VisibleApps {
|
||||
"claude-desktop": boolean;
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
grokbuild: boolean;
|
||||
opencode: boolean;
|
||||
openclaw: boolean;
|
||||
hermes: boolean;
|
||||
@@ -394,6 +395,8 @@ export interface Settings {
|
||||
codexConfigDir?: string;
|
||||
// 覆盖 Gemini 配置目录(可选)
|
||||
geminiConfigDir?: string;
|
||||
// 覆盖 Grok Build 配置目录(可选)
|
||||
grokConfigDir?: string;
|
||||
// 覆盖 OpenCode 配置目录(可选)
|
||||
opencodeConfigDir?: string;
|
||||
// 覆盖 OpenClaw 配置目录(可选)
|
||||
@@ -488,6 +491,7 @@ export interface McpApps {
|
||||
"claude-desktop"?: boolean;
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
grokbuild?: boolean;
|
||||
opencode: boolean;
|
||||
openclaw: boolean;
|
||||
hermes: boolean;
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface ProxyTakeoverStatus {
|
||||
"claude-desktop"?: boolean;
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
grokbuild: boolean;
|
||||
opencode: boolean;
|
||||
openclaw: boolean;
|
||||
hermes: boolean;
|
||||
|
||||
+3
-1
@@ -172,7 +172,7 @@ export interface UsageRangeSelection {
|
||||
* `opencode` / `openclaw` / `hermes` have no proxy handler at all — they
|
||||
* appear only as managed apps elsewhere.
|
||||
*/
|
||||
export type AppType = "claude" | "codex" | "gemini" | "opencode";
|
||||
export type AppType = "claude" | "codex" | "gemini" | "grokbuild" | "opencode";
|
||||
|
||||
export type AppTypeFilter = "all" | AppType;
|
||||
|
||||
@@ -180,6 +180,7 @@ export const KNOWN_APP_TYPES: ReadonlyArray<AppType> = [
|
||||
"claude",
|
||||
"codex",
|
||||
"gemini",
|
||||
"grokbuild",
|
||||
"opencode",
|
||||
];
|
||||
|
||||
@@ -198,6 +199,7 @@ export const KNOWN_APP_TYPES: ReadonlyArray<AppType> = [
|
||||
export const CACHE_INCLUSIVE_APP_TYPES: ReadonlySet<string> = new Set([
|
||||
"codex",
|
||||
"gemini",
|
||||
"grokbuild",
|
||||
]);
|
||||
|
||||
/** Subset of request-log fields needed to derive cache-normalized input. */
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
import {
|
||||
buildGrokBuildConfig,
|
||||
extractGrokBuildBaseUrl,
|
||||
parseGrokBuildConfig,
|
||||
updateGrokBuildConfig,
|
||||
validateGrokBuildConfig,
|
||||
} from "./grokBuildConfig";
|
||||
|
||||
describe("Grok Build config", () => {
|
||||
it("builds the expected provider TOML", () => {
|
||||
const config = buildGrokBuildConfig({
|
||||
model: "grok-4.5",
|
||||
baseUrl: "https://relay.example.com/v1",
|
||||
name: 'Relay "A"',
|
||||
apiKey: "secret",
|
||||
apiBackend: "responses",
|
||||
contextWindow: 500000,
|
||||
});
|
||||
const parsed = parseToml(config) as any;
|
||||
|
||||
expect(parsed.models.default).toBe("grok-4.5");
|
||||
expect(parsed.model["grok-4.5"]).toEqual({
|
||||
model: "grok-4.5",
|
||||
base_url: "https://relay.example.com/v1",
|
||||
name: 'Relay "A"',
|
||||
api_key: "secret",
|
||||
api_backend: "responses",
|
||||
context_window: 500000,
|
||||
});
|
||||
expect(config).toContain('[model."grok-4.5"]');
|
||||
});
|
||||
|
||||
it("reads values back from a generated config", () => {
|
||||
const config = buildGrokBuildConfig({
|
||||
model: "custom-model",
|
||||
upstreamModel: "upstream-model",
|
||||
baseUrl: "https://api.example.com",
|
||||
name: "Custom",
|
||||
apiKey: "key",
|
||||
envKey: "",
|
||||
apiBackend: "responses",
|
||||
contextWindow: 320000,
|
||||
});
|
||||
|
||||
expect(parseGrokBuildConfig(config)).toEqual({
|
||||
model: "custom-model",
|
||||
upstreamModel: "upstream-model",
|
||||
baseUrl: "https://api.example.com",
|
||||
name: "Custom",
|
||||
apiKey: "key",
|
||||
envKey: "",
|
||||
apiBackend: "responses",
|
||||
contextWindow: 320000,
|
||||
});
|
||||
expect(extractGrokBuildBaseUrl(config)).toBe("https://api.example.com");
|
||||
});
|
||||
|
||||
it("accepts env_key credentials without adding an empty api_key", () => {
|
||||
const config = `[models]
|
||||
default = "env-profile"
|
||||
|
||||
[model."env-profile"]
|
||||
model = "grok-4.5"
|
||||
base_url = "https://api.example.com/v1"
|
||||
name = "Env Relay"
|
||||
env_key = "XAI_API_KEY"
|
||||
api_backend = "responses"
|
||||
context_window = 500000
|
||||
`;
|
||||
|
||||
expect(validateGrokBuildConfig(config)).toBeNull();
|
||||
expect(parseGrokBuildConfig(config).envKey).toBe("XAI_API_KEY");
|
||||
|
||||
const updated = updateGrokBuildConfig(config, {
|
||||
...parseGrokBuildConfig(config),
|
||||
baseUrl: "https://updated.example.com/v1",
|
||||
});
|
||||
const parsed = parseToml(updated) as any;
|
||||
expect(parsed.model["env-profile"].env_key).toBe("XAI_API_KEY");
|
||||
expect(parsed.model["env-profile"]).not.toHaveProperty("api_key");
|
||||
});
|
||||
|
||||
it("reports malformed, incomplete, and invalid-window configs", () => {
|
||||
expect(validateGrokBuildConfig("")).toBe("config.toml must not be empty");
|
||||
expect(validateGrokBuildConfig("[models")).not.toBeNull();
|
||||
expect(validateGrokBuildConfig('[models]\ndefault = "missing"\n')).toBe(
|
||||
"Missing [models] default model table",
|
||||
);
|
||||
|
||||
const missingCredentials = buildGrokBuildConfig({
|
||||
model: "grok-4.5",
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
name: "Relay",
|
||||
apiKey: "",
|
||||
apiBackend: "responses",
|
||||
contextWindow: 500000,
|
||||
});
|
||||
expect(validateGrokBuildConfig(missingCredentials)).toBe(
|
||||
"Missing api_key or env_key",
|
||||
);
|
||||
|
||||
const invalidWindow = missingCredentials.replace(
|
||||
"context_window = 500000",
|
||||
"context_window = 0",
|
||||
);
|
||||
expect(validateGrokBuildConfig(invalidWindow)).toBe(
|
||||
"Missing api_key or env_key",
|
||||
);
|
||||
expect(
|
||||
validateGrokBuildConfig(
|
||||
invalidWindow.replace(
|
||||
'name = "Relay"',
|
||||
'name = "Relay"\napi_key = "secret"',
|
||||
),
|
||||
),
|
||||
).toBe("context_window must be a positive integer");
|
||||
});
|
||||
|
||||
it("renames the selected profile without leaving the old table behind", () => {
|
||||
const original = buildGrokBuildConfig({
|
||||
model: "old-profile",
|
||||
upstreamModel: "grok-upstream",
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
name: "Relay",
|
||||
apiKey: "secret",
|
||||
apiBackend: "responses",
|
||||
contextWindow: 500000,
|
||||
});
|
||||
|
||||
const renamed = updateGrokBuildConfig(original, {
|
||||
...parseGrokBuildConfig(original),
|
||||
model: "new-profile",
|
||||
});
|
||||
const parsed = parseToml(renamed) as any;
|
||||
|
||||
expect(parsed.models.default).toBe("new-profile");
|
||||
expect(parsed.model["new-profile"].model).toBe("grok-upstream");
|
||||
expect(parsed.model).not.toHaveProperty("old-profile");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
||||
|
||||
export const GROK_BUILD_DEFAULT_MODEL = "grok-4.5";
|
||||
export const GROK_BUILD_DEFAULT_API_BACKEND = "responses";
|
||||
export const GROK_BUILD_DEFAULT_CONTEXT_WINDOW = 500000;
|
||||
|
||||
export interface GrokBuildConfigValues {
|
||||
/** Client-visible profile selected by [models].default. */
|
||||
model: string;
|
||||
/** Real model sent to the upstream provider. */
|
||||
upstreamModel?: string;
|
||||
baseUrl: string;
|
||||
name: string;
|
||||
apiKey: string;
|
||||
envKey?: string;
|
||||
apiBackend: string;
|
||||
contextWindow: number;
|
||||
}
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
const asString = (value: unknown, fallback = "") =>
|
||||
typeof value === "string" ? value : fallback;
|
||||
|
||||
export function parseGrokBuildConfig(
|
||||
configToml: string | undefined,
|
||||
fallbackName = "",
|
||||
): GrokBuildConfigValues {
|
||||
const fallback: GrokBuildConfigValues = {
|
||||
model: GROK_BUILD_DEFAULT_MODEL,
|
||||
upstreamModel: GROK_BUILD_DEFAULT_MODEL,
|
||||
baseUrl: "",
|
||||
name: fallbackName,
|
||||
apiKey: "",
|
||||
apiBackend: GROK_BUILD_DEFAULT_API_BACKEND,
|
||||
contextWindow: GROK_BUILD_DEFAULT_CONTEXT_WINDOW,
|
||||
};
|
||||
|
||||
if (!configToml?.trim()) return fallback;
|
||||
|
||||
try {
|
||||
const root = asRecord(parseToml(configToml));
|
||||
const models = asRecord(root?.models);
|
||||
const defaultModel = asString(models?.default, GROK_BUILD_DEFAULT_MODEL);
|
||||
const modelTables = asRecord(root?.model);
|
||||
const selectedModel = asRecord(modelTables?.[defaultModel]);
|
||||
const rawContextWindow = selectedModel?.context_window;
|
||||
|
||||
return {
|
||||
model: defaultModel,
|
||||
upstreamModel: asString(selectedModel?.model, defaultModel),
|
||||
baseUrl: asString(selectedModel?.base_url),
|
||||
name: asString(selectedModel?.name, fallbackName),
|
||||
apiKey: asString(selectedModel?.api_key),
|
||||
envKey: asString(selectedModel?.env_key),
|
||||
apiBackend: asString(
|
||||
selectedModel?.api_backend,
|
||||
GROK_BUILD_DEFAULT_API_BACKEND,
|
||||
),
|
||||
contextWindow:
|
||||
typeof rawContextWindow === "number" &&
|
||||
Number.isInteger(rawContextWindow) &&
|
||||
rawContextWindow > 0
|
||||
? rawContextWindow
|
||||
: GROK_BUILD_DEFAULT_CONTEXT_WINDOW,
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildGrokBuildConfig(values: GrokBuildConfigValues): string {
|
||||
return updateGrokBuildConfig(undefined, values);
|
||||
}
|
||||
|
||||
export function updateGrokBuildConfig(
|
||||
configToml: string | undefined,
|
||||
values: GrokBuildConfigValues,
|
||||
): string {
|
||||
const profile = values.model.trim() || GROK_BUILD_DEFAULT_MODEL;
|
||||
const upstreamModel = values.upstreamModel?.trim() || profile;
|
||||
let config: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
config = asRecord(configToml?.trim() ? parseToml(configToml) : {}) ?? {};
|
||||
} catch {
|
||||
config = {};
|
||||
}
|
||||
|
||||
const existingModels = asRecord(config.models) ?? {};
|
||||
const previousProfile = asString(existingModels.default, profile);
|
||||
config.models = { ...existingModels, default: profile };
|
||||
|
||||
const modelTables = asRecord(config.model) ?? {};
|
||||
const existingSelected =
|
||||
asRecord(modelTables[profile]) ??
|
||||
asRecord(modelTables[previousProfile]) ??
|
||||
{};
|
||||
const apiKey = values.apiKey.trim();
|
||||
const envKey =
|
||||
values.envKey?.trim() || asString(existingSelected.env_key).trim();
|
||||
const updatedSelected: Record<string, unknown> = {
|
||||
...existingSelected,
|
||||
model: upstreamModel,
|
||||
base_url: values.baseUrl.trim(),
|
||||
name: values.name.trim(),
|
||||
api_backend: values.apiBackend.trim() || GROK_BUILD_DEFAULT_API_BACKEND,
|
||||
context_window:
|
||||
Number.isInteger(values.contextWindow) && values.contextWindow > 0
|
||||
? values.contextWindow
|
||||
: GROK_BUILD_DEFAULT_CONTEXT_WINDOW,
|
||||
};
|
||||
if (apiKey) updatedSelected.api_key = apiKey;
|
||||
else delete updatedSelected.api_key;
|
||||
if (envKey) updatedSelected.env_key = envKey;
|
||||
else delete updatedSelected.env_key;
|
||||
|
||||
config.model = {
|
||||
...modelTables,
|
||||
[profile]: updatedSelected,
|
||||
};
|
||||
|
||||
if (previousProfile !== profile && previousProfile in modelTables) {
|
||||
delete (config.model as Record<string, unknown>)[previousProfile];
|
||||
}
|
||||
|
||||
return `${stringifyToml(config).trim()}\n`;
|
||||
}
|
||||
|
||||
export function validateGrokBuildConfig(configToml: string): string | null {
|
||||
if (!configToml.trim()) return "config.toml must not be empty";
|
||||
try {
|
||||
const root = asRecord(parseToml(configToml));
|
||||
const models = asRecord(root?.models);
|
||||
const profile = asString(models?.default).trim();
|
||||
const selected = asRecord(asRecord(root?.model)?.[profile]);
|
||||
if (!profile || !selected) return "Missing [models] default model table";
|
||||
for (const field of ["model", "base_url", "name", "api_backend"]) {
|
||||
if (!asString(selected[field]).trim()) return `Missing ${field}`;
|
||||
}
|
||||
if (
|
||||
!asString(selected.api_key).trim() &&
|
||||
!asString(selected.env_key).trim()
|
||||
) {
|
||||
return "Missing api_key or env_key";
|
||||
}
|
||||
const contextWindow = selected.context_window;
|
||||
if (
|
||||
typeof contextWindow !== "number" ||
|
||||
!Number.isInteger(contextWindow) ||
|
||||
contextWindow <= 0
|
||||
) {
|
||||
return "context_window must be a positive integer";
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : "Invalid TOML";
|
||||
}
|
||||
}
|
||||
|
||||
export function extractGrokBuildBaseUrl(configToml: string): string {
|
||||
return parseGrokBuildConfig(configToml).baseUrl;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveProviderIcon } from "./providerIcon";
|
||||
|
||||
describe("resolveProviderIcon", () => {
|
||||
it("clears the legacy automatic Grok Build icon", () => {
|
||||
expect(resolveProviderIcon("grokbuild", "grok", "")).toBeUndefined();
|
||||
expect(resolveProviderIcon("grokbuild", "grok")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves a Grok icon explicitly selected by the user", () => {
|
||||
expect(resolveProviderIcon("grokbuild", "grok", "currentColor")).toBe(
|
||||
"grok",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not reinterpret another app's provider icon", () => {
|
||||
expect(resolveProviderIcon("codex", "grok", "")).toBe("grok");
|
||||
});
|
||||
|
||||
it("normalizes an empty icon to the initials fallback", () => {
|
||||
expect(resolveProviderIcon("grokbuild", " ", "")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
|
||||
/**
|
||||
* Grok Build providers created before the provider-icon rules were aligned
|
||||
* received the Grok app icon automatically. The icon picker records the
|
||||
* selected icon's default color (`currentColor` for Grok), so an empty color
|
||||
* identifies the old automatic value without hiding an explicit user choice.
|
||||
*/
|
||||
export function resolveProviderIcon(
|
||||
appId: AppId,
|
||||
icon?: string,
|
||||
iconColor?: string,
|
||||
): string | undefined {
|
||||
const normalizedIcon = icon?.trim();
|
||||
if (!normalizedIcon) return undefined;
|
||||
|
||||
if (
|
||||
appId === "grokbuild" &&
|
||||
normalizedIcon === "grok" &&
|
||||
!iconColor?.trim()
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalizedIcon;
|
||||
}
|
||||
Reference in New Issue
Block a user