From e9317f476e37201b106abeb594f2c64399f64c1e Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 19 Jul 2026 00:20:17 +0800 Subject: [PATCH] feat(xai): add Grok account management UI with four-locale strings Add XaiOAuthSection (device-code login, account list, default selection) backed by the generalized useManagedAuth hook, wire it into the Auth Center and both Claude provider forms, and expose model fetching for signed-in accounts. - Accounts requiring re-auth stay visible in the account selector as disabled items with an expired badge instead of silently vanishing. - Auth status refetches periodically so a revoked refresh token surfaces without reloading the panel. - All strings ship in zh/en/ja/zh-TW; a locale coverage test pins every required key in all four locales. --- .../forms/ClaudeDesktopProviderForm.tsx | 190 +++++++--- .../providers/forms/ClaudeFormFields.tsx | 97 +++++- .../providers/forms/ProviderForm.tsx | 110 +++++- .../providers/forms/XaiOAuthSection.tsx | 326 ++++++++++++++++++ src/components/providers/forms/hooks/index.ts | 1 + .../providers/forms/hooks/useManagedAuth.ts | 4 + .../providers/forms/hooks/useXaiOauth.ts | 6 + src/components/settings/AuthCenterPanel.tsx | 20 ++ src/i18n/locales/en.json | 29 +- src/i18n/locales/ja.json | 29 +- src/i18n/locales/zh-TW.json | 29 +- src/i18n/locales/zh.json | 29 +- src/lib/api/auth.ts | 6 +- src/lib/api/model-fetch.ts | 9 + tests/components/XaiOAuthSection.test.tsx | 63 ++++ tests/config/xaiOauthLocales.test.ts | 56 +++ 16 files changed, 936 insertions(+), 68 deletions(-) create mode 100644 src/components/providers/forms/XaiOAuthSection.tsx create mode 100644 src/components/providers/forms/hooks/useXaiOauth.ts create mode 100644 tests/components/XaiOAuthSection.test.tsx create mode 100644 tests/config/xaiOauthLocales.test.ts diff --git a/src/components/providers/forms/ClaudeDesktopProviderForm.tsx b/src/components/providers/forms/ClaudeDesktopProviderForm.tsx index 1030c6d0f..c88976164 100644 --- a/src/components/providers/forms/ClaudeDesktopProviderForm.tsx +++ b/src/components/providers/forms/ClaudeDesktopProviderForm.tsx @@ -39,6 +39,7 @@ import { Switch } from "@/components/ui/switch"; import { BasicFormFields } from "./BasicFormFields"; import { CodexOAuthSection } from "./CodexOAuthSection"; import { CopilotAuthSection } from "./CopilotAuthSection"; +import { XaiOAuthSection } from "./XaiOAuthSection"; import { ApiKeySection } from "./shared/ApiKeySection"; import { EndpointField } from "./shared/EndpointField"; import { ModelDropdown } from "./shared/ModelDropdown"; @@ -68,6 +69,7 @@ import { type ClaudeDesktopDefaultRoute, } from "@/lib/api/providers"; import { resolveManagedAccountId } from "@/lib/authBinding"; +import { useCopilotAuth, useCodexOauth, useXaiOauth } from "./hooks"; export type ClaudeDesktopProviderFormValues = ProviderFormData & { presetId?: string; @@ -280,6 +282,9 @@ export function ClaudeDesktopProviderForm({ const [selectedCodexAccountId, setSelectedCodexAccountId] = useState< string | null >(() => resolveManagedAccountId(initialData?.meta, "codex_oauth")); + const [selectedXaiAccountId, setSelectedXaiAccountId] = useState< + string | null + >(() => resolveManagedAccountId(initialData?.meta, "xai_oauth")); const [codexFastMode, setCodexFastMode] = useState( () => initialData?.meta?.codexFastMode ?? false, ); @@ -377,13 +382,24 @@ export function ClaudeDesktopProviderForm({ ); const activeProviderType = activePreset?.providerType ?? initialData?.meta?.providerType; + const { isAuthenticated: isCopilotAuthenticated, accounts: copilotAccounts } = + useCopilotAuth(); + const { + isAuthenticated: isCodexOauthAuthenticated, + accounts: codexOauthAccounts, + } = useCodexOauth(); + const { + isAuthenticated: isXaiOauthAuthenticated, + accounts: xaiOauthAccounts, + } = useXaiOauth(); const isOfficial = initialData?.category === "official" || activePreset?.category === "official"; const usesManagedOAuth = activePreset?.requiresOAuth === true || activeProviderType === "github_copilot" || - activeProviderType === "codex_oauth"; + activeProviderType === "codex_oauth" || + activeProviderType === "xai_oauth"; // API Key 获取/邀请链接(与 Claude Code 表单同款,见 ClaudeFormFields) const apiKeyLinkCategory = activePreset?.category ?? initialData?.category; @@ -568,7 +584,7 @@ export function ClaudeDesktopProviderForm({ }); return; } - if (!baseUrl.trim()) { + if (!baseUrl.trim() && !usesManagedOAuth) { toast.error( t("providerForm.fetchModelsNeedEndpoint", { defaultValue: "请先填写接口地址", @@ -576,6 +592,61 @@ export function ClaudeDesktopProviderForm({ ); return; } + const selectedAccountIsUsable = ( + accountId: string | null, + accounts: Array<{ id: string; requires_reauth: boolean }>, + ) => + accountId === null || + accounts.some( + (account) => account.id === accountId && !account.requires_reauth, + ); + const managedAuthState = + activeProviderType === "github_copilot" + ? { + authenticated: isCopilotAuthenticated, + accountId: selectedGitHubAccountId, + accounts: copilotAccounts, + loginMessage: t("copilot.loginRequired", { + defaultValue: "请先登录 GitHub Copilot", + }), + } + : activeProviderType === "codex_oauth" + ? { + authenticated: isCodexOauthAuthenticated, + accountId: selectedCodexAccountId, + accounts: codexOauthAccounts, + loginMessage: t("codexOauth.loginRequired", { + defaultValue: "请先登录 ChatGPT 账号", + }), + } + : activeProviderType === "xai_oauth" + ? { + authenticated: isXaiOauthAuthenticated, + accountId: selectedXaiAccountId, + accounts: xaiOauthAccounts, + loginMessage: t("xaiOauth.loginRequired", { + defaultValue: "请先登录 xAI 账号", + }), + } + : null; + if (managedAuthState && !managedAuthState.authenticated) { + toast.error(managedAuthState.loginMessage); + return; + } + if ( + managedAuthState && + !selectedAccountIsUsable( + managedAuthState.accountId, + managedAuthState.accounts, + ) + ) { + toast.error( + t("managedAuth.selectedAccountUnavailable", { + defaultValue: "已绑定账号不存在或需要重新登录,请重新选择账号", + }), + ); + return; + } if (!usesManagedOAuth && !apiKey.trim()) { toast.error( t("providerForm.fetchModelsNeedApiKey", { @@ -594,7 +665,8 @@ export function ClaudeDesktopProviderForm({ })) .filter((route) => route.route || route.model); - if (mode === "proxy") { + const effectiveMode = activeProviderType === "xai_oauth" ? "proxy" : mode; + if (effectiveMode === "proxy") { // 固定四档(Sonnet / Opus / Fable / Haiku),route_id 由 UI 生成、恒合法, // 因此只要求至少填一个实际请求模型;留空档继承第一个已填档(Sonnet 优先), // 对齐 Claude Code 的兜底,保证落库四档齐全、子 agent 不会找不到模型。 @@ -654,9 +726,11 @@ export function ClaudeDesktopProviderForm({ Record >((acc, route) => { acc[route.route] = { - model: mode === "direct" ? route.route : route.model || route.route, + model: + effectiveMode === "direct" ? route.route : route.model || route.route, labelOverride: - route.labelOverride || (mode === "proxy" ? route.model : undefined), + route.labelOverride || + (effectiveMode === "proxy" ? route.model : undefined), supports1m: route.supports1m || undefined, }; return acc; @@ -664,8 +738,13 @@ export function ClaudeDesktopProviderForm({ const meta: ProviderMeta = { ...(initialData?.meta ?? {}), - claudeDesktopMode: mode, - apiFormat: mode === "proxy" ? apiFormat : "anthropic", + claudeDesktopMode: effectiveMode, + apiFormat: + activeProviderType === "xai_oauth" + ? "openai_responses" + : effectiveMode === "proxy" + ? apiFormat + : "anthropic", }; meta.claudeDesktopModelRoutes = routeMap; @@ -683,7 +762,13 @@ export function ClaudeDesktopProviderForm({ authProvider: "codex_oauth", accountId: selectedCodexAccountId ?? undefined, } - : undefined; + : activeProviderType === "xai_oauth" + ? { + source: "managed_account", + authProvider: "xai_oauth", + accountId: selectedXaiAccountId ?? undefined, + } + : undefined; meta.codexFastMode = activeProviderType === "codex_oauth" ? codexFastMode : undefined; @@ -773,13 +858,18 @@ export function ClaudeDesktopProviderForm({ selectedAccountId={selectedGitHubAccountId} onAccountSelect={setSelectedGitHubAccountId} /> - ) : ( + ) : activeProviderType === "codex_oauth" ? ( + ) : ( + )} ) : ( @@ -835,6 +925,7 @@ export function ClaudeDesktopProviderForm({ -
- - -
+ {activeProviderType !== "xai_oauth" && ( +
+ + +
+ )}
diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index 57ee93883..089dfe26a 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -36,6 +36,7 @@ import EndpointSpeedTest from "./EndpointSpeedTest"; import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared"; import { CopilotAuthSection } from "./CopilotAuthSection"; import { CodexOAuthSection } from "./CodexOAuthSection"; +import { XaiOAuthSection } from "./XaiOAuthSection"; import { copilotGetModels, copilotGetModelsForAccount, @@ -43,6 +44,7 @@ import { import type { CopilotModel } from "@/lib/api/copilot"; import { fetchCodexOauthModels, + fetchXaiOauthModels, fetchModelsForConfig, showFetchModelsError, type FetchedModel, @@ -98,6 +100,12 @@ interface ClaudeFormFieldsProps { codexFastMode?: boolean; onCodexFastModeChange?: (enabled: boolean) => void; + // xAI OAuth + isXaiOauthPreset?: boolean; + isXaiOauthAuthenticated?: boolean; + selectedXaiAccountId?: string | null; + onXaiAccountSelect?: (accountId: string | null) => void; + // Template Values templateValueEntries: Array<[string, TemplateValueConfig]>; templateValues: Record; @@ -174,6 +182,10 @@ export function ClaudeFormFields({ onCodexAccountSelect, codexFastMode, onCodexFastModeChange, + isXaiOauthPreset, + isXaiOauthAuthenticated, + selectedXaiAccountId, + onXaiAccountSelect, templateValueEntries, templateValues, templatePresetName, @@ -224,19 +236,23 @@ export function ClaudeFormFields({ defaultOpusModel || defaultFableModel || subagentModel || - apiFormat !== "anthropic" || + (!isXaiOauthPreset && apiFormat !== "anthropic") || apiKeyField !== "ANTHROPIC_AUTH_TOKEN" || customUserAgent || hasRequestOverrides ); - const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue); + const [advancedExpanded, setAdvancedExpanded] = useState( + isXaiOauthPreset ? false : hasAnyAdvancedValue, + ); // 预设填充高级值后自动展开(仅从折叠→展开,不会自动折叠) useEffect(() => { - if (hasAnyAdvancedValue) { + if (isXaiOauthPreset) { + setAdvancedExpanded(false); + } else if (hasAnyAdvancedValue) { setAdvancedExpanded(true); } - }, [hasAnyAdvancedValue]); + }, [hasAnyAdvancedValue, isXaiOauthPreset]); // Copilot 可用模型列表 const [copilotModels, setCopilotModels] = useState([]); @@ -247,6 +263,10 @@ export function ClaudeFormFields({ const [codexOauthModels, setCodexOauthModels] = useState([]); const [codexOauthModelsLoading, setCodexOauthModelsLoading] = useState(false); const codexOauthModelsRequestRef = useRef(0); + + const [xaiOauthModels, setXaiOauthModels] = useState([]); + const [xaiOauthModelsLoading, setXaiOauthModelsLoading] = useState(false); + const xaiOauthModelsRequestRef = useRef(0); const fallbackUsesOneM = hasClaudeOneMMarker(claudeModel); // 通用模型获取(非 Copilot 供应商) @@ -373,6 +393,37 @@ export function ClaudeFormFields({ t, ]); + const handleFetchXaiOauthModels = useCallback(() => { + if (!isXaiOauthAuthenticated) { + toast.error( + t("xaiOauth.loginRequired", { + defaultValue: "请先登录 xAI 账号", + }), + ); + return; + } + + const requestId = xaiOauthModelsRequestRef.current + 1; + xaiOauthModelsRequestRef.current = requestId; + setXaiOauthModelsLoading(true); + fetchXaiOauthModels(selectedXaiAccountId) + .then((models) => { + if (xaiOauthModelsRequestRef.current !== requestId) return; + setXaiOauthModels(models); + showModelFetchResult(models.length); + }) + .catch((err) => { + if (xaiOauthModelsRequestRef.current !== requestId) return; + console.warn("[XaiOAuth] Failed to fetch models:", err); + showFetchModelsError(err, t); + }) + .finally(() => { + if (xaiOauthModelsRequestRef.current === requestId) { + setXaiOauthModelsLoading(false); + } + }); + }, [isXaiOauthAuthenticated, selectedXaiAccountId, showModelFetchResult, t]); + useEffect(() => { copilotModelsRequestRef.current += 1; setCopilotModels([]); @@ -385,16 +436,26 @@ export function ClaudeFormFields({ setCodexOauthModelsLoading(false); }, [isCodexOauthPreset, isCodexOauthAuthenticated, selectedCodexAccountId]); + useEffect(() => { + xaiOauthModelsRequestRef.current += 1; + setXaiOauthModels([]); + setXaiOauthModelsLoading(false); + }, [isXaiOauthPreset, isXaiOauthAuthenticated, selectedXaiAccountId]); + const modelFetchLoading = isCopilotPreset ? modelsLoading : isCodexOauthPreset ? codexOauthModelsLoading - : isFetchingModels; + : isXaiOauthPreset + ? xaiOauthModelsLoading + : isFetchingModels; const handleModelFetchClick = isCopilotPreset ? handleFetchCopilotModels : isCodexOauthPreset ? handleFetchCodexOauthModels - : handleFetchModels; + : isXaiOauthPreset + ? handleFetchXaiOauthModels + : handleFetchModels; // 模型输入框:支持手动输入 + 下拉选择 const renderModelInput = ( @@ -420,6 +481,19 @@ export function ClaudeFormFields({ ); } + if (isXaiOauthPreset) { + return ( + + ); + } + if (isCopilotPreset && copilotModels.length > 0) { // 按 vendor 分组 const grouped: Record = {}; @@ -619,6 +693,13 @@ export function ClaudeFormFields({ /> )} + {isXaiOauthPreset && ( + + )} + {/* API Key 输入框(非 OAuth 预设时显示) */} {shouldShowApiKey && !usesOAuth && ( onEndpointModalToggle(true) : undefined } - showFullUrlToggle={showEndpointTools} + showFullUrlToggle={showEndpointTools && !isXaiOauthPreset} isFullUrl={isFullUrl} onFullUrlChange={onFullUrlChange} /> @@ -739,7 +820,7 @@ export function ClaudeFormFields({ )} {/* API 格式选择(仅非云服务商显示) */} - {category !== "cloud_provider" && ( + {category !== "cloud_provider" && !isXaiOauthPreset && (
{t("providerForm.apiFormat", { defaultValue: "API 格式" })} diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index ec118dd1c..59dc3d590 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -107,6 +107,7 @@ import { useHermesFormState, useCopilotAuth, useCodexOauth, + useXaiOauth, } from "./hooks"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { useSettingsQuery } from "@/lib/query"; @@ -514,10 +515,19 @@ function ProviderFormFull({ ); // Copilot OAuth 认证状态(仅 Claude 应用需要) - const { isAuthenticated: isCopilotAuthenticated } = useCopilotAuth(); + const { isAuthenticated: isCopilotAuthenticated, accounts: copilotAccounts } = + useCopilotAuth(); // Codex OAuth 认证状态(ChatGPT Plus/Pro 反代) - const { isAuthenticated: isCodexOauthAuthenticated } = useCodexOauth(); + const { + isAuthenticated: isCodexOauthAuthenticated, + accounts: codexOauthAccounts, + } = useCodexOauth(); + + const { + isAuthenticated: isXaiOauthAuthenticated, + accounts: xaiOauthAccounts, + } = useXaiOauth(); // 选中的 GitHub 账号 ID(多账号支持) const [selectedGitHubAccountId, setSelectedGitHubAccountId] = useState< @@ -528,6 +538,9 @@ function ProviderFormFull({ const [selectedCodexAccountId, setSelectedCodexAccountId] = useState< string | null >(() => resolveManagedAccountId(initialData?.meta, "codex_oauth")); + const [selectedXaiAccountId, setSelectedXaiAccountId] = useState< + string | null + >(() => resolveManagedAccountId(initialData?.meta, "xai_oauth")); const [codexFastMode, setCodexFastMode] = useState( () => initialData?.meta?.codexFastMode ?? false, ); @@ -1146,6 +1159,9 @@ function ProviderFormFull({ const isCodexOauthProvider = templatePreset?.providerType === "codex_oauth" || initialData?.meta?.providerType === "codex_oauth"; + const isXaiOauthProvider = + templatePreset?.providerType === "xai_oauth" || + initialData?.meta?.providerType === "xai_oauth"; if (isCopilotProvider && !isCopilotAuthenticated) { toast.error( t("copilot.loginRequired", { @@ -1162,6 +1178,56 @@ function ProviderFormFull({ ); return; } + if (isXaiOauthProvider && !isXaiOauthAuthenticated) { + toast.error( + t("xaiOauth.loginRequired", { + defaultValue: "请先登录 xAI 账号", + }), + ); + return; + } + + const selectedAccountIsUsable = ( + accountId: string | null, + accounts: Array<{ id: string; requires_reauth: boolean }>, + ) => + accountId === null || + accounts.some( + (account) => account.id === accountId && !account.requires_reauth, + ); + if ( + isCopilotProvider && + !selectedAccountIsUsable(selectedGitHubAccountId, copilotAccounts) + ) { + toast.error( + t("managedAuth.selectedAccountUnavailable", { + defaultValue: "已绑定账号不存在,请重新选择账号", + }), + ); + return; + } + if ( + isCodexOauthProvider && + !selectedAccountIsUsable(selectedCodexAccountId, codexOauthAccounts) + ) { + toast.error( + t("managedAuth.selectedAccountUnavailable", { + defaultValue: "已绑定账号不存在,请重新选择账号", + }), + ); + return; + } + if ( + isXaiOauthProvider && + !selectedAccountIsUsable(selectedXaiAccountId, xaiOauthAccounts) + ) { + toast.error( + t("managedAuth.selectedAccountNeedsReauth", { + defaultValue: "已绑定 xAI 账号不存在或需要重新登录", + }), + ); + return; + } // OMO Other Fields JSON:B 类(格式错了保存下去数据就坏了) if ( @@ -1198,14 +1264,19 @@ function ProviderFormFull({ // cloud_provider(如 Bedrock)通过模板变量处理认证,跳过通用校验 if (category !== "official" && category !== "cloud_provider") { if (appId === "claude") { - if (!isCodexOauthProvider && !baseUrl.trim()) { + if (!isCodexOauthProvider && !isXaiOauthProvider && !baseUrl.trim()) { issues.push( t("providerForm.endpointRequired", { defaultValue: "非官方供应商请填写 API 端点", }), ); } - if (!isCopilotProvider && !isCodexOauthProvider && !apiKey.trim()) { + if ( + !isCopilotProvider && + !isCodexOauthProvider && + !isXaiOauthProvider && + !apiKey.trim() + ) { issues.push( t("providerForm.apiKeyRequired", { defaultValue: "非官方供应商请填写 API Key", @@ -1278,6 +1349,9 @@ function ProviderFormFull({ const isCodexOauthProvider = templatePreset?.providerType === "codex_oauth" || initialData?.meta?.providerType === "codex_oauth"; + const isXaiOauthProvider = + templatePreset?.providerType === "xai_oauth" || + initialData?.meta?.providerType === "xai_oauth"; let settingsConfig: string; @@ -1483,7 +1557,13 @@ function ProviderFormFull({ authProvider: "codex_oauth", accountId: selectedCodexAccountId ?? undefined, } - : undefined, + : isXaiOauthProvider + ? { + source: "managed_account", + authProvider: "xai_oauth", + accountId: selectedXaiAccountId ?? undefined, + } + : undefined, // GitHub Copilot 多账号:保存关联的账号 ID githubAccountId: isCopilotProvider && selectedGitHubAccountId @@ -1519,7 +1599,9 @@ function ProviderFormFull({ : undefined, apiFormat: appId === "claude" && category !== "official" - ? localApiFormat + ? isXaiOauthProvider + ? "openai_responses" + : localApiFormat : appId === "codex" && category !== "official" ? localCodexApiFormat : undefined, @@ -1552,7 +1634,10 @@ function ProviderFormFull({ ? Number(localCodexMaxOutputTokens) : undefined, isFullUrl: - supportsFullUrl && category !== "official" && localIsFullUrl + supportsFullUrl && + category !== "official" && + !isXaiOauthProvider && + localIsFullUrl ? true : undefined, }; @@ -2109,13 +2194,19 @@ function ProviderFormFull({ templatePreset?.providerType === "codex_oauth" || initialData?.meta?.providerType === "codex_oauth" } + isXaiOauthPreset={ + templatePreset?.providerType === "xai_oauth" || + initialData?.meta?.providerType === "xai_oauth" + } usesOAuth={ templatePreset?.requiresOAuth === true || templatePreset?.providerType === "github_copilot" || initialData?.meta?.providerType === "github_copilot" || baseUrl.includes("githubcopilot.com") || templatePreset?.providerType === "codex_oauth" || - initialData?.meta?.providerType === "codex_oauth" + initialData?.meta?.providerType === "codex_oauth" || + templatePreset?.providerType === "xai_oauth" || + initialData?.meta?.providerType === "xai_oauth" } isCopilotAuthenticated={isCopilotAuthenticated} selectedGitHubAccountId={selectedGitHubAccountId} @@ -2125,6 +2216,9 @@ function ProviderFormFull({ onCodexAccountSelect={setSelectedCodexAccountId} codexFastMode={codexFastMode} onCodexFastModeChange={setCodexFastMode} + isXaiOauthAuthenticated={isXaiOauthAuthenticated} + selectedXaiAccountId={selectedXaiAccountId} + onXaiAccountSelect={setSelectedXaiAccountId} templateValueEntries={templateValueEntries} templateValues={templateValues} templatePresetName={templatePreset?.name || ""} diff --git a/src/components/providers/forms/XaiOAuthSection.tsx b/src/components/providers/forms/XaiOAuthSection.tsx new file mode 100644 index 000000000..47ce02bbe --- /dev/null +++ b/src/components/providers/forms/XaiOAuthSection.tsx @@ -0,0 +1,326 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { + AlertTriangle, + Check, + Copy, + ExternalLink, + Loader2, + LogOut, + Plus, + Sparkles, + User, + X, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { copyText } from "@/lib/clipboard"; +import { useXaiOauth } from "./hooks/useXaiOauth"; + +interface XaiOAuthSectionProps { + className?: string; + selectedAccountId?: string | null; + onAccountSelect?: (accountId: string | null) => void; +} + +export const XaiOAuthSection: React.FC = ({ + className, + selectedAccountId, + onAccountSelect, +}) => { + const { t } = useTranslation(); + const [copied, setCopied] = React.useState(false); + const { + accounts, + defaultAccountId, + hasAnyAccount, + isAuthenticated, + pollingState, + deviceCode, + error, + isPolling, + isAddingAccount, + isRemovingAccount, + isSettingDefaultAccount, + addAccount, + removeAccount, + setDefaultAccount, + cancelAuth, + logout, + } = useXaiOauth(); + + const usableAccounts = accounts.filter((account) => !account.requires_reauth); + + const copyUserCode = async () => { + if (!deviceCode?.user_code) return; + await copyText(deviceCode.user_code); + setCopied(true); + setTimeout(() => setCopied(false), 2_000); + }; + + const remove = (accountId: string, event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + removeAccount(accountId); + if (selectedAccountId === accountId) onAccountSelect?.(null); + }; + + return ( +
+
+ + + {isAuthenticated + ? t("xaiOauth.accountCount", { + count: usableAccounts.length, + defaultValue: `${usableAccounts.length} 个可用账号`, + }) + : hasAnyAccount + ? t("xaiOauth.reauthRequired", "需要重新登录") + : t("xaiOauth.notAuthenticated", "未认证")} + +
+ + {accounts.length > 0 && onAccountSelect && ( +
+ + +
+ )} + + {hasAnyAccount && ( +
+ +
+ {accounts.map((account) => ( +
+
+ {account.requires_reauth ? ( + + ) : ( + + )} + + {account.login} + + {defaultAccountId === account.id && ( + + {t("xaiOauth.defaultAccount", "默认")} + + )} + {account.requires_reauth && ( + + {t("xaiOauth.expired", "凭据已失效")} + + )} +
+
+ {!account.requires_reauth && + defaultAccountId !== account.id && ( + + )} + +
+
+ ))} +
+
+ )} + + {pollingState === "idle" && ( + + )} + + {isPolling && deviceCode && ( +
+
+ + {t("xaiOauth.waitingForAuth", "等待 xAI 授权中…")} +
+
+

+ {t("xaiOauth.enterCode", "若浏览器未自动填入,请输入:")} +

+
+ + {deviceCode.user_code} + + +
+
+ +
+ +
+
+ )} + + {pollingState === "error" && error && ( +
+

{error}

+
+ + +
+
+ )} + + {hasAnyAccount && accounts.length > 1 && ( + + )} +
+ ); +}; + +export default XaiOAuthSection; diff --git a/src/components/providers/forms/hooks/index.ts b/src/components/providers/forms/hooks/index.ts index f70b1daf3..3c98c2645 100644 --- a/src/components/providers/forms/hooks/index.ts +++ b/src/components/providers/forms/hooks/index.ts @@ -20,3 +20,4 @@ export { useOpenclawFormState } from "./useOpenclawFormState"; export { useHermesFormState } from "./useHermesFormState"; export { useCopilotAuth } from "./useCopilotAuth"; export { useCodexOauth } from "./useCodexOauth"; +export { useXaiOauth } from "./useXaiOauth"; diff --git a/src/components/providers/forms/hooks/useManagedAuth.ts b/src/components/providers/forms/hooks/useManagedAuth.ts index 86360b397..08bb7d99e 100644 --- a/src/components/providers/forms/hooks/useManagedAuth.ts +++ b/src/components/providers/forms/hooks/useManagedAuth.ts @@ -35,6 +35,10 @@ export function useManagedAuth( queryKey, queryFn: () => authApi.authGetStatus(authProvider), staleTime: 30000, + // A rejected xAI refresh token is persisted as `requires_reauth` by the + // proxy hot path. Periodically refresh local status so an already-open Auth + // Center stops showing the account as logged in without requiring a reload. + refetchInterval: authProvider === "xai_oauth" ? 15_000 : false, }); const stopPolling = useCallback(() => { diff --git a/src/components/providers/forms/hooks/useXaiOauth.ts b/src/components/providers/forms/hooks/useXaiOauth.ts new file mode 100644 index 000000000..33a43efa2 --- /dev/null +++ b/src/components/providers/forms/hooks/useXaiOauth.ts @@ -0,0 +1,6 @@ +import { useManagedAuth } from "./useManagedAuth"; + +/** xAI OAuth device-code authentication hook. */ +export function useXaiOauth() { + return useManagedAuth("xai_oauth"); +} diff --git a/src/components/settings/AuthCenterPanel.tsx b/src/components/settings/AuthCenterPanel.tsx index 04dd51359..0726b5974 100644 --- a/src/components/settings/AuthCenterPanel.tsx +++ b/src/components/settings/AuthCenterPanel.tsx @@ -4,6 +4,8 @@ import { Badge } from "@/components/ui/badge"; import { CodexIcon } from "@/components/BrandIcons"; import { CopilotAuthSection } from "@/components/providers/forms/CopilotAuthSection"; import { CodexOAuthSection } from "@/components/providers/forms/CodexOAuthSection"; +import { XaiOAuthSection } from "@/components/providers/forms/XaiOAuthSection"; +import { ProviderIcon } from "@/components/ProviderIcon"; export function AuthCenterPanel() { const { t } = useTranslation(); @@ -69,6 +71,24 @@ export function AuthCenterPanel() { + +
+
+
+ +
+
+

xAI (Grok OAuth)

+

+ {t("settings.authCenter.xaiOauthDescription", { + defaultValue: "管理 xAI / Grok 账号", + })} +

+
+
+ + +
); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 08f42b1ad..c962f2842 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -326,7 +326,8 @@ "description": "Use your other subscriptions in Claude Code — please be mindful of compliance risks.", "beta": "Beta", "copilotDescription": "Manage GitHub Copilot accounts", - "codexOauthDescription": "Manage ChatGPT accounts" + "codexOauthDescription": "Manage ChatGPT accounts", + "xaiOauthDescription": "Manage xAI / Grok accounts" }, "advanced": { "configDir": { @@ -1014,6 +1015,7 @@ "customApiKeyHint": "💡 Custom configuration requires manually filling all necessary fields", "omoHint": "💡 OMO config manages Agent model assignments and supports both oh-my-openagent.jsonc and oh-my-opencode.jsonc", "officialHint": "💡 Official provider uses browser login, no API Key needed", + "providerKeyStatusLoading": "Provider identifier status is still loading. Please try again shortly.", "getApiKey": "Get API Key", "partnerPromotion": { "sudocode": "With one SudoCode key, Claude Code and Claude Desktop use Claude Opus 4.8, while Codex uses GPT-5.6. Sign up, join QQ group 726213516, and contact the group owner to claim CNY ¥10 in trial credit.", @@ -1222,6 +1224,31 @@ "fastMode": "FAST mode", "fastModeDescription": "Send service_tier=\"priority\" for lower latency. Off by default — enabling it consumes your ChatGPT quota at a higher rate." }, + "xaiOauth": { + "authStatus": "xAI OAuth authentication", + "accountCount": "Usable accounts: {{count}}", + "reauthRequired": "Sign-in required", + "notAuthenticated": "Not authenticated", + "selectAccount": "Select account", + "selectAccountPlaceholder": "Select an xAI account", + "useDefaultAccount": "Use default account", + "accounts": "xAI accounts", + "defaultAccount": "Default", + "expired": "Credentials expired", + "setAsDefault": "Set as default", + "removeAccount": "Remove account", + "addOrReauth": "Add account or sign in again", + "login": "Sign in with xAI", + "waitingForAuth": "Waiting for xAI authorization…", + "enterCode": "If the browser did not fill it in automatically, enter:", + "retry": "Retry", + "logoutAll": "Remove all xAI accounts", + "loginRequired": "Sign in to an xAI account first" + }, + "managedAuth": { + "selectedAccountNeedsReauth": "The linked account is missing or needs you to sign in again", + "selectedAccountUnavailable": "The linked account is missing or needs you to sign in again. Select another account." + }, "endpointTest": { "title": "API Endpoint Management", "endpoints": "endpoints", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 4fe3d8229..9f8aa2400 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -326,7 +326,8 @@ "description": "Claude Code で他のサブスクリプションをご利用いただけます。コンプライアンスリスクにご注意ください。", "beta": "Beta", "copilotDescription": "GitHub Copilot アカウントを管理します", - "codexOauthDescription": "ChatGPT アカウントを管理します" + "codexOauthDescription": "ChatGPT アカウントを管理します", + "xaiOauthDescription": "xAI / Grok アカウントを管理します" }, "advanced": { "configDir": { @@ -1014,6 +1015,7 @@ "customApiKeyHint": "💡 カスタム設定では必要な項目をすべて手動で入力してください", "omoHint": "💡 OMO 設定は Agent のモデル割り当てを管理し、oh-my-openagent.jsonc / oh-my-opencode.jsonc の両方に対応します", "officialHint": "💡 公式プロバイダーはブラウザログインで、API Key は不要です", + "providerKeyStatusLoading": "プロバイダー識別子の状態を読み込んでいます。しばらくしてからもう一度お試しください", "getApiKey": "API Key を取得", "partnerPromotion": { "sudocode": "SudoCode の1つのキーで、Claude Code と Claude Desktop では Claude Opus 4.8、Codex では GPT-5.6 を利用できます。登録後 QQ グループ 726213516 に参加し、管理者へ連絡すると CNY ¥10 のトライアルクレジットを受け取れます。", @@ -1222,6 +1224,31 @@ "fastMode": "FAST モード", "fastModeDescription": "低遅延のため service_tier=\"priority\" を送信します。既定ではオフ——オンにすると ChatGPT のクォータがより速く消費されます。" }, + "xaiOauth": { + "authStatus": "xAI OAuth 認証", + "accountCount": "利用可能なアカウント: {{count}} 件", + "reauthRequired": "再ログインが必要です", + "notAuthenticated": "未認証", + "selectAccount": "アカウントを選択", + "selectAccountPlaceholder": "xAI アカウントを選択", + "useDefaultAccount": "デフォルトアカウントを使用", + "accounts": "xAI アカウント", + "defaultAccount": "デフォルト", + "expired": "認証情報が失効しました", + "setAsDefault": "デフォルトに設定", + "removeAccount": "アカウントを削除", + "addOrReauth": "アカウントを追加または再ログイン", + "login": "xAI でログイン", + "waitingForAuth": "xAI の認証を待っています…", + "enterCode": "ブラウザに自動入力されていない場合は、次を入力してください:", + "retry": "再試行", + "logoutAll": "すべての xAI アカウントを削除", + "loginRequired": "先に xAI アカウントへログインしてください" + }, + "managedAuth": { + "selectedAccountNeedsReauth": "リンクされたアカウントが存在しないか、再ログインが必要です", + "selectedAccountUnavailable": "リンクされたアカウントが存在しないか、再ログインが必要です。別のアカウントを選択してください" + }, "endpointTest": { "title": "API エンドポイント管理", "endpoints": "エンドポイント", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index f782f5c44..5ea6823ed 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -326,7 +326,8 @@ "description": "在 Claude Code 中使用您的其他訂閱,請注意合規風險。", "beta": "Beta", "copilotDescription": "管理 GitHub Copilot 帳號", - "codexOauthDescription": "管理 ChatGPT 帳號" + "codexOauthDescription": "管理 ChatGPT 帳號", + "xaiOauthDescription": "管理 xAI / Grok 帳號" }, "advanced": { "configDir": { @@ -985,6 +986,7 @@ "customApiKeyHint": "自訂設定需手動填寫所有必要欄位", "omoHint": "OMO 設定管理 Agent 模型指派,相容 oh-my-openagent.jsonc / oh-my-opencode.jsonc", "officialHint": "官方供應商使用瀏覽器登入,無需設定 API Key", + "providerKeyStatusLoading": "正在載入供應商識別碼狀態,請稍後再試", "getApiKey": "取得 API Key", "partnerPromotion": { "sudocode": "SudoCode 讓 Claude Code 與 Claude Desktop 使用 Claude Opus 4.8,Codex 使用 GPT-5.6,一個 Key 統一管理。CC Switch 使用者註冊並加入 QQ 群 726213516,聯絡群主領取人民幣 ¥10 試用額度。", @@ -1194,6 +1196,31 @@ "fastMode": "FAST 模式", "fastModeDescription": "發送 service_tier=\"priority\" 換取更低延遲。預設關閉——開啟後會以更高速率消耗 ChatGPT 配額。" }, + "xaiOauth": { + "authStatus": "xAI OAuth 驗證", + "accountCount": "{{count}} 個可用帳號", + "reauthRequired": "需要重新登入", + "notAuthenticated": "尚未驗證", + "selectAccount": "選擇帳號", + "selectAccountPlaceholder": "選擇 xAI 帳號", + "useDefaultAccount": "使用預設帳號", + "accounts": "xAI 帳號", + "defaultAccount": "預設", + "expired": "憑證已失效", + "setAsDefault": "設為預設", + "removeAccount": "移除帳號", + "addOrReauth": "新增帳號或重新登入", + "login": "使用 xAI 登入", + "waitingForAuth": "正在等待 xAI 授權…", + "enterCode": "若瀏覽器未自動填入,請輸入:", + "retry": "重試", + "logoutAll": "移除所有 xAI 帳號", + "loginRequired": "請先登入 xAI 帳號" + }, + "managedAuth": { + "selectedAccountNeedsReauth": "已綁定的帳號不存在或需要重新登入", + "selectedAccountUnavailable": "已綁定的帳號不存在或需要重新登入,請重新選擇帳號" + }, "endpointTest": { "title": "請求位址管理", "endpoints": "個端點", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7bf167a22..0545bb8d8 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -326,7 +326,8 @@ "description": "在 Claude Code 中使用您的其他订阅,请注意合规风险。", "beta": "Beta", "copilotDescription": "管理 GitHub Copilot 账号", - "codexOauthDescription": "管理 ChatGPT 账号" + "codexOauthDescription": "管理 ChatGPT 账号", + "xaiOauthDescription": "管理 xAI / Grok 账号" }, "advanced": { "configDir": { @@ -1014,6 +1015,7 @@ "customApiKeyHint": "💡 自定义配置需手动填写所有必要字段", "omoHint": "💡 OMO 配置管理 Agent 模型分配,兼容 oh-my-openagent.jsonc / oh-my-opencode.jsonc", "officialHint": "💡 官方供应商使用浏览器登录,无需配置 API Key", + "providerKeyStatusLoading": "正在加载供应商标识状态,请稍后再试", "getApiKey": "获取 API Key", "partnerPromotion": { "sudocode": "SudoCode 让 Claude Code 与 Claude Desktop 接入 Claude Opus 4.8,Codex 接入 GPT-5.6,一个 Key 统一使用。CC Switch 用户注册并加入 QQ 群 726213516,联系群主领取 ¥10 试用额度。", @@ -1222,6 +1224,31 @@ "fastMode": "FAST 模式", "fastModeDescription": "发送 service_tier=\"priority\" 换取更低延迟。默认关闭——开启后会按更高速率消耗 ChatGPT 配额。" }, + "xaiOauth": { + "authStatus": "xAI OAuth 认证", + "accountCount": "{{count}} 个可用账号", + "reauthRequired": "需要重新登录", + "notAuthenticated": "未认证", + "selectAccount": "选择账号", + "selectAccountPlaceholder": "选择 xAI 账号", + "useDefaultAccount": "使用默认账号", + "accounts": "xAI 账号", + "defaultAccount": "默认", + "expired": "凭据已失效", + "setAsDefault": "设为默认", + "removeAccount": "移除账号", + "addOrReauth": "添加账号或重新登录", + "login": "使用 xAI 登录", + "waitingForAuth": "等待 xAI 授权中…", + "enterCode": "若浏览器未自动填入,请输入:", + "retry": "重试", + "logoutAll": "移除所有 xAI 账号", + "loginRequired": "请先登录 xAI 账号" + }, + "managedAuth": { + "selectedAccountNeedsReauth": "已绑定账号不存在或需要重新登录", + "selectedAccountUnavailable": "已绑定账号不存在或需要重新登录,请重新选择账号" + }, "endpointTest": { "title": "请求地址管理", "endpoints": "个端点", diff --git a/src/lib/api/auth.ts b/src/lib/api/auth.ts index 294661802..86fb7ba6e 100644 --- a/src/lib/api/auth.ts +++ b/src/lib/api/auth.ts @@ -1,6 +1,9 @@ import { invoke } from "@tauri-apps/api/core"; -export type ManagedAuthProvider = "github_copilot" | "codex_oauth"; +export type ManagedAuthProvider = + | "github_copilot" + | "codex_oauth" + | "xai_oauth"; export interface ManagedAuthAccount { id: string; @@ -10,6 +13,7 @@ export interface ManagedAuthAccount { authenticated_at: number; is_default: boolean; github_domain: string; + requires_reauth: boolean; } export interface ManagedAuthStatus { diff --git a/src/lib/api/model-fetch.ts b/src/lib/api/model-fetch.ts index 6b6d89f3d..c3601273f 100644 --- a/src/lib/api/model-fetch.ts +++ b/src/lib/api/model-fetch.ts @@ -42,6 +42,15 @@ export async function fetchCodexOauthModels( }); } +/** 获取当前 xAI OAuth 账号可访问的模型列表。 */ +export async function fetchXaiOauthModels( + accountId?: string | null, +): Promise { + return invoke("get_xai_oauth_models", { + accountId: accountId || null, + }); +} + /** * 根据错误类型显示对应的 toast 提示 */ diff --git a/tests/components/XaiOAuthSection.test.tsx b/tests/components/XaiOAuthSection.test.tsx new file mode 100644 index 000000000..d167965f2 --- /dev/null +++ b/tests/components/XaiOAuthSection.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { XaiOAuthSection } from "@/components/providers/forms/XaiOAuthSection"; + +const mockUseXaiOauth = vi.hoisted(() => vi.fn()); + +vi.mock("@/components/providers/forms/hooks/useXaiOauth", () => ({ + useXaiOauth: mockUseXaiOauth, +})); + +describe("XaiOAuthSection", () => { + beforeEach(() => { + mockUseXaiOauth.mockReturnValue({ + accounts: [ + { + id: "expired-account", + login: "expired@example.com", + avatar_url: null, + authenticated_at: 1, + github_domain: "x.ai", + requires_reauth: true, + }, + { + id: "usable-account", + login: "usable@example.com", + avatar_url: null, + authenticated_at: 2, + github_domain: "x.ai", + requires_reauth: false, + }, + ], + defaultAccountId: "usable-account", + hasAnyAccount: true, + isAuthenticated: true, + pollingState: "idle", + deviceCode: null, + error: null, + isPolling: false, + isAddingAccount: false, + isRemovingAccount: false, + isSettingDefaultAccount: false, + addAccount: vi.fn(), + removeAccount: vi.fn(), + setDefaultAccount: vi.fn(), + cancelAuth: vi.fn(), + logout: vi.fn(), + }); + }); + + it("keeps a selected account visible when it requires reauthentication", () => { + render( + , + ); + + expect(screen.getByRole("combobox")).toHaveTextContent( + "expired@example.com", + ); + expect(screen.getByRole("combobox")).toHaveTextContent("凭据已失效"); + }); +}); diff --git a/tests/config/xaiOauthLocales.test.ts b/tests/config/xaiOauthLocales.test.ts new file mode 100644 index 000000000..c42186e15 --- /dev/null +++ b/tests/config/xaiOauthLocales.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import en from "@/i18n/locales/en.json"; +import ja from "@/i18n/locales/ja.json"; +import zhTW from "@/i18n/locales/zh-TW.json"; +import zh from "@/i18n/locales/zh.json"; + +const requiredKeys = [ + "xaiOauth.authStatus", + "xaiOauth.accountCount", + "xaiOauth.reauthRequired", + "xaiOauth.notAuthenticated", + "xaiOauth.selectAccount", + "xaiOauth.selectAccountPlaceholder", + "xaiOauth.useDefaultAccount", + "xaiOauth.accounts", + "xaiOauth.defaultAccount", + "xaiOauth.expired", + "xaiOauth.setAsDefault", + "xaiOauth.removeAccount", + "xaiOauth.addOrReauth", + "xaiOauth.login", + "xaiOauth.waitingForAuth", + "xaiOauth.enterCode", + "xaiOauth.retry", + "xaiOauth.logoutAll", + "xaiOauth.loginRequired", + "managedAuth.selectedAccountNeedsReauth", + "managedAuth.selectedAccountUnavailable", + "providerForm.providerKeyStatusLoading", + "settings.authCenter.xaiOauthDescription", +] as const; + +type TranslationTree = Record; + +function readTranslation(tree: TranslationTree, path: string): unknown { + return path.split(".").reduce((value, segment) => { + if (typeof value !== "object" || value === null) return undefined; + return (value as TranslationTree)[segment]; + }, tree); +} + +describe("xAI OAuth locale coverage", () => { + it.each([ + ["zh", zh], + ["zh-TW", zhTW], + ["en", en], + ["ja", ja], + ])("defines every required key in %s", (_locale, translations) => { + const missing = requiredKeys.filter((key) => { + const value = readTranslation(translations, key); + return typeof value !== "string" || value.trim().length === 0; + }); + + expect(missing).toEqual([]); + }); +});