mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
feat(copilot): add GitHub Copilot reverse proxy support (#930)
* refactor(toolsearch): replace binary patch with ENABLE_TOOL_SEARCH env var toggle
- Remove toolsearch_patch.rs binary patching mechanism (~590 lines)
- Delete `toolsearch_patch.rs` and `commands/toolsearch.rs`
- Remove auto-patch startup logic and command registration from lib.rs
- Remove `tool_search_bypass` field from settings.rs
- Remove frontend settings ToggleRow, useSettings hook sync logic, and API methods
- Clean up zh/en/ja i18n keys (notifications + settings)
- Add ENABLE_TOOL_SEARCH toggle to Claude provider form
- Add checkbox in CommonConfigEditor.tsx (alongside teammates toggle)
- When enabled, writes `"env": { "ENABLE_TOOL_SEARCH": "true" }`
- When disabled, removes the key; takes effect on provider switch
- Add zh/en/ja i18n key: `claudeConfig.enableToolSearch`
Claude Code 2.1.76+ natively supports this env var, eliminating the need for binary patching.
* feat(claude): add effortLevel high toggle to provider form
- Add "high-effort thinking" checkbox to Claude provider config form
- When checked, writes `"effortLevel": "high"`; when unchecked, removes the field
- Add zh/en/ja i18n translations
* refactor(claude): remove deprecated alwaysThinking toggle
- Claude Code now enables extended thinking by default; alwaysThinkingEnabled is a no-op
- Thinking control is now handled via effortLevel (added in prior commit)
- Remove state, switch case, and checkbox UI from CommonConfigEditor
- Clean up alwaysThinking i18n keys across zh/en/ja locales
* feat(opencode): add setCacheKey: true to all provider presets
- Add setCacheKey: true to options in all 33 regular presets
- Add setCacheKey: true to OPENCODE_DEFAULT_CONFIG for custom providers
- Exclude 2 OMO presets (Oh My OpenCode / Slim) which have their own config mechanism
Closes #1523
* fix(codex): resolve 1M context window toggle causing MCP editor flicker
- Add localValueRef to short-circuit duplicate CodeMirror updateListener callbacks,
breaking the React state → CodeMirror → stale onChange → React state feedback loop
- Use localValueRef.current in handleContextWindowToggle and handleCompactLimitChange
to avoid stale closure reads
- Change compact limit input from type="number" to type="text" with inputMode="numeric"
to remove unnecessary spinner buttons
* feat(codex): add 1M context window toggle utilities and i18n keys
- Add extractCodexTopLevelInt, setCodexTopLevelInt, removeCodexTopLevelField
TOML helpers in providerConfigUtils.ts
- Add i18n keys for contextWindow1M, autoCompactLimit in zh/en/ja locales
* feat(claude): collapse model mapping fields by default
- Wrap 5 model mapping inputs in a Collapsible, collapsed by default
- Auto-expand when any model value is present (including preset-filled)
- Show hint text when collapsed explaining most users need no config
- Add zh/en/ja i18n keys for toggle label and collapsed hint
- Use variant={null} to avoid ghost button hover style clash in dark mode
* feat(claude): merge advanced fields into single collapsible section
- Merge API format, auth field, and model mapping into a unified "Advanced Options" collapsible
- Extend smart-expand logic to detect non-default values across all advanced fields
- Preserve model mapping sub-header and hint with a separator line
- Update zh/en/ja i18n keys (advancedOptionsToggle, advancedOptionsHint, modelMappingLabel, modelMappingHint)
* feat(copilot): add GitHub Copilot reverse proxy support
Add GitHub Copilot as a Claude provider variant with OAuth device code
authentication and Anthropic ↔ OpenAI format transformation.
Backend:
- Add CopilotAuthManager for GitHub OAuth device code flow
- Implement Copilot token auto-refresh (60s before expiry)
- Persist GitHub token to ~/.cc-switch/copilot_auth.json
- Add ProviderType::GitHubCopilot and AuthStrategy::GitHubCopilot
- Modify forwarder to use /chat/completions for Copilot
- Add Copilot-specific headers (Editor-Version, Editor-Plugin-Version)
Frontend:
- Add CopilotAuthSection component for OAuth UI
- Add useCopilotAuth hook for OAuth state management
- Auto-copy user code to clipboard and open browser
- Use 8-second polling interval to avoid GitHub rate limits
- Skip API Key validation for Copilot providers
- Add GitHub Copilot preset with claude-sonnet-4 model
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
* fix(copilot): remove is_expired() calls from tests
Remove references to deleted is_expired() method in test code.
Only is_expiring_soon() is needed for token refresh logic.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
* feat(copilot): add real-time model listing from Copilot API
- Add fetch_models() to CopilotAuthManager calling GET /models endpoint
- Add copilot_get_models Tauri command
- Add copilotGetModels() frontend API wrapper
- Modify ClaudeFormFields to show model dropdown for Copilot providers
- Fetches available models on component mount when isCopilotPreset
- Groups models by vendor (Anthropic, OpenAI, Google, etc.)
- Input + dropdown button combo allows both manual entry and selection
- Non-Copilot providers keep original plain Input behavior
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(copilot): add usage query integration
- Add Copilot usage API integration (fetch_usage method)
- Add copilot_get_usage Tauri command
- Add GitHub Copilot template in usage query modal
- Unify naming: copilot → github_copilot
- Add constants management (TEMPLATE_TYPES, PROVIDER_TYPES)
- Improve error handling with detailed error messages
- Add database migration (v5 → v6) for template type update
- Add i18n translations (zh, en, ja)
- Improve type safety with TemplateType
- Apply code formatting (cargo fmt, prettier)
* 修复github 登录和注销问题 ,模型选择问题
* feat(copilot): add multi-account support for GitHub Copilot
- Add multi-account storage structure with v1 to v2 migration
- Add per-account token caching and auto-refresh
- Add new Tauri commands for account management
- Integrate account selection in Proxy forwarder
- Add account selection UI in CopilotAuthSection
- Save githubAccountId to ProviderMeta
- Add i18n translations for multi-account features (zh/en/ja)
* 修复用量查询Reset字段出现多余字符
* refactor(auth-binding): introduce generic provider auth binding primitives
- add shared authBinding types in Rust and TypeScript while keeping githubAccountId as a compatibility field\n- resolve Copilot token, models, and usage through provider-bound account lookup instead of only the implicit default account\n- fix the Unix build regression in settings.rs by restoring std::io::Write for write_all()\n- remove the accidental .github ignore entry and drop leftover Copilot form debug logs\n- keep the first migration step non-breaking by writing both authBinding and the legacy githubAccountId field from the form
* refactor(auth-service): add managed auth command surface and explicit default account state
- introduce generic managed auth commands and frontend auth API wrappers for provider-scoped login, status, account listing, removal, logout, and default-account selection\n- store an explicit Copilot default_account_id instead of relying on HashMap iteration order, and use it consistently for fallback token/model/usage resolution\n- sort managed accounts deterministically and surface default-account state to the UI\n- refactor the Copilot form hook to wrap a generic useManagedAuth implementation while preserving the existing component contract\n- add default-account controls to the Copilot auth section and extend Copilot auth status serialization/tests for the new state
* feat(auth-center): add a dedicated settings entrypoint for managed OAuth accounts
- add an Auth Center tab to Settings so managed OAuth accounts are no longer hidden inside individual provider forms\n- introduce a first AuthCenterPanel that hosts GitHub Copilot account management as the initial managed auth provider\n- keep the provider form experience intact while establishing a global account-management surface for future providers such as OpenAI\n- validate that the new settings tab works cleanly with the generic managed auth hook and existing Copilot account controls
* feat(add-provider): expose managed OAuth sources alongside universal providers
- add an OAuth tab to the Add Provider flow so managed auth sources sit beside app-specific and universal providers\n- reuse the new Auth Center panel inside the dialog, keeping account management discoverable during provider creation\n- make the dialog footer adapt to the OAuth tab so account setup does not pretend to create a provider directly\n- align the add-provider UX with the new architecture where OAuth accounts are global assets and providers bind to them later
* fix(auth-reliability): harden managed auth persistence and refresh behavior
- replace direct Copilot auth store writes with private temp-file writes and atomic rename semantics, and document the local token storage limitation\n- add per-account refresh locks plus a double-check path so concurrent requests do not stampede GitHub token refresh\n- surface legacy migration failures through auth status, expose them in the UI, and add translated copy for the new account-state labels\n- stop writing the legacy githubAccountId field from the provider form while keeping compatibility reads in place\n- add logout error recovery and Copilot model-load toasts so auth failures are no longer silently swallowed
* refactor(copilot-detection): prefer provider type before URL fallbacks
- update forwarder endpoint rewriting to treat providerType as the primary GitHub Copilot signal\n- keep githubcopilot.com string matching only as a compatibility fallback for older provider records without providerType\n- reduce one more path where Copilot behavior depended purely on URL heuristics
* fix(copilot-auth): add cancel button to error state in CopilotAuthSection
- 错误状态下仅有"重试"按钮,用户无法退出(如不可恢复的 403 未订阅错误)
- 新增"取消"按钮,复用已有的 cancelAuth 逻辑重置为 idle 状态
* 修复打包后github账号头像显示异常
* 修复github copilot 来源的模型测试报错
* feat(copilot-preset): add default model presets for GitHub Copilot
- 补充 Copilot 预设的默认模型配置,用户选完预设即可直接使用
- ANTHROPIC_MODEL: claude-opus-4.6
- ANTHROPIC_DEFAULT_HAIKU_MODEL: claude-haiku-4.5
- ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4.6
- ANTHROPIC_DEFAULT_OPUS_MODEL: claude-opus-4.6
---------
Co-authored-by: Jason <farion1231@gmail.com>
Co-authored-by: 周梦泽 <mengze.zhou@dafeng-tech.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/components/providers/forms/ProviderForm";
|
||||
import { UniversalProviderFormModal } from "@/components/universal/UniversalProviderFormModal";
|
||||
import { UniversalProviderPanel } from "@/components/universal";
|
||||
import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel";
|
||||
import { providerPresets } from "@/config/claudeProviderPresets";
|
||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
||||
@@ -42,9 +43,9 @@ export function AddProviderDialog({
|
||||
const { t } = useTranslation();
|
||||
// OpenCode and OpenClaw don't support universal providers
|
||||
const showUniversalTab = appId !== "opencode" && appId !== "openclaw";
|
||||
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
|
||||
"app-specific",
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"app-specific" | "universal" | "oauth"
|
||||
>("app-specific");
|
||||
const [universalFormOpen, setUniversalFormOpen] = useState(false);
|
||||
const [selectedUniversalPreset, setSelectedUniversalPreset] =
|
||||
useState<UniversalProviderPreset | null>(null);
|
||||
@@ -255,6 +256,14 @@ export function AddProviderDialog({
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</>
|
||||
) : activeTab === "oauth" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{t("common.close", { defaultValue: "关闭" })}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
@@ -284,15 +293,20 @@ export function AddProviderDialog({
|
||||
{showUniversalTab ? (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
|
||||
onValueChange={(v) =>
|
||||
setActiveTab(v as "app-specific" | "universal" | "oauth")
|
||||
}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsList className="grid w-full grid-cols-3 mb-6">
|
||||
<TabsTrigger value="app-specific">
|
||||
{t(`apps.${appId}`)} {t("provider.tabProvider")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="universal">
|
||||
{t("provider.tabUniversal")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="oauth">
|
||||
{t("provider.tabOAuth", { defaultValue: "OAuth 认证源" })}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="app-specific" className="mt-0">
|
||||
@@ -309,6 +323,10 @@ export function AddProviderDialog({
|
||||
<TabsContent value="universal" className="mt-0">
|
||||
<UniversalProviderPanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="oauth" className="mt-0">
|
||||
<AuthCenterPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
// OpenCode/OpenClaw: directly show form without tabs
|
||||
|
||||
@@ -199,14 +199,14 @@ export function ProviderCard({
|
||||
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
||||
// - 普通模式:isCurrent
|
||||
const isActiveProvider = isAnyOmo
|
||||
? isCurrent
|
||||
: appId === "openclaw"
|
||||
? Boolean(isDefaultModel)
|
||||
: appId === "opencode"
|
||||
? false
|
||||
: isAutoFailoverEnabled
|
||||
? activeProviderId === provider.id
|
||||
: isCurrent;
|
||||
? isCurrent
|
||||
: appId === "openclaw"
|
||||
? Boolean(isDefaultModel)
|
||||
: appId === "opencode"
|
||||
? false
|
||||
: isAutoFailoverEnabled
|
||||
? activeProviderId === provider.id
|
||||
: isCurrent;
|
||||
|
||||
const shouldUseGreen = !isAnyOmo && isProxyTakeover && isActiveProvider;
|
||||
const shouldUseBlue =
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { toast } from "sonner";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -8,8 +16,23 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
|
||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||
import { ApiKeySection, EndpointField } from "./shared";
|
||||
import { CopilotAuthSection } from "./CopilotAuthSection";
|
||||
import {
|
||||
copilotGetModels,
|
||||
copilotGetModelsForAccount,
|
||||
} from "@/lib/api/copilot";
|
||||
import type { CopilotModel } from "@/lib/api/copilot";
|
||||
import type {
|
||||
ProviderCategory,
|
||||
ClaudeApiFormat,
|
||||
@@ -33,6 +56,15 @@ interface ClaudeFormFieldsProps {
|
||||
isPartner?: boolean;
|
||||
partnerPromotionKey?: string;
|
||||
|
||||
// GitHub Copilot OAuth
|
||||
isCopilotPreset?: boolean;
|
||||
usesOAuth?: boolean;
|
||||
isCopilotAuthenticated?: boolean;
|
||||
/** 当前选中的 GitHub 账号 ID(多账号支持) */
|
||||
selectedGitHubAccountId?: string | null;
|
||||
/** GitHub 账号选择回调(多账号支持) */
|
||||
onGitHubAccountSelect?: (accountId: string | null) => void;
|
||||
|
||||
// Template Values
|
||||
templateValueEntries: Array<[string, TemplateValueConfig]>;
|
||||
templateValues: Record<string, TemplateValueConfig>;
|
||||
@@ -88,6 +120,11 @@ export function ClaudeFormFields({
|
||||
websiteUrl,
|
||||
isPartner,
|
||||
partnerPromotionKey,
|
||||
isCopilotPreset,
|
||||
usesOAuth,
|
||||
isCopilotAuthenticated,
|
||||
selectedGitHubAccountId,
|
||||
onGitHubAccountSelect,
|
||||
templateValueEntries,
|
||||
templateValues,
|
||||
templatePresetName,
|
||||
@@ -114,11 +151,172 @@ export function ClaudeFormFields({
|
||||
onApiKeyFieldChange,
|
||||
}: ClaudeFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasAnyAdvancedValue = !!(
|
||||
claudeModel ||
|
||||
reasoningModel ||
|
||||
defaultHaikuModel ||
|
||||
defaultSonnetModel ||
|
||||
defaultOpusModel ||
|
||||
apiFormat !== "anthropic" ||
|
||||
apiKeyField !== "ANTHROPIC_AUTH_TOKEN"
|
||||
);
|
||||
const [advancedExpanded, setAdvancedExpanded] =
|
||||
useState(hasAnyAdvancedValue);
|
||||
|
||||
// 预设填充高级值后自动展开(仅从折叠→展开,不会自动折叠)
|
||||
useEffect(() => {
|
||||
if (hasAnyAdvancedValue) {
|
||||
setAdvancedExpanded(true);
|
||||
}
|
||||
}, [hasAnyAdvancedValue]);
|
||||
|
||||
// Copilot 可用模型列表
|
||||
const [copilotModels, setCopilotModels] = useState<CopilotModel[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
|
||||
// 当 Copilot 预设且已认证时,加载可用模型
|
||||
useEffect(() => {
|
||||
// 如果不是 Copilot 预设或未认证,清空模型列表
|
||||
if (!isCopilotPreset || !isCopilotAuthenticated) {
|
||||
setCopilotModels([]);
|
||||
setModelsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setModelsLoading(true);
|
||||
const fetchModels = selectedGitHubAccountId
|
||||
? copilotGetModelsForAccount(selectedGitHubAccountId)
|
||||
: copilotGetModels();
|
||||
|
||||
fetchModels
|
||||
.then((models) => {
|
||||
if (!cancelled) setCopilotModels(models);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("[Copilot] Failed to fetch models:", err);
|
||||
if (!cancelled) {
|
||||
toast.error(
|
||||
t("copilot.loadModelsFailed", {
|
||||
defaultValue: "加载 Copilot 模型列表失败",
|
||||
}),
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setModelsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isCopilotPreset, isCopilotAuthenticated, selectedGitHubAccountId]);
|
||||
|
||||
// 模型输入框:支持手动输入 + 下拉选择
|
||||
const renderModelInput = (
|
||||
id: string,
|
||||
value: string,
|
||||
field: ClaudeFormFieldsProps["onModelChange"] extends (
|
||||
f: infer F,
|
||||
v: string,
|
||||
) => void
|
||||
? F
|
||||
: never,
|
||||
placeholder?: string,
|
||||
) => {
|
||||
if (isCopilotPreset && copilotModels.length > 0) {
|
||||
// 按 vendor 分组
|
||||
const grouped: Record<string, CopilotModel[]> = {};
|
||||
for (const model of copilotModels) {
|
||||
const vendor = model.vendor || "Other";
|
||||
if (!grouped[vendor]) grouped[vendor] = [];
|
||||
grouped[vendor].push(model);
|
||||
}
|
||||
const vendors = Object.keys(grouped).sort();
|
||||
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onModelChange(field, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
className="flex-1"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="shrink-0">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="max-h-64 overflow-y-auto z-[200]"
|
||||
>
|
||||
{vendors.map((vendor, vi) => (
|
||||
<div key={vendor}>
|
||||
{vi > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel>{vendor}</DropdownMenuLabel>
|
||||
{grouped[vendor].map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
onSelect={() => onModelChange(field, model.id)}
|
||||
>
|
||||
{model.id}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCopilotPreset && modelsLoading) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onModelChange(field, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="icon" className="shrink-0" disabled>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onModelChange(field, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* API Key 输入框 */}
|
||||
{shouldShowApiKey && (
|
||||
{/* GitHub Copilot OAuth 认证 */}
|
||||
{isCopilotPreset && (
|
||||
<CopilotAuthSection
|
||||
selectedAccountId={selectedGitHubAccountId}
|
||||
onAccountSelect={onGitHubAccountSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* API Key 输入框(非 OAuth 预设时显示) */}
|
||||
{shouldShowApiKey && !usesOAuth && (
|
||||
<ApiKeySection
|
||||
value={apiKey}
|
||||
onChange={onApiKeyChange}
|
||||
@@ -200,188 +398,185 @@ export function ClaudeFormFields({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* API 格式选择(仅非官方、非云服务商显示) */}
|
||||
{shouldShowModelSelector && category !== "cloud_provider" && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="apiFormat">
|
||||
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
|
||||
</FormLabel>
|
||||
<Select value={apiFormat} onValueChange={onApiFormatChange}>
|
||||
<SelectTrigger id="apiFormat" 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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.apiFormatHint", {
|
||||
defaultValue: "选择供应商 API 的输入格式",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 认证字段选择器 */}
|
||||
{/* 高级选项(API 格式 + 认证字段 + 模型映射) */}
|
||||
{shouldShowModelSelector && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
{t("providerForm.authField", { defaultValue: "认证字段" })}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={apiKeyField}
|
||||
onValueChange={(v) => onApiKeyFieldChange(v as ClaudeApiKeyField)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ANTHROPIC_AUTH_TOKEN">
|
||||
{t("providerForm.authFieldAuthToken", {
|
||||
defaultValue: "ANTHROPIC_AUTH_TOKEN(默认)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="ANTHROPIC_API_KEY">
|
||||
{t("providerForm.authFieldApiKey", {
|
||||
defaultValue: "ANTHROPIC_API_KEY",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.authFieldHint", {
|
||||
defaultValue: "选择写入配置的认证环境变量名",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Collapsible
|
||||
open={advancedExpanded}
|
||||
onOpenChange={setAdvancedExpanded}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={null}
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-0 text-sm font-medium text-foreground hover:opacity-70"
|
||||
>
|
||||
{advancedExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
{t("providerForm.advancedOptionsToggle")}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
{!advancedExpanded && (
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-1">
|
||||
{t("providerForm.advancedOptionsHint")}
|
||||
</p>
|
||||
)}
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
{/* API 格式选择(仅非云服务商显示) */}
|
||||
{category !== "cloud_provider" && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="apiFormat">
|
||||
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
|
||||
</FormLabel>
|
||||
<Select value={apiFormat} onValueChange={onApiFormatChange}>
|
||||
<SelectTrigger id="apiFormat" 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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.apiFormatHint", {
|
||||
defaultValue: "选择供应商 API 的输入格式",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 模型选择器 */}
|
||||
{shouldShowModelSelector && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 主模型 */}
|
||||
{/* 认证字段选择器 */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeModel">
|
||||
{t("providerForm.anthropicModel", { defaultValue: "主模型" })}
|
||||
<FormLabel>
|
||||
{t("providerForm.authField", { defaultValue: "认证字段" })}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="claudeModel"
|
||||
type="text"
|
||||
value={claudeModel}
|
||||
onChange={(e) =>
|
||||
onModelChange("ANTHROPIC_MODEL", e.target.value)
|
||||
<Select
|
||||
value={apiKeyField}
|
||||
onValueChange={(v) =>
|
||||
onApiKeyFieldChange(v as ClaudeApiKeyField)
|
||||
}
|
||||
placeholder={t("providerForm.modelPlaceholder", {
|
||||
defaultValue: "",
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ANTHROPIC_AUTH_TOKEN">
|
||||
{t("providerForm.authFieldAuthToken", {
|
||||
defaultValue: "ANTHROPIC_AUTH_TOKEN(默认)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="ANTHROPIC_API_KEY">
|
||||
{t("providerForm.authFieldApiKey", {
|
||||
defaultValue: "ANTHROPIC_API_KEY",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.authFieldHint", {
|
||||
defaultValue: "选择写入配置的认证环境变量名",
|
||||
})}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 推理模型 */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="reasoningModel">
|
||||
{t("providerForm.anthropicReasoningModel")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="reasoningModel"
|
||||
type="text"
|
||||
value={reasoningModel}
|
||||
onChange={(e) =>
|
||||
onModelChange("ANTHROPIC_REASONING_MODEL", e.target.value)
|
||||
}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{/* 模型映射 */}
|
||||
<div className="space-y-1 pt-2 border-t">
|
||||
<FormLabel>{t("providerForm.modelMappingLabel")}</FormLabel>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.modelMappingHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 主模型 */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeModel">
|
||||
{t("providerForm.anthropicModel", {
|
||||
defaultValue: "主模型",
|
||||
})}
|
||||
</FormLabel>
|
||||
{renderModelInput(
|
||||
"claudeModel",
|
||||
claudeModel,
|
||||
"ANTHROPIC_MODEL",
|
||||
t("providerForm.modelPlaceholder", { defaultValue: "" }),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 默认 Haiku */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeDefaultHaikuModel">
|
||||
{t("providerForm.anthropicDefaultHaikuModel", {
|
||||
defaultValue: "Haiku 默认模型",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="claudeDefaultHaikuModel"
|
||||
type="text"
|
||||
value={defaultHaikuModel}
|
||||
onChange={(e) =>
|
||||
onModelChange("ANTHROPIC_DEFAULT_HAIKU_MODEL", e.target.value)
|
||||
}
|
||||
placeholder={t("providerForm.haikuModelPlaceholder", {
|
||||
defaultValue: "",
|
||||
})}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
{/* 推理模型 */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="reasoningModel">
|
||||
{t("providerForm.anthropicReasoningModel")}
|
||||
</FormLabel>
|
||||
{renderModelInput(
|
||||
"reasoningModel",
|
||||
reasoningModel,
|
||||
"ANTHROPIC_REASONING_MODEL",
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 默认 Sonnet */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeDefaultSonnetModel">
|
||||
{t("providerForm.anthropicDefaultSonnetModel", {
|
||||
defaultValue: "Sonnet 默认模型",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="claudeDefaultSonnetModel"
|
||||
type="text"
|
||||
value={defaultSonnetModel}
|
||||
onChange={(e) =>
|
||||
onModelChange(
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder={t("providerForm.modelPlaceholder", {
|
||||
defaultValue: "",
|
||||
})}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
{/* 默认 Haiku */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeDefaultHaikuModel">
|
||||
{t("providerForm.anthropicDefaultHaikuModel", {
|
||||
defaultValue: "Haiku 默认模型",
|
||||
})}
|
||||
</FormLabel>
|
||||
{renderModelInput(
|
||||
"claudeDefaultHaikuModel",
|
||||
defaultHaikuModel,
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
t("providerForm.haikuModelPlaceholder", { defaultValue: "" }),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 默认 Opus */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeDefaultOpusModel">
|
||||
{t("providerForm.anthropicDefaultOpusModel", {
|
||||
defaultValue: "Opus 默认模型",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="claudeDefaultOpusModel"
|
||||
type="text"
|
||||
value={defaultOpusModel}
|
||||
onChange={(e) =>
|
||||
onModelChange("ANTHROPIC_DEFAULT_OPUS_MODEL", e.target.value)
|
||||
}
|
||||
placeholder={t("providerForm.modelPlaceholder", {
|
||||
defaultValue: "",
|
||||
})}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{/* 默认 Sonnet */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeDefaultSonnetModel">
|
||||
{t("providerForm.anthropicDefaultSonnetModel", {
|
||||
defaultValue: "Sonnet 默认模型",
|
||||
})}
|
||||
</FormLabel>
|
||||
{renderModelInput(
|
||||
"claudeDefaultSonnetModel",
|
||||
defaultSonnetModel,
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
t("providerForm.modelPlaceholder", { defaultValue: "" }),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 默认 Opus */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeDefaultOpusModel">
|
||||
{t("providerForm.anthropicDefaultOpusModel", {
|
||||
defaultValue: "Opus 默认模型",
|
||||
})}
|
||||
</FormLabel>
|
||||
{renderModelInput(
|
||||
"claudeDefaultOpusModel",
|
||||
defaultOpusModel,
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
t("providerForm.modelPlaceholder", { defaultValue: "" }),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.modelHelper", {
|
||||
defaultValue:
|
||||
"可选:指定默认使用的 Claude 模型,留空则使用系统默认。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
import {
|
||||
extractCodexTopLevelInt,
|
||||
setCodexTopLevelInt,
|
||||
removeCodexTopLevelField,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
|
||||
interface CodexAuthSectionProps {
|
||||
value: string;
|
||||
@@ -115,6 +120,95 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Mirror value prop to local state (same pattern as CommonConfigEditor)
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const localValueRef = useRef(value);
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
localValueRef.current = value;
|
||||
}, [value]);
|
||||
|
||||
const handleLocalChange = useCallback(
|
||||
(newValue: string) => {
|
||||
if (newValue === localValueRef.current) return;
|
||||
localValueRef.current = newValue;
|
||||
setLocalValue(newValue);
|
||||
onChange(newValue);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Parse toggle states from TOML text
|
||||
const toggleStates = useMemo(() => {
|
||||
const contextWindow = extractCodexTopLevelInt(
|
||||
localValue,
|
||||
"model_context_window",
|
||||
);
|
||||
const compactLimit = extractCodexTopLevelInt(
|
||||
localValue,
|
||||
"model_auto_compact_token_limit",
|
||||
);
|
||||
return {
|
||||
contextWindow1M: contextWindow === 1000000,
|
||||
compactLimit: compactLimit ?? 900000,
|
||||
};
|
||||
}, [localValue]);
|
||||
|
||||
// Debounce timer for compact limit input
|
||||
const compactTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const handleContextWindowToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
let toml = localValueRef.current || "";
|
||||
if (checked) {
|
||||
toml = setCodexTopLevelInt(toml, "model_context_window", 1000000);
|
||||
// Auto-set compact limit if not already present
|
||||
if (
|
||||
extractCodexTopLevelInt(toml, "model_auto_compact_token_limit") ===
|
||||
undefined
|
||||
) {
|
||||
toml = setCodexTopLevelInt(
|
||||
toml,
|
||||
"model_auto_compact_token_limit",
|
||||
900000,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toml = removeCodexTopLevelField(toml, "model_context_window");
|
||||
toml = removeCodexTopLevelField(
|
||||
toml,
|
||||
"model_auto_compact_token_limit",
|
||||
);
|
||||
}
|
||||
handleLocalChange(toml);
|
||||
},
|
||||
[handleLocalChange],
|
||||
);
|
||||
|
||||
const handleCompactLimitChange = useCallback(
|
||||
(inputValue: string) => {
|
||||
clearTimeout(compactTimerRef.current);
|
||||
compactTimerRef.current = setTimeout(() => {
|
||||
const num = parseInt(inputValue, 10);
|
||||
if (!Number.isNaN(num) && num > 0) {
|
||||
handleLocalChange(
|
||||
setCodexTopLevelInt(
|
||||
localValueRef.current || "",
|
||||
"model_auto_compact_token_limit",
|
||||
num,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, 500);
|
||||
},
|
||||
[handleLocalChange],
|
||||
);
|
||||
|
||||
// Cleanup debounce timer
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(compactTimerRef.current);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -152,9 +246,34 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toggleStates.contextWindow1M}
|
||||
onChange={(e) => handleContextWindowToggle(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
<span>{t("codexConfig.contextWindow1M")}</span>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("codexConfig.autoCompactLimit")}:</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
key={toggleStates.compactLimit}
|
||||
defaultValue={toggleStates.compactLimit}
|
||||
disabled={!toggleStates.contextWindow1M}
|
||||
onChange={(e) => handleCompactLimitChange(e.target.value)}
|
||||
className="w-28 h-7 px-2 text-sm rounded border border-border bg-background text-foreground disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
value={localValue}
|
||||
onChange={handleLocalChange}
|
||||
placeholder=""
|
||||
darkMode={isDarkMode}
|
||||
rows={8}
|
||||
|
||||
@@ -75,16 +75,20 @@ export function CommonConfigEditor({
|
||||
return {
|
||||
hideAttribution:
|
||||
config?.attribution?.commit === "" && config?.attribution?.pr === "",
|
||||
alwaysThinking: config?.alwaysThinkingEnabled === true,
|
||||
teammates:
|
||||
config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1" ||
|
||||
config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === 1,
|
||||
enableToolSearch:
|
||||
config?.env?.ENABLE_TOOL_SEARCH === "true" ||
|
||||
config?.env?.ENABLE_TOOL_SEARCH === "1",
|
||||
effortHigh: config?.effortLevel === "high",
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
hideAttribution: false,
|
||||
alwaysThinking: false,
|
||||
teammates: false,
|
||||
enableToolSearch: false,
|
||||
effortHigh: false,
|
||||
};
|
||||
}
|
||||
}, [localValue]);
|
||||
@@ -102,13 +106,6 @@ export function CommonConfigEditor({
|
||||
delete config.attribution;
|
||||
}
|
||||
break;
|
||||
case "alwaysThinking":
|
||||
if (checked) {
|
||||
config.alwaysThinkingEnabled = true;
|
||||
} else {
|
||||
delete config.alwaysThinkingEnabled;
|
||||
}
|
||||
break;
|
||||
case "teammates":
|
||||
if (!config.env) config.env = {};
|
||||
if (checked) {
|
||||
@@ -118,6 +115,22 @@ export function CommonConfigEditor({
|
||||
if (Object.keys(config.env).length === 0) delete config.env;
|
||||
}
|
||||
break;
|
||||
case "enableToolSearch":
|
||||
if (!config.env) config.env = {};
|
||||
if (checked) {
|
||||
config.env.ENABLE_TOOL_SEARCH = "true";
|
||||
} else {
|
||||
delete config.env.ENABLE_TOOL_SEARCH;
|
||||
if (Object.keys(config.env).length === 0) delete config.env;
|
||||
}
|
||||
break;
|
||||
case "effortHigh":
|
||||
if (checked) {
|
||||
config.effortLevel = "high";
|
||||
} else {
|
||||
delete config.effortLevel;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
handleLocalChange(JSON.stringify(config, null, 2));
|
||||
@@ -178,15 +191,6 @@ export function CommonConfigEditor({
|
||||
/>
|
||||
<span>{t("claudeConfig.hideAttribution")}</span>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toggleStates.alwaysThinking}
|
||||
onChange={(e) => handleToggle("alwaysThinking", e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
<span>{t("claudeConfig.alwaysThinking")}</span>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -196,6 +200,26 @@ export function CommonConfigEditor({
|
||||
/>
|
||||
<span>{t("claudeConfig.enableTeammates")}</span>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toggleStates.enableToolSearch}
|
||||
onChange={(e) =>
|
||||
handleToggle("enableToolSearch", e.target.checked)
|
||||
}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
<span>{t("claudeConfig.enableToolSearch")}</span>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toggleStates.effortHigh}
|
||||
onChange={(e) => handleToggle("effortHigh", e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
<span>{t("claudeConfig.effortHigh")}</span>
|
||||
</label>
|
||||
</div>
|
||||
<JsonEditor
|
||||
value={localValue}
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Loader2,
|
||||
Github,
|
||||
LogOut,
|
||||
Copy,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Plus,
|
||||
X,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useCopilotAuth } from "./hooks/useCopilotAuth";
|
||||
import type { GitHubAccount } from "@/lib/api";
|
||||
|
||||
interface CopilotAuthSectionProps {
|
||||
className?: string;
|
||||
/** 当前选中的 GitHub 账号 ID */
|
||||
selectedAccountId?: string | null;
|
||||
/** 账号选择回调 */
|
||||
onAccountSelect?: (accountId: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copilot OAuth 认证区块
|
||||
*
|
||||
* 显示 GitHub Copilot 的认证状态,支持多账号管理和选择。
|
||||
*/
|
||||
export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
|
||||
className,
|
||||
selectedAccountId,
|
||||
onAccountSelect,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const {
|
||||
accounts,
|
||||
defaultAccountId,
|
||||
migrationError,
|
||||
hasAnyAccount,
|
||||
pollingState,
|
||||
deviceCode,
|
||||
error,
|
||||
isPolling,
|
||||
isAddingAccount,
|
||||
isRemovingAccount,
|
||||
isSettingDefaultAccount,
|
||||
addAccount,
|
||||
removeAccount,
|
||||
setDefaultAccount,
|
||||
cancelAuth,
|
||||
logout,
|
||||
} = useCopilotAuth();
|
||||
|
||||
// 复制用户码
|
||||
const copyUserCode = async () => {
|
||||
if (deviceCode?.user_code) {
|
||||
await navigator.clipboard.writeText(deviceCode.user_code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理账号选择
|
||||
const handleAccountSelect = (value: string) => {
|
||||
onAccountSelect?.(value === "none" ? null : value);
|
||||
};
|
||||
|
||||
// 处理移除账号
|
||||
const handleRemoveAccount = (accountId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
removeAccount(accountId);
|
||||
// 如果移除的是当前选中的账号,清除选择
|
||||
if (selectedAccountId === accountId) {
|
||||
onAccountSelect?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染账号头像
|
||||
const renderAvatar = (account: GitHubAccount) => {
|
||||
return <CopilotAccountAvatar account={account} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className || ""}`}>
|
||||
{/* 认证状态标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t("copilot.authStatus", "GitHub Copilot 认证")}</Label>
|
||||
<Badge
|
||||
variant={hasAnyAccount ? "default" : "secondary"}
|
||||
className={hasAnyAccount ? "bg-green-500 hover:bg-green-600" : ""}
|
||||
>
|
||||
{hasAnyAccount
|
||||
? t("copilot.accountCount", {
|
||||
count: accounts.length,
|
||||
defaultValue: `${accounts.length} 个账号`,
|
||||
})
|
||||
: t("copilot.notAuthenticated", "未认证")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{migrationError && (
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||||
{t("copilot.migrationFailed", {
|
||||
error: migrationError,
|
||||
defaultValue: `旧认证数据迁移失败:${migrationError}`,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 账号选择器(有账号时显示) */}
|
||||
{hasAnyAccount && onAccountSelect && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("copilot.selectAccount", "选择账号")}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedAccountId || "none"}
|
||||
onValueChange={handleAccountSelect}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"copilot.selectAccountPlaceholder",
|
||||
"选择一个 GitHub 账号",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
<span className="text-muted-foreground">
|
||||
{t("copilot.useDefaultAccount", "使用默认账号")}
|
||||
</span>
|
||||
</SelectItem>
|
||||
{accounts.map((account) => (
|
||||
<SelectItem key={account.id} value={account.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
{renderAvatar(account)}
|
||||
<span>{account.login}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已登录账号列表 */}
|
||||
{hasAnyAccount && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("copilot.loggedInAccounts", "已登录账号")}
|
||||
</Label>
|
||||
<div className="space-y-1">
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="flex items-center justify-between p-2 rounded-md border bg-muted/30"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{renderAvatar(account)}
|
||||
<span className="text-sm font-medium">{account.login}</span>
|
||||
{defaultAccountId === account.id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("copilot.defaultAccount", "默认")}
|
||||
</Badge>
|
||||
)}
|
||||
{selectedAccountId === account.id && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("copilot.selected", "已选中")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{defaultAccountId !== account.id && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground"
|
||||
onClick={() => setDefaultAccount(account.id)}
|
||||
disabled={isSettingDefaultAccount}
|
||||
>
|
||||
{t("copilot.setAsDefault", "设为默认")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-red-500"
|
||||
onClick={(e) => handleRemoveAccount(account.id, e)}
|
||||
disabled={isRemovingAccount}
|
||||
title={t("copilot.removeAccount", "移除账号")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 未认证状态 - 登录按钮 */}
|
||||
{!hasAnyAccount && pollingState === "idle" && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addAccount}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
>
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
{t("copilot.loginWithGitHub", "使用 GitHub 登录")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 已有账号 - 添加更多账号按钮 */}
|
||||
{hasAnyAccount && pollingState === "idle" && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addAccount}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isAddingAccount}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("copilot.addAnotherAccount", "添加其他账号")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 轮询中状态 */}
|
||||
{isPolling && deviceCode && (
|
||||
<div className="space-y-3 p-4 rounded-lg border border-border bg-muted/50">
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("copilot.waitingForAuth", "等待授权中...")}
|
||||
</div>
|
||||
|
||||
{/* 用户码 */}
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
{t("copilot.enterCode", "在浏览器中输入以下代码:")}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<code className="text-2xl font-mono font-bold tracking-wider bg-background px-4 py-2 rounded border">
|
||||
{deviceCode.user_code}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={copyUserCode}
|
||||
title={t("copilot.copyCode", "复制代码")}
|
||||
>
|
||||
{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"
|
||||
onClick={addAccount}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{t("copilot.retry", "重试")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={cancelAuth}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
{t("common.cancel", "取消")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 注销所有账号按钮 */}
|
||||
{hasAnyAccount && accounts.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={logout}
|
||||
className="w-full text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{t("copilot.logoutAll", "注销所有账号")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CopilotAccountAvatar: React.FC<{ account: GitHubAccount }> = ({
|
||||
account,
|
||||
}) => {
|
||||
const [failed, setFailed] = React.useState(false);
|
||||
|
||||
if (!account.avatar_url || failed) {
|
||||
return <User className="h-5 w-5 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={account.avatar_url}
|
||||
alt={account.login}
|
||||
className="h-5 w-5 rounded-full"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopilotAuthSection;
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
useOpencodeFormState,
|
||||
useOmoDraftState,
|
||||
useOpenclawFormState,
|
||||
useCopilotAuth,
|
||||
} from "./hooks";
|
||||
import {
|
||||
CLAUDE_DEFAULT_CONFIG,
|
||||
@@ -89,6 +90,7 @@ import {
|
||||
OPENCLAW_DEFAULT_CONFIG,
|
||||
normalizePricingSource,
|
||||
} from "./helpers/opencodeFormUtils";
|
||||
import { resolveManagedAccountId } from "@/lib/authBinding";
|
||||
|
||||
type PresetEntry = {
|
||||
id: string;
|
||||
@@ -331,6 +333,14 @@ export function ProviderForm({
|
||||
[localApiKeyField, form, handleSettingsConfigChange],
|
||||
);
|
||||
|
||||
// Copilot OAuth 认证状态(仅 Claude 应用需要)
|
||||
const { isAuthenticated: isCopilotAuthenticated } = useCopilotAuth();
|
||||
|
||||
// 选中的 GitHub 账号 ID(多账号支持)
|
||||
const [selectedGitHubAccountId, setSelectedGitHubAccountId] = useState<
|
||||
string | null
|
||||
>(() => resolveManagedAccountId(initialData?.meta, "github_copilot"));
|
||||
|
||||
const {
|
||||
codexAuth,
|
||||
codexConfig,
|
||||
@@ -660,6 +670,21 @@ export function ProviderForm({
|
||||
|
||||
// 非官方供应商必填校验:端点和 API Key
|
||||
// cloud_provider(如 Bedrock)通过模板变量处理认证,跳过通用校验
|
||||
// GitHub Copilot 使用 OAuth 认证,不需要 API Key
|
||||
const isCopilotProvider =
|
||||
templatePreset?.providerType === "github_copilot" ||
|
||||
initialData?.meta?.providerType === "github_copilot" ||
|
||||
baseUrl.includes("githubcopilot.com");
|
||||
// GitHub Copilot 必须先登录才能添加
|
||||
if (isCopilotProvider && !isCopilotAuthenticated) {
|
||||
toast.error(
|
||||
t("copilot.loginRequired", {
|
||||
defaultValue: "请先登录 GitHub Copilot",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (category !== "official" && category !== "cloud_provider") {
|
||||
if (appId === "claude") {
|
||||
if (!baseUrl.trim()) {
|
||||
@@ -670,7 +695,7 @@ export function ProviderForm({
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!apiKey.trim()) {
|
||||
if (!isCopilotProvider && !apiKey.trim()) {
|
||||
toast.error(
|
||||
t("providerForm.apiKeyRequired", {
|
||||
defaultValue: "非官方供应商请填写 API Key",
|
||||
@@ -867,6 +892,11 @@ export function ProviderForm({
|
||||
|
||||
const baseMeta: ProviderMeta | undefined =
|
||||
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
|
||||
|
||||
// 确定 providerType(新建时从预设获取,编辑时从现有数据获取)
|
||||
const providerType =
|
||||
templatePreset?.providerType || initialData?.meta?.providerType;
|
||||
|
||||
payload.meta = {
|
||||
...(baseMeta ?? {}),
|
||||
commonConfigEnabled:
|
||||
@@ -878,6 +908,20 @@ export function ProviderForm({
|
||||
? useGeminiCommonConfigFlag
|
||||
: undefined,
|
||||
endpointAutoSelect,
|
||||
// 保存 providerType(用于识别 Copilot 等特殊供应商)
|
||||
providerType,
|
||||
authBinding: isCopilotProvider
|
||||
? {
|
||||
source: "managed_account",
|
||||
authProvider: "github_copilot",
|
||||
accountId: selectedGitHubAccountId ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
// GitHub Copilot 多账号:保存关联的账号 ID
|
||||
githubAccountId:
|
||||
isCopilotProvider && selectedGitHubAccountId
|
||||
? selectedGitHubAccountId
|
||||
: undefined,
|
||||
testConfig: testConfig.enabled ? testConfig : undefined,
|
||||
proxyConfig: proxyConfig.enabled ? proxyConfig : undefined,
|
||||
costMultiplier: pricingConfig.enabled
|
||||
@@ -1318,6 +1362,20 @@ export function ProviderForm({
|
||||
websiteUrl={claudeWebsiteUrl}
|
||||
isPartner={isClaudePartner}
|
||||
partnerPromotionKey={claudePartnerPromotionKey}
|
||||
isCopilotPreset={
|
||||
templatePreset?.providerType === "github_copilot" ||
|
||||
initialData?.meta?.providerType === "github_copilot" ||
|
||||
baseUrl.includes("githubcopilot.com")
|
||||
}
|
||||
usesOAuth={
|
||||
templatePreset?.requiresOAuth === true ||
|
||||
templatePreset?.providerType === "github_copilot" ||
|
||||
initialData?.meta?.providerType === "github_copilot" ||
|
||||
baseUrl.includes("githubcopilot.com")
|
||||
}
|
||||
isCopilotAuthenticated={isCopilotAuthenticated}
|
||||
selectedGitHubAccountId={selectedGitHubAccountId}
|
||||
onGitHubAccountSelect={setSelectedGitHubAccountId}
|
||||
templateValueEntries={templateValueEntries}
|
||||
templateValues={templateValues}
|
||||
templatePresetName={templatePreset?.name || ""}
|
||||
@@ -1607,7 +1665,9 @@ export function ProviderForm({
|
||||
<Button variant="outline" type="button" onClick={onCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>{submitLabel}</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -28,6 +28,7 @@ export const OPENCODE_DEFAULT_CONFIG = JSON.stringify(
|
||||
options: {
|
||||
baseURL: "",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {},
|
||||
},
|
||||
|
||||
@@ -11,8 +11,10 @@ export { useCodexCommonConfig } from "./useCodexCommonConfig";
|
||||
export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints";
|
||||
export { useCodexTomlValidation } from "./useCodexTomlValidation";
|
||||
export { useGeminiConfigState } from "./useGeminiConfigState";
|
||||
export { useManagedAuth } from "./useManagedAuth";
|
||||
export { useGeminiCommonConfig } from "./useGeminiCommonConfig";
|
||||
export { useOmoModelSource } from "./useOmoModelSource";
|
||||
export { useOpencodeFormState } from "./useOpencodeFormState";
|
||||
export { useOmoDraftState } from "./useOmoDraftState";
|
||||
export { useOpenclawFormState } from "./useOpenclawFormState";
|
||||
export { useCopilotAuth } from "./useCopilotAuth";
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { GitHubAccount } from "@/lib/api";
|
||||
import { useManagedAuth } from "./useManagedAuth";
|
||||
|
||||
export function useCopilotAuth() {
|
||||
const managedAuth = useManagedAuth("github_copilot");
|
||||
const defaultAccount =
|
||||
managedAuth.accounts.find(
|
||||
(account) => account.id === managedAuth.defaultAccountId,
|
||||
) ?? managedAuth.accounts[0];
|
||||
|
||||
return {
|
||||
...managedAuth,
|
||||
authStatus: managedAuth.authStatus
|
||||
? {
|
||||
authenticated: managedAuth.authStatus.authenticated,
|
||||
username: defaultAccount?.login ?? null,
|
||||
// Managed auth status does not expose a single provider-wide token expiry.
|
||||
expires_at: null,
|
||||
default_account_id: managedAuth.defaultAccountId,
|
||||
migration_error: managedAuth.migrationError,
|
||||
accounts: managedAuth.accounts as GitHubAccount[],
|
||||
}
|
||||
: undefined,
|
||||
// Managed auth status no longer exposes a single default token expiry.
|
||||
username: defaultAccount?.login ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { authApi, settingsApi } from "@/lib/api";
|
||||
import type {
|
||||
ManagedAuthProvider,
|
||||
ManagedAuthStatus,
|
||||
ManagedAuthDeviceCodeResponse,
|
||||
} from "@/lib/api";
|
||||
|
||||
type PollingState = "idle" | "polling" | "success" | "error";
|
||||
|
||||
export function useManagedAuth(authProvider: ManagedAuthProvider) {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = ["managed-auth-status", authProvider];
|
||||
|
||||
const [pollingState, setPollingState] = useState<PollingState>("idle");
|
||||
const [deviceCode, setDeviceCode] =
|
||||
useState<ManagedAuthDeviceCodeResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(
|
||||
null,
|
||||
);
|
||||
const pollingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const {
|
||||
data: authStatus,
|
||||
isLoading: isLoadingStatus,
|
||||
refetch: refetchStatus,
|
||||
} = useQuery<ManagedAuthStatus>({
|
||||
queryKey,
|
||||
queryFn: () => authApi.authGetStatus(authProvider),
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
if (pollingTimeoutRef.current) {
|
||||
clearTimeout(pollingTimeoutRef.current);
|
||||
pollingTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopPolling();
|
||||
};
|
||||
}, [stopPolling]);
|
||||
|
||||
const startLoginMutation = useMutation({
|
||||
mutationFn: () => authApi.authStartLogin(authProvider),
|
||||
onSuccess: async (response) => {
|
||||
setDeviceCode(response);
|
||||
setPollingState("polling");
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(response.user_code);
|
||||
} catch (e) {
|
||||
console.debug("[ManagedAuth] Failed to copy user code:", e);
|
||||
}
|
||||
|
||||
try {
|
||||
await settingsApi.openExternal(response.verification_uri);
|
||||
} catch (e) {
|
||||
console.debug("[ManagedAuth] Failed to open browser:", e);
|
||||
}
|
||||
|
||||
// Add a small buffer on top of GitHub's suggested interval to avoid
|
||||
// hitting slow_down responses too aggressively during device polling.
|
||||
const interval = Math.max((response.interval || 5) + 3, 8) * 1000;
|
||||
const expiresAt = Date.now() + response.expires_in * 1000;
|
||||
|
||||
const pollOnce = async () => {
|
||||
if (Date.now() > expiresAt) {
|
||||
stopPolling();
|
||||
setPollingState("error");
|
||||
setError("Device code expired. Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newAccount = await authApi.authPollForAccount(
|
||||
authProvider,
|
||||
response.device_code,
|
||||
);
|
||||
if (newAccount) {
|
||||
stopPolling();
|
||||
setPollingState("success");
|
||||
await refetchStatus();
|
||||
await queryClient.invalidateQueries({ queryKey });
|
||||
setPollingState("idle");
|
||||
setDeviceCode(null);
|
||||
}
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
if (
|
||||
!errorMessage.includes("pending") &&
|
||||
!errorMessage.includes("slow_down")
|
||||
) {
|
||||
stopPolling();
|
||||
setPollingState("error");
|
||||
setError(errorMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void pollOnce();
|
||||
pollingIntervalRef.current = setInterval(pollOnce, interval);
|
||||
pollingTimeoutRef.current = setTimeout(() => {
|
||||
stopPolling();
|
||||
setPollingState("error");
|
||||
setError("Device code expired. Please try again.");
|
||||
}, response.expires_in * 1000);
|
||||
},
|
||||
onError: (e) => {
|
||||
setPollingState("error");
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
},
|
||||
});
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: () => authApi.authLogout(authProvider),
|
||||
onSuccess: async () => {
|
||||
setPollingState("idle");
|
||||
setDeviceCode(null);
|
||||
setError(null);
|
||||
queryClient.setQueryData(queryKey, {
|
||||
provider: authProvider,
|
||||
authenticated: false,
|
||||
default_account_id: null,
|
||||
accounts: [],
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
onError: async (e) => {
|
||||
console.error("[ManagedAuth] Failed to logout:", e);
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
await refetchStatus();
|
||||
},
|
||||
});
|
||||
|
||||
const removeAccountMutation = useMutation({
|
||||
mutationFn: (accountId: string) =>
|
||||
authApi.authRemoveAccount(authProvider, accountId),
|
||||
onSuccess: async () => {
|
||||
setPollingState("idle");
|
||||
setDeviceCode(null);
|
||||
setError(null);
|
||||
await refetchStatus();
|
||||
await queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
onError: (e) => {
|
||||
console.error("[ManagedAuth] Failed to remove account:", e);
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
},
|
||||
});
|
||||
|
||||
const setDefaultAccountMutation = useMutation({
|
||||
mutationFn: (accountId: string) =>
|
||||
authApi.authSetDefaultAccount(authProvider, accountId),
|
||||
onSuccess: async () => {
|
||||
await refetchStatus();
|
||||
await queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
onError: (e) => {
|
||||
console.error("[ManagedAuth] Failed to set default account:", e);
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
},
|
||||
});
|
||||
|
||||
const startAuth = useCallback(() => {
|
||||
setPollingState("idle");
|
||||
setDeviceCode(null);
|
||||
setError(null);
|
||||
stopPolling();
|
||||
startLoginMutation.mutate();
|
||||
}, [startLoginMutation, stopPolling]);
|
||||
|
||||
const cancelAuth = useCallback(() => {
|
||||
stopPolling();
|
||||
setPollingState("idle");
|
||||
setDeviceCode(null);
|
||||
setError(null);
|
||||
}, [stopPolling]);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
logoutMutation.mutate();
|
||||
}, [logoutMutation]);
|
||||
|
||||
const removeAccount = useCallback(
|
||||
(accountId: string) => {
|
||||
removeAccountMutation.mutate(accountId);
|
||||
},
|
||||
[removeAccountMutation],
|
||||
);
|
||||
|
||||
const setDefaultAccount = useCallback(
|
||||
(accountId: string) => {
|
||||
setDefaultAccountMutation.mutate(accountId);
|
||||
},
|
||||
[setDefaultAccountMutation],
|
||||
);
|
||||
|
||||
const accounts = authStatus?.accounts ?? [];
|
||||
|
||||
return {
|
||||
authStatus,
|
||||
isLoadingStatus,
|
||||
accounts,
|
||||
hasAnyAccount: accounts.length > 0,
|
||||
isAuthenticated: authStatus?.authenticated ?? false,
|
||||
defaultAccountId: authStatus?.default_account_id ?? null,
|
||||
migrationError: authStatus?.migration_error ?? null,
|
||||
pollingState,
|
||||
deviceCode,
|
||||
error,
|
||||
isPolling: pollingState === "polling",
|
||||
isAddingAccount: startLoginMutation.isPending || pollingState === "polling",
|
||||
isRemovingAccount: removeAccountMutation.isPending,
|
||||
isSettingDefaultAccount: setDefaultAccountMutation.isPending,
|
||||
startAuth,
|
||||
addAccount: startAuth,
|
||||
cancelAuth,
|
||||
logout,
|
||||
removeAccount,
|
||||
setDefaultAccount,
|
||||
refetchStatus,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user