mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 04:22:58 +08:00
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.
This commit is contained in:
@@ -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<boolean>(
|
||||
() => 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<string, ClaudeDesktopModelRoute>
|
||||
>((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" ? (
|
||||
<CodexOAuthSection
|
||||
selectedAccountId={selectedCodexAccountId}
|
||||
onAccountSelect={setSelectedCodexAccountId}
|
||||
fastModeEnabled={codexFastMode}
|
||||
onFastModeChange={setCodexFastMode}
|
||||
/>
|
||||
) : (
|
||||
<XaiOAuthSection
|
||||
selectedAccountId={selectedXaiAccountId}
|
||||
onAccountSelect={setSelectedXaiAccountId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -835,6 +925,7 @@ export function ClaudeDesktopProviderForm({
|
||||
<Switch
|
||||
checked={needsModelMapping}
|
||||
onCheckedChange={handleModelMappingChange}
|
||||
disabled={activeProviderType === "xai_oauth"}
|
||||
aria-label={t("claudeDesktop.modelMappingToggle", {
|
||||
defaultValue: "需要模型映射",
|
||||
})}
|
||||
@@ -844,44 +935,49 @@ export function ClaudeDesktopProviderForm({
|
||||
|
||||
{needsModelMapping && (
|
||||
<div className="space-y-4 rounded-lg border border-border-default p-4">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
|
||||
</Label>
|
||||
<Select
|
||||
value={apiFormat}
|
||||
onValueChange={(value) =>
|
||||
setApiFormat(value as ClaudeApiFormat)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="anthropic">
|
||||
{t("providerForm.apiFormatAnthropic", {
|
||||
defaultValue: "Anthropic Messages (原生)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="openai_chat">
|
||||
{t("providerForm.apiFormatOpenAIChat", {
|
||||
defaultValue: "OpenAI Chat Completions (需开启路由)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="openai_responses">
|
||||
{t("providerForm.apiFormatOpenAIResponses", {
|
||||
defaultValue: "OpenAI Responses API (需开启路由)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="gemini_native">
|
||||
{t("providerForm.apiFormatGeminiNative", {
|
||||
defaultValue:
|
||||
"Gemini Native generateContent (需开启路由)",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{activeProviderType !== "xai_oauth" && (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("providerForm.apiFormat", {
|
||||
defaultValue: "API 格式",
|
||||
})}
|
||||
</Label>
|
||||
<Select
|
||||
value={apiFormat}
|
||||
onValueChange={(value) =>
|
||||
setApiFormat(value as ClaudeApiFormat)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="anthropic">
|
||||
{t("providerForm.apiFormatAnthropic", {
|
||||
defaultValue: "Anthropic Messages (原生)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="openai_chat">
|
||||
{t("providerForm.apiFormatOpenAIChat", {
|
||||
defaultValue:
|
||||
"OpenAI Chat Completions (需开启路由)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="openai_responses">
|
||||
{t("providerForm.apiFormatOpenAIResponses", {
|
||||
defaultValue: "OpenAI Responses API (需开启路由)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="gemini_native">
|
||||
{t("providerForm.apiFormatGeminiNative", {
|
||||
defaultValue:
|
||||
"Gemini Native generateContent (需开启路由)",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1 border-t border-border-default pt-4">
|
||||
|
||||
@@ -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<string, TemplateValueConfig>;
|
||||
@@ -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<CopilotModel[]>([]);
|
||||
@@ -247,6 +263,10 @@ export function ClaudeFormFields({
|
||||
const [codexOauthModels, setCodexOauthModels] = useState<FetchedModel[]>([]);
|
||||
const [codexOauthModelsLoading, setCodexOauthModelsLoading] = useState(false);
|
||||
const codexOauthModelsRequestRef = useRef(0);
|
||||
|
||||
const [xaiOauthModels, setXaiOauthModels] = useState<FetchedModel[]>([]);
|
||||
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 (
|
||||
<ModelInputWithFetch
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={updateValue}
|
||||
placeholder={placeholder}
|
||||
fetchedModels={xaiOauthModels}
|
||||
isLoading={xaiOauthModelsLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCopilotPreset && copilotModels.length > 0) {
|
||||
// 按 vendor 分组
|
||||
const grouped: Record<string, CopilotModel[]> = {};
|
||||
@@ -619,6 +693,13 @@ export function ClaudeFormFields({
|
||||
/>
|
||||
)}
|
||||
|
||||
{isXaiOauthPreset && (
|
||||
<XaiOAuthSection
|
||||
selectedAccountId={selectedXaiAccountId}
|
||||
onAccountSelect={onXaiAccountSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* API Key 输入框(非 OAuth 预设时显示) */}
|
||||
{shouldShowApiKey && !usesOAuth && (
|
||||
<ApiKeySection
|
||||
@@ -693,7 +774,7 @@ export function ClaudeFormFields({
|
||||
onManageClick={
|
||||
showEndpointTools ? () => onEndpointModalToggle(true) : undefined
|
||||
}
|
||||
showFullUrlToggle={showEndpointTools}
|
||||
showFullUrlToggle={showEndpointTools && !isXaiOauthPreset}
|
||||
isFullUrl={isFullUrl}
|
||||
onFullUrlChange={onFullUrlChange}
|
||||
/>
|
||||
@@ -739,7 +820,7 @@ export function ClaudeFormFields({
|
||||
)}
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
{/* API 格式选择(仅非云服务商显示) */}
|
||||
{category !== "cloud_provider" && (
|
||||
{category !== "cloud_provider" && !isXaiOauthPreset && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="apiFormat">
|
||||
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
|
||||
|
||||
@@ -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<boolean>(
|
||||
() => 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 || ""}
|
||||
|
||||
@@ -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<XaiOAuthSectionProps> = ({
|
||||
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 (
|
||||
<div className={`space-y-4 ${className ?? ""}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t("xaiOauth.authStatus", "xAI OAuth 认证")}</Label>
|
||||
<Badge
|
||||
variant={isAuthenticated ? "default" : "secondary"}
|
||||
className={
|
||||
isAuthenticated
|
||||
? "bg-green-500 hover:bg-green-600"
|
||||
: hasAnyAccount
|
||||
? "border-amber-500 text-amber-600"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{isAuthenticated
|
||||
? t("xaiOauth.accountCount", {
|
||||
count: usableAccounts.length,
|
||||
defaultValue: `${usableAccounts.length} 个可用账号`,
|
||||
})
|
||||
: hasAnyAccount
|
||||
? t("xaiOauth.reauthRequired", "需要重新登录")
|
||||
: t("xaiOauth.notAuthenticated", "未认证")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{accounts.length > 0 && onAccountSelect && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("xaiOauth.selectAccount", "选择账号")}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedAccountId || "none"}
|
||||
onValueChange={(value) =>
|
||||
onAccountSelect(value === "none" ? null : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"xaiOauth.selectAccountPlaceholder",
|
||||
"选择 xAI 账号",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t("xaiOauth.useDefaultAccount", "使用默认账号")}
|
||||
</SelectItem>
|
||||
{accounts.map((account) => (
|
||||
<SelectItem
|
||||
key={account.id}
|
||||
value={account.id}
|
||||
disabled={account.requires_reauth}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{account.requires_reauth ? (
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
) : (
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
{account.login}
|
||||
{account.requires_reauth && (
|
||||
<span className="text-xs text-amber-600">
|
||||
({t("xaiOauth.expired", "凭据已失效")})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasAnyAccount && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("xaiOauth.accounts", "xAI 账号")}
|
||||
</Label>
|
||||
<div className="space-y-1">
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="flex items-center justify-between rounded-md border bg-muted/30 p-2"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{account.requires_reauth ? (
|
||||
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-500" />
|
||||
) : (
|
||||
<User className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate text-sm font-medium">
|
||||
{account.login}
|
||||
</span>
|
||||
{defaultAccountId === account.id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("xaiOauth.defaultAccount", "默认")}
|
||||
</Badge>
|
||||
)}
|
||||
{account.requires_reauth && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500 text-xs text-amber-600"
|
||||
>
|
||||
{t("xaiOauth.expired", "凭据已失效")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!account.requires_reauth &&
|
||||
defaultAccountId !== account.id && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={isSettingDefaultAccount}
|
||||
onClick={() => setDefaultAccount(account.id)}
|
||||
>
|
||||
{t("xaiOauth.setAsDefault", "设为默认")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-red-500"
|
||||
disabled={isRemovingAccount}
|
||||
onClick={(event) => remove(account.id, event)}
|
||||
title={t("xaiOauth.removeAccount", "移除账号")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pollingState === "idle" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={isAddingAccount}
|
||||
onClick={addAccount}
|
||||
>
|
||||
{hasAnyAccount ? (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{hasAnyAccount
|
||||
? t("xaiOauth.addOrReauth", "添加账号或重新登录")
|
||||
: t("xaiOauth.login", "使用 xAI 登录")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isPolling && deviceCode && (
|
||||
<div className="space-y-3 rounded-lg border bg-muted/50 p-4">
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("xaiOauth.waitingForAuth", "等待 xAI 授权中…")}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="mb-1 text-xs text-muted-foreground">
|
||||
{t("xaiOauth.enterCode", "若浏览器未自动填入,请输入:")}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<code className="rounded border bg-background px-4 py-2 font-mono text-2xl font-bold tracking-wider">
|
||||
{deviceCode.user_code}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={copyUserCode}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<a
|
||||
href={deviceCode.verification_uri}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-blue-500 hover:underline"
|
||||
>
|
||||
{deviceCode.verification_uri}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={cancelAuth}
|
||||
>
|
||||
{t("common.cancel", "取消")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pollingState === "error" && error && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addAccount}
|
||||
>
|
||||
{t("xaiOauth.retry", "重试")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={cancelAuth}
|
||||
>
|
||||
{t("common.cancel", "取消")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasAnyAccount && accounts.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full text-red-500 hover:text-red-600"
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{t("xaiOauth.logoutAll", "移除所有 xAI 账号")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default XaiOAuthSection;
|
||||
@@ -20,3 +20,4 @@ export { useOpenclawFormState } from "./useOpenclawFormState";
|
||||
export { useHermesFormState } from "./useHermesFormState";
|
||||
export { useCopilotAuth } from "./useCopilotAuth";
|
||||
export { useCodexOauth } from "./useCodexOauth";
|
||||
export { useXaiOauth } from "./useXaiOauth";
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useManagedAuth } from "./useManagedAuth";
|
||||
|
||||
/** xAI OAuth device-code authentication hook. */
|
||||
export function useXaiOauth() {
|
||||
return useManagedAuth("xai_oauth");
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
<CodexOAuthSection />
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border/60 bg-card/60 p-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-muted">
|
||||
<ProviderIcon icon="xai" name="xAI" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium">xAI (Grok OAuth)</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.authCenter.xaiOauthDescription", {
|
||||
defaultValue: "管理 xAI / Grok 账号",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<XaiOAuthSection />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "エンドポイント",
|
||||
|
||||
@@ -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": "個端點",
|
||||
|
||||
@@ -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": "个端点",
|
||||
|
||||
+5
-1
@@ -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 {
|
||||
|
||||
@@ -42,6 +42,15 @@ export async function fetchCodexOauthModels(
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取当前 xAI OAuth 账号可访问的模型列表。 */
|
||||
export async function fetchXaiOauthModels(
|
||||
accountId?: string | null,
|
||||
): Promise<FetchedModel[]> {
|
||||
return invoke("get_xai_oauth_models", {
|
||||
accountId: accountId || null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据错误类型显示对应的 toast 提示
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
<XaiOAuthSection
|
||||
selectedAccountId="expired-account"
|
||||
onAccountSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent(
|
||||
"expired@example.com",
|
||||
);
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent("凭据已失效");
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
function readTranslation(tree: TranslationTree, path: string): unknown {
|
||||
return path.split(".").reduce<unknown>((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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user