mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-31 19:22:15 +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");
|
||||
}
|
||||
Reference in New Issue
Block a user