feat: support OAuth & API key for Qwen/MiniMax providers (#177)

This commit is contained in:
paisley
2026-02-26 15:11:37 +08:00
committed by GitHub
Unverified
parent e1ae68ce7e
commit 7b16b6af14
12 changed files with 1581 additions and 479 deletions

View File

@@ -2,7 +2,7 @@
* Providers Settings Component
* Manage AI provider configurations and API keys
*/
import { useState, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import {
Plus,
Trash2,
@@ -14,6 +14,9 @@ import {
Loader2,
Star,
Key,
ExternalLink,
Copy,
XCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -76,8 +79,8 @@ export function ProvidersSettings() {
apiKey.trim() || undefined
);
// Auto-set as default if this is the first provider
if (providers.length === 0) {
// Auto-set as default if no default is currently configured
if (!defaultProviderId) {
await setDefaultProvider(id);
}
@@ -370,16 +373,25 @@ function ProviderCard({
) : (
<div className="flex items-center justify-between rounded-md bg-muted/50 px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<Key className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-mono text-muted-foreground truncate">
{provider.hasKey
? (provider.keyMasked && provider.keyMasked.length > 12
? `${provider.keyMasked.substring(0, 4)}...${provider.keyMasked.substring(provider.keyMasked.length - 4)}`
: provider.keyMasked)
: t('aiProviders.card.noKey')}
</span>
{provider.hasKey && (
<Badge variant="secondary" className="text-xs shrink-0">{t('aiProviders.card.configured')}</Badge>
{typeInfo?.isOAuth ? (
<>
<Key className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<Badge variant="secondary" className="text-xs shrink-0">{t('aiProviders.card.configured')}</Badge>
</>
) : (
<>
<Key className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-mono text-muted-foreground truncate">
{provider.hasKey
? (provider.keyMasked && provider.keyMasked.length > 12
? `${provider.keyMasked.substring(0, 4)}...${provider.keyMasked.substring(provider.keyMasked.length - 4)}`
: provider.keyMasked)
: t('aiProviders.card.noKey')}
</span>
{provider.hasKey && (
<Badge variant="secondary" className="text-xs shrink-0">{t('aiProviders.card.configured')}</Badge>
)}
</>
)}
</div>
<div className="flex gap-0.5 shrink-0 ml-2">
@@ -441,7 +453,96 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
const [saving, setSaving] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
// OAuth Flow State
const [oauthFlowing, setOauthFlowing] = useState(false);
const [oauthData, setOauthData] = useState<{
verificationUri: string;
userCode: string;
expiresIn: number;
} | null>(null);
const [oauthError, setOauthError] = useState<string | null>(null);
// For providers that support both OAuth and API key, let the user choose
const [authMode, setAuthMode] = useState<'oauth' | 'apikey'>('oauth');
const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === selectedType);
const isOAuth = typeInfo?.isOAuth ?? false;
const supportsApiKey = typeInfo?.supportsApiKey ?? false;
// Effective OAuth mode: pure OAuth providers, or dual-mode with oauth selected
const useOAuthFlow = isOAuth && (!supportsApiKey || authMode === 'oauth');
// Keep a ref to the latest values so the effect closure can access them
const latestRef = React.useRef({ selectedType, typeInfo, onAdd, onClose, t });
useEffect(() => {
latestRef.current = { selectedType, typeInfo, onAdd, onClose, t };
});
// Manage OAuth events
useEffect(() => {
const handleCode = (data: unknown) => {
setOauthData(data as { verificationUri: string; userCode: string; expiresIn: number });
setOauthError(null);
};
const handleSuccess = async () => {
setOauthFlowing(false);
setOauthData(null);
setValidationError(null);
const { selectedType: type, typeInfo: info, onAdd: add, onClose: close, t: translate } = latestRef.current;
// Save the provider to the store so the list refreshes automatically
if (type && add) {
try {
await add(
type,
info?.name || type,
'', // OAuth providers don't use a plain API key
{ model: info?.defaultModelId }
);
} catch {
// provider may already exist; ignore duplicate errors
}
}
close();
toast.success(translate('aiProviders.toast.added'));
};
const handleError = (data: unknown) => {
setOauthError((data as { message: string }).message);
setOauthData(null);
};
window.electron.ipcRenderer.on('oauth:code', handleCode);
window.electron.ipcRenderer.on('oauth:success', handleSuccess);
window.electron.ipcRenderer.on('oauth:error', handleError);
return () => {
if (typeof window.electron.ipcRenderer.off === 'function') {
window.electron.ipcRenderer.off('oauth:code', handleCode);
window.electron.ipcRenderer.off('oauth:success', handleSuccess);
window.electron.ipcRenderer.off('oauth:error', handleError);
}
};
}, []);
const handleStartOAuth = async () => {
if (!selectedType) return;
setOauthFlowing(true);
setOauthData(null);
setOauthError(null);
try {
await window.electron.ipcRenderer.invoke('provider:requestOAuth', selectedType, 'global');
} catch (e) {
setOauthError(String(e));
setOauthFlowing(false);
}
};
const handleCancelOAuth = async () => {
setOauthFlowing(false);
setOauthData(null);
setOauthError(null);
await window.electron.ipcRenderer.invoke('provider:cancelOAuth');
};
// Only custom can be added multiple times.
const availableTypes = PROVIDER_TYPE_INFO.filter(
@@ -562,35 +663,62 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
/>
</div>
<div className="space-y-2">
<Label htmlFor="apiKey">{t('aiProviders.dialog.apiKey')}</Label>
<div className="relative">
<Input
id="apiKey"
type={showKey ? 'text' : 'password'}
placeholder={typeInfo?.id === 'ollama' ? t('aiProviders.notRequired') : typeInfo?.placeholder}
value={apiKey}
onChange={(e) => {
setApiKey(e.target.value);
setValidationError(null);
}}
className="pr-10"
/>
{/* Auth mode toggle for providers supporting both */}
{isOAuth && supportsApiKey && (
<div className="flex rounded-lg border overflow-hidden text-sm">
<button
type="button"
onClick={() => setShowKey(!showKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setAuthMode('oauth')}
className={cn(
'flex-1 py-2 px-3 transition-colors',
authMode === 'oauth' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground'
)}
>
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
{t('aiProviders.oauth.loginMode')}
</button>
<button
onClick={() => setAuthMode('apikey')}
className={cn(
'flex-1 py-2 px-3 transition-colors',
authMode === 'apikey' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground'
)}
>
{t('aiProviders.oauth.apikeyMode')}
</button>
</div>
{validationError && (
<p className="text-xs text-destructive">{validationError}</p>
)}
<p className="text-xs text-muted-foreground">
{t('aiProviders.dialog.apiKeyStored')}
</p>
</div>
)}
{/* API Key input — shown for non-OAuth providers or when apikey mode is selected */}
{(!isOAuth || (supportsApiKey && authMode === 'apikey')) && (
<div className="space-y-2">
<Label htmlFor="apiKey">{t('aiProviders.dialog.apiKey')}</Label>
<div className="relative">
<Input
id="apiKey"
type={showKey ? 'text' : 'password'}
placeholder={typeInfo?.id === 'ollama' ? t('aiProviders.notRequired') : typeInfo?.placeholder}
value={apiKey}
onChange={(e) => {
setApiKey(e.target.value);
setValidationError(null);
}}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowKey(!showKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{validationError && (
<p className="text-xs text-destructive">{validationError}</p>
)}
<p className="text-xs text-muted-foreground">
{t('aiProviders.dialog.apiKeyStored')}
</p>
</div>
)}
{typeInfo?.showBaseUrl && (
<div className="space-y-2">
@@ -618,6 +746,98 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
/>
</div>
)}
{/* Device OAuth Trigger — only shown when in OAuth mode */}
{useOAuthFlow && (
<div className="space-y-4 pt-2">
<div className="rounded-lg bg-blue-500/10 border border-blue-500/20 p-4 text-center">
<p className="text-sm text-blue-200 mb-3 block">
{t('aiProviders.oauth.loginPrompt')}
</p>
<Button
onClick={handleStartOAuth}
disabled={oauthFlowing}
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
>
{oauthFlowing ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t('aiProviders.oauth.waiting')}</>
) : (
t('aiProviders.oauth.loginButton')
)}
</Button>
</div>
{/* OAuth Active State Modal / Inline View */}
{oauthFlowing && (
<div className="mt-4 p-4 border rounded-xl bg-card relative overflow-hidden">
{/* Background pulse effect */}
<div className="absolute inset-0 bg-primary/5 animate-pulse" />
<div className="relative z-10 flex flex-col items-center justify-center text-center space-y-4">
{oauthError ? (
<div className="text-red-400 space-y-2">
<XCircle className="h-8 w-8 mx-auto" />
<p className="font-medium">{t('aiProviders.oauth.authFailed')}</p>
<p className="text-sm opacity-80">{oauthError}</p>
<Button variant="outline" size="sm" onClick={handleCancelOAuth} className="mt-2 text-foreground">
Try Again
</Button>
</div>
) : !oauthData ? (
<div className="space-y-3 py-4">
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto" />
<p className="text-sm text-muted-foreground animate-pulse">{t('aiProviders.oauth.requestingCode')}</p>
</div>
) : (
<div className="space-y-4 w-full">
<div className="space-y-1">
<h3 className="font-medium text-lg text-foreground">{t('aiProviders.oauth.approveLogin')}</h3>
<div className="text-sm text-muted-foreground text-left mt-2 space-y-1">
<p>1. {t('aiProviders.oauth.step1')}</p>
<p>2. {t('aiProviders.oauth.step2')}</p>
<p>3. {t('aiProviders.oauth.step3')}</p>
</div>
</div>
<div className="flex items-center justify-center gap-2 p-3 bg-background border rounded-lg">
<code className="text-2xl font-mono tracking-widest font-bold text-primary">
{oauthData.userCode}
</code>
<Button
variant="ghost"
size="icon"
onClick={() => {
navigator.clipboard.writeText(oauthData.userCode);
toast.success(t('aiProviders.oauth.codeCopied'));
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<Button
variant="secondary"
className="w-full"
onClick={() => window.electron.ipcRenderer.invoke('shell:openExternal', oauthData.verificationUri)}
>
<ExternalLink className="h-4 w-4 mr-2" />
{t('aiProviders.oauth.openLoginPage')}
</Button>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground pt-2">
<Loader2 className="h-3 w-3 animate-spin" />
<span>{t('aiProviders.oauth.waitingApproval')}</span>
</div>
<Button variant="ghost" size="sm" className="w-full mt-2" onClick={handleCancelOAuth}>
Cancel
</Button>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
)}
@@ -629,6 +849,7 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
</Button>
<Button
onClick={handleAdd}
className={cn(useOAuthFlow && "hidden")}
disabled={!selectedType || saving || ((typeInfo?.showModelId ?? false) && modelId.trim().length === 0)}
>
{saving ? (

View File

@@ -1,138 +1,156 @@
{
"title": "Settings",
"subtitle": "Configure your ClawX experience",
"appearance": {
"title": "Appearance",
"description": "Customize the look and feel",
"theme": "Theme",
"light": "Light",
"dark": "Dark",
"system": "System",
"language": "Language"
"title": "Settings",
"subtitle": "Configure your ClawX experience",
"appearance": {
"title": "Appearance",
"description": "Customize the look and feel",
"theme": "Theme",
"light": "Light",
"dark": "Dark",
"system": "System",
"language": "Language"
},
"aiProviders": {
"title": "AI Providers",
"description": "Configure your AI model providers and API keys",
"add": "Add Provider",
"custom": "Custom",
"notRequired": "Not required",
"empty": {
"title": "No providers configured",
"desc": "Add an AI provider to start using ClawX",
"cta": "Add Your First Provider"
},
"aiProviders": {
"title": "AI Providers",
"description": "Configure your AI model providers and API keys",
"add": "Add Provider",
"custom": "Custom",
"notRequired": "Not required",
"empty": {
"title": "No providers configured",
"desc": "Add an AI provider to start using ClawX",
"cta": "Add Your First Provider"
},
"dialog": {
"title": "Add AI Provider",
"desc": "Configure a new AI model provider",
"displayName": "Display Name",
"apiKey": "API Key",
"apiKeyStored": "Your API key is stored locally on your machine.",
"baseUrl": "Base URL",
"modelId": "Model ID",
"cancel": "Cancel",
"change": "Change provider",
"add": "Add Provider",
"save": "Save",
"validate": "Validate"
},
"card": {
"default": "Default",
"configured": "Configured",
"noKey": "No API key set",
"setDefault": "Set as default",
"editKey": "Edit API key",
"delete": "Delete provider"
},
"toast": {
"added": "Provider added successfully",
"failedAdd": "Failed to add provider",
"deleted": "Provider deleted",
"failedDelete": "Failed to delete provider",
"defaultUpdated": "Default provider updated",
"failedDefault": "Failed to set default",
"updated": "Provider updated",
"failedUpdate": "Failed to update provider",
"invalidKey": "Invalid API key",
"modelRequired": "Model ID is required"
}
"dialog": {
"title": "Add AI Provider",
"desc": "Configure a new AI model provider",
"displayName": "Display Name",
"apiKey": "API Key",
"apiKeyStored": "Your API key is stored locally on your machine.",
"baseUrl": "Base URL",
"modelId": "Model ID",
"cancel": "Cancel",
"change": "Change provider",
"add": "Add Provider",
"save": "Save",
"validate": "Validate"
},
"gateway": {
"title": "Gateway",
"description": "OpenClaw Gateway settings",
"status": "Status",
"port": "Port",
"logs": "Logs",
"appLogs": "Application Logs",
"openFolder": "Open Folder",
"autoStart": "Auto-start Gateway",
"autoStartDesc": "Start Gateway when ClawX launches"
"card": {
"default": "Default",
"configured": "Configured",
"noKey": "No API key set",
"setDefault": "Set as default",
"editKey": "Edit API key",
"delete": "Delete provider"
},
"updates": {
"title": "Updates",
"description": "Keep ClawX up to date",
"autoCheck": "Auto-check for updates",
"autoCheckDesc": "Check for updates on startup",
"autoDownload": "Auto-update",
"autoDownloadDesc": "Automatically download and install updates",
"status": {
"checking": "Checking for updates...",
"downloading": "Downloading update...",
"available": "Update available: v{{version}}",
"downloaded": "Ready to install: v{{version}}",
"autoInstalling": "Restarting to install update in {{seconds}}s...",
"failed": "Update check failed",
"latest": "You have the latest version",
"check": "Check for updates to get the latest features"
},
"action": {
"checking": "Checking...",
"downloading": "Downloading...",
"download": "Download Update",
"install": "Install & Restart",
"cancelAutoInstall": "Cancel",
"retry": "Retry",
"check": "Check for Updates"
},
"currentVersion": "Current Version",
"whatsNew": "What's New:",
"errorDetails": "Error Details:",
"help": "When auto-update is enabled, updates are downloaded and installed automatically."
"toast": {
"added": "Provider added successfully",
"failedAdd": "Failed to add provider",
"deleted": "Provider deleted",
"failedDelete": "Failed to delete provider",
"defaultUpdated": "Default provider updated",
"failedDefault": "Failed to set default",
"updated": "Provider updated",
"failedUpdate": "Failed to update provider",
"invalidKey": "Invalid API key",
"modelRequired": "Model ID is required"
},
"advanced": {
"title": "Advanced",
"description": "Power-user options",
"devMode": "Developer Mode",
"devModeDesc": "Show developer tools and shortcuts"
},
"developer": {
"title": "Developer",
"description": "Advanced options for developers",
"console": "OpenClaw Console",
"consoleDesc": "Access the native OpenClaw management interface",
"openConsole": "Open Developer Console",
"consoleNote": "Opens the Control UI with gateway token injected",
"gatewayToken": "Gateway Token",
"gatewayTokenDesc": "Paste this into Control UI settings if prompted",
"tokenUnavailable": "Token unavailable",
"tokenCopied": "Gateway token copied",
"cli": "OpenClaw CLI",
"cliDesc": "Copy a command to run OpenClaw without modifying PATH.",
"cliPowershell": "PowerShell command.",
"cmdUnavailable": "Command unavailable",
"cmdCopied": "CLI command copied",
"installCmd": "Install \"openclaw\" Command",
"installCmdDesc": "Installs ~/.local/bin/openclaw (no admin required)",
"installTitle": "Install OpenClaw Command",
"installMessage": "Install the \"openclaw\" command?",
"installDetail": "This will create ~/.local/bin/openclaw. Ensure ~/.local/bin is on your PATH if you want to run it globally."
},
"about": {
"title": "About",
"appName": "ClawX",
"tagline": "Graphical AI Assistant",
"basedOn": "Based on OpenClaw",
"version": "Version {{version}}",
"docs": "Website",
"github": "GitHub"
"oauth": {
"loginMode": "OAuth Login",
"apikeyMode": "API Key",
"loginPrompt": "This provider requires signing in via your browser.",
"loginButton": "Login with Browser",
"waiting": "Waiting...",
"openLoginPage": "Open Login Page",
"waitingApproval": "Waiting for approval in browser...",
"cancel": "Cancel",
"codeCopied": "Code copied to clipboard",
"authFailed": "Authentication Failed",
"tryAgain": "Try Again",
"approveLogin": "Approve Login",
"step1": "Copy the authorization code below.",
"step2": "Open the login page in your browser.",
"step3": "Paste the code to approve access.",
"requestingCode": "Requesting secure login code..."
}
},
"gateway": {
"title": "Gateway",
"description": "OpenClaw Gateway settings",
"status": "Status",
"port": "Port",
"logs": "Logs",
"appLogs": "Application Logs",
"openFolder": "Open Folder",
"autoStart": "Auto-start Gateway",
"autoStartDesc": "Start Gateway when ClawX launches"
},
"updates": {
"title": "Updates",
"description": "Keep ClawX up to date",
"autoCheck": "Auto-check for updates",
"autoCheckDesc": "Check for updates on startup",
"autoDownload": "Auto-update",
"autoDownloadDesc": "Automatically download and install updates",
"status": {
"checking": "Checking for updates...",
"downloading": "Downloading update...",
"available": "Update available: v{{version}}",
"downloaded": "Ready to install: v{{version}}",
"autoInstalling": "Restarting to install update in {{seconds}}s...",
"failed": "Update check failed",
"latest": "You have the latest version",
"check": "Check for updates to get the latest features"
},
"action": {
"checking": "Checking...",
"downloading": "Downloading...",
"download": "Download Update",
"install": "Install & Restart",
"cancelAutoInstall": "Cancel",
"retry": "Retry",
"check": "Check for Updates"
},
"currentVersion": "Current Version",
"whatsNew": "What's New:",
"errorDetails": "Error Details:",
"help": "When auto-update is enabled, updates are downloaded and installed automatically."
},
"advanced": {
"title": "Advanced",
"description": "Power-user options",
"devMode": "Developer Mode",
"devModeDesc": "Show developer tools and shortcuts"
},
"developer": {
"title": "Developer",
"description": "Advanced options for developers",
"console": "OpenClaw Console",
"consoleDesc": "Access the native OpenClaw management interface",
"openConsole": "Open Developer Console",
"consoleNote": "Opens the Control UI with gateway token injected",
"gatewayToken": "Gateway Token",
"gatewayTokenDesc": "Paste this into Control UI settings if prompted",
"tokenUnavailable": "Token unavailable",
"tokenCopied": "Gateway token copied",
"cli": "OpenClaw CLI",
"cliDesc": "Copy a command to run OpenClaw without modifying PATH.",
"cliPowershell": "PowerShell command.",
"cmdUnavailable": "Command unavailable",
"cmdCopied": "CLI command copied",
"installCmd": "Install \"openclaw\" Command",
"installCmdDesc": "Installs ~/.local/bin/openclaw (no admin required)",
"installTitle": "Install OpenClaw Command",
"installMessage": "Install the \"openclaw\" command?",
"installDetail": "This will create ~/.local/bin/openclaw. Ensure ~/.local/bin is on your PATH if you want to run it globally."
},
"about": {
"title": "About",
"appName": "ClawX",
"tagline": "Graphical AI Assistant",
"basedOn": "Based on OpenClaw",
"version": "Version {{version}}",
"docs": "Website",
"github": "GitHub"
}
}

View File

@@ -1,138 +1,156 @@
{
"title": "設定",
"subtitle": "ClawX の体験をカスタマイズ",
"appearance": {
"title": "外観",
"description": "外観とスタイルをカスタマイズ",
"theme": "テーマ",
"light": "ライト",
"dark": "ダーク",
"system": "システム",
"language": "言語"
"title": "設定",
"subtitle": "ClawX の体験をカスタマイズ",
"appearance": {
"title": "外観",
"description": "外観とスタイルをカスタマイズ",
"theme": "テーマ",
"light": "ライト",
"dark": "ダーク",
"system": "システム",
"language": "言語"
},
"aiProviders": {
"title": "AI プロバイダー",
"description": "AI モデルプロバイダーと API キーを設定",
"add": "プロバイダーを追加",
"custom": "カスタム",
"notRequired": "不要",
"empty": {
"title": "プロバイダーが構成されていません",
"desc": "ClawX の使用を開始するには AI プロバイダーを追加してください",
"cta": "最初のプロバイダーを追加"
},
"aiProviders": {
"title": "AI プロバイダー",
"description": "AI モデルプロバイダーと API キーを設定",
"add": "プロバイダーを追加",
"custom": "カスタム",
"notRequired": "不要",
"empty": {
"title": "プロバイダーが構成されていません",
"desc": "ClawX の使用を開始するには AI プロバイダーを追加してください",
"cta": "最初のプロバイダーを追加"
},
"dialog": {
"title": "AI プロバイダーを追加",
"desc": "新しい AI モデルプロバイダーを構成",
"displayName": "表示名",
"apiKey": "API キー",
"apiKeyStored": "API キーはローカルマシンに保存されます。",
"baseUrl": "ベース URL",
"modelId": "モデル ID",
"cancel": "キャンセル",
"change": "プロバイダーを変更",
"add": "プロバイダーを追加",
"save": "保存",
"validate": "検証"
},
"card": {
"default": "デフォルト",
"configured": "構成済み",
"noKey": "API キー未設定",
"setDefault": "デフォルトに設定",
"editKey": "API キーを編集",
"delete": "プロバイダーを削除"
},
"toast": {
"added": "プロバイダーが正常に追加されました",
"failedAdd": "プロバイダーの追加に失敗しました",
"deleted": "プロバイダーが削除されました",
"failedDelete": "プロバイダーの削除に失敗しました",
"defaultUpdated": "デフォルトプロバイダーが更新されました",
"failedDefault": "デフォルトの設定に失敗しました",
"updated": "プロバイダーが更新されました",
"failedUpdate": "プロバイダーの更新に失敗しました",
"invalidKey": "無効な API キー",
"modelRequired": "モデル ID が必要です"
}
"dialog": {
"title": "AI プロバイダーを追加",
"desc": "新しい AI モデルプロバイダーを構成",
"displayName": "表示名",
"apiKey": "API キー",
"apiKeyStored": "API キーはローカルマシンに保存されます。",
"baseUrl": "ベース URL",
"modelId": "モデル ID",
"cancel": "キャンセル",
"change": "プロバイダーを変更",
"add": "プロバイダーを追加",
"save": "保存",
"validate": "検証"
},
"gateway": {
"title": "ゲートウェイ",
"description": "OpenClaw ゲートウェイ設定",
"status": "ステータス",
"port": "ポート",
"logs": "ログ",
"appLogs": "アプリケーションログ",
"openFolder": "フォルダーを開く",
"autoStart": "ゲートウェイ自動起動",
"autoStartDesc": "ClawX 起動時にゲートウェイを自動起動"
"card": {
"default": "デフォルト",
"configured": "構成済み",
"noKey": "API キー未設定",
"setDefault": "デフォルトに設定",
"editKey": "API キーを編集",
"delete": "プロバイダーを削除"
},
"updates": {
"title": "アップデート",
"description": "ClawX を最新に保つ",
"autoCheck": "自動更新チェック",
"autoCheckDesc": "起動時に更新を確認",
"autoDownload": "自動アップデート",
"autoDownloadDesc": "更新を自動的にダウンロードしてインストール",
"status": {
"checking": "更新を確認中...",
"downloading": "更新をダウンロード中...",
"available": "更新あり: v{{version}}",
"downloaded": "インストール準備完了: v{{version}}",
"autoInstalling": "{{seconds}} 秒後に再起動して更新をインストールします...",
"failed": "更新の確認に失敗しました",
"latest": "最新バージョンです",
"check": "更新を確認して最新の機能を入手"
},
"action": {
"checking": "確認中...",
"downloading": "ダウンロード中...",
"download": "更新をダウンロード",
"install": "インストールして再起動",
"cancelAutoInstall": "キャンセル",
"retry": "再試行",
"check": "更新を確認"
},
"currentVersion": "現在のバージョン",
"whatsNew": "更新内容:",
"errorDetails": "エラー詳細:",
"help": "自動アップデートが有効な場合、更新は自動的にダウンロードされインストールされます。"
"toast": {
"added": "プロバイダーが正常に追加されました",
"failedAdd": "プロバイダーの追加に失敗しました",
"deleted": "プロバイダーが削除されました",
"failedDelete": "プロバイダーの削除に失敗しました",
"defaultUpdated": "デフォルトプロバイダーが更新されました",
"failedDefault": "デフォルトの設定に失敗しました",
"updated": "プロバイダーが更新されました",
"failedUpdate": "プロバイダーの更新に失敗しました",
"invalidKey": "無効な API キー",
"modelRequired": "モデル ID が必要です"
},
"advanced": {
"title": "詳細設定",
"description": "上級ユーザー向けオプション",
"devMode": "開発者モード",
"devModeDesc": "開発者ツールとショートカットを表示"
},
"developer": {
"title": "開発者",
"description": "開発者向け詳細オプション",
"console": "OpenClaw コンソール",
"consoleDesc": "ネイティブ OpenClaw 管理インターフェースにアクセス",
"openConsole": "開発者コンソールを開く",
"consoleNote": "ゲートウェイトークンを注入して Control UI を開きます",
"gatewayToken": "ゲートウェイトークン",
"gatewayTokenDesc": "Control UI の設定に求められた場合、これを貼り付けてください",
"tokenUnavailable": "トークンが利用できません",
"tokenCopied": "ゲートウェイトークンをコピーしました",
"cli": "OpenClaw CLI",
"cliDesc": "PATH を変更せずに OpenClaw を実行するコマンドをコピー。",
"cliPowershell": "PowerShell コマンド。",
"cmdUnavailable": "コマンドが利用できません",
"cmdCopied": "CLI コマンドをコピーしました",
"installCmd": "\"openclaw\" コマンドをインストール",
"installCmdDesc": "~/.local/bin/openclaw をインストール(管理者権限不要)",
"installTitle": "OpenClaw コマンドをインストール",
"installMessage": "\"openclaw\" コマンドをインストールしますか?",
"installDetail": "~/.local/bin/openclaw が作成されます。グローバルに実行するには ~/.local/bin が PATH に含まれていることを確認してください。"
},
"about": {
"title": "バージョン情報",
"appName": "ClawX",
"tagline": "グラフィカル AI アシスタント",
"basedOn": "OpenClaw ベース",
"version": "バージョン {{version}}",
"docs": "公式サイト",
"github": "GitHub"
"oauth": {
"loginMode": "OAuthログイン",
"apikeyMode": "APIキー",
"loginPrompt": "このプロバイダーはブラウザからのサインインが必要です。",
"loginButton": "ブラウザでログイン",
"waiting": "待機中...",
"openLoginPage": "ログインページを開く",
"waitingApproval": "ブラウザの承認を待っています...",
"cancel": "キャンセル",
"codeCopied": "コードをクリップボードにコピーしました",
"authFailed": "認証に失敗しました",
"tryAgain": "再試行",
"approveLogin": "ログインを承認",
"step1": "以下の認証コードをコピーしてください。",
"step2": "ブラウザでログインページを開いてください",
"step3": "コードを貼り付けてアクセスを承認してください。",
"requestingCode": "セキュアログインコードを取得中..."
}
},
"gateway": {
"title": "ゲートウェイ",
"description": "OpenClaw ゲートウェイ設定",
"status": "ステータス",
"port": "ポート",
"logs": "ログ",
"appLogs": "アプリケーションログ",
"openFolder": "フォルダーを開く",
"autoStart": "ゲートウェイ自動起動",
"autoStartDesc": "ClawX 起動時にゲートウェイを自動起動"
},
"updates": {
"title": "アップデート",
"description": "ClawX を最新に保つ",
"autoCheck": "自動更新チェック",
"autoCheckDesc": "起動時に更新を確認",
"autoDownload": "自動アップデート",
"autoDownloadDesc": "更新を自動的にダウンロードしてインストール",
"status": {
"checking": "更新を確認中...",
"downloading": "更新をダウンロード中...",
"available": "更新あり: v{{version}}",
"downloaded": "インストール準備完了: v{{version}}",
"autoInstalling": "{{seconds}} 秒後に再起動して更新をインストールします...",
"failed": "更新の確認に失敗しました",
"latest": "最新バージョンです",
"check": "更新を確認して最新の機能を入手"
},
"action": {
"checking": "確認中...",
"downloading": "ダウンロード中...",
"download": "更新をダウンロード",
"install": "インストールして再起動",
"cancelAutoInstall": "キャンセル",
"retry": "再試行",
"check": "更新を確認"
},
"currentVersion": "現在のバージョン",
"whatsNew": "更新内容:",
"errorDetails": "エラー詳細:",
"help": "自動アップデートが有効な場合、更新は自動的にダウンロードされインストールされます。"
},
"advanced": {
"title": "詳細設定",
"description": "上級ユーザー向けオプション",
"devMode": "開発者モード",
"devModeDesc": "開発者ツールとショートカットを表示"
},
"developer": {
"title": "開発者",
"description": "開発者向け詳細オプション",
"console": "OpenClaw コンソール",
"consoleDesc": "ネイティブ OpenClaw 管理インターフェースにアクセス",
"openConsole": "開発者コンソールを開く",
"consoleNote": "ゲートウェイトークンを注入して Control UI を開きます",
"gatewayToken": "ゲートウェイトークン",
"gatewayTokenDesc": "Control UI の設定に求められた場合、これを貼り付けてください",
"tokenUnavailable": "トークンが利用できません",
"tokenCopied": "ゲートウェイトークンをコピーしました",
"cli": "OpenClaw CLI",
"cliDesc": "PATH を変更せずに OpenClaw を実行するコマンドをコピー。",
"cliPowershell": "PowerShell コマンド。",
"cmdUnavailable": "コマンドが利用できません",
"cmdCopied": "CLI コマンドをコピーしました",
"installCmd": "\"openclaw\" コマンドをインストール",
"installCmdDesc": "~/.local/bin/openclaw をインストール(管理者権限不要)",
"installTitle": "OpenClaw コマンドをインストール",
"installMessage": "\"openclaw\" コマンドをインストールしますか?",
"installDetail": "~/.local/bin/openclaw が作成されます。グローバルに実行するには ~/.local/bin が PATH に含まれていることを確認してください。"
},
"about": {
"title": "バージョン情報",
"appName": "ClawX",
"tagline": "グラフィカル AI アシスタント",
"basedOn": "OpenClaw ベース",
"version": "バージョン {{version}}",
"docs": "公式サイト",
"github": "GitHub"
}
}

View File

@@ -1,138 +1,156 @@
{
"title": "设置",
"subtitle": "配置您的 ClawX 体验",
"appearance": {
"title": "外观",
"description": "自定义外观和风格",
"theme": "主题",
"light": "浅色",
"dark": "深色",
"system": "跟随系统",
"language": "语言"
"title": "设置",
"subtitle": "配置您的 ClawX 体验",
"appearance": {
"title": "外观",
"description": "自定义外观和风格",
"theme": "主题",
"light": "浅色",
"dark": "深色",
"system": "跟随系统",
"language": "语言"
},
"aiProviders": {
"title": "AI 模型提供商",
"description": "配置 AI 模型提供商和 API 密钥",
"add": "添加提供商",
"custom": "自定义",
"notRequired": "非必填",
"empty": {
"title": "未配置提供商",
"desc": "添加 AI 提供商以开始使用 ClawX",
"cta": "添加您的第一个提供商"
},
"aiProviders": {
"title": "AI 模型提供商",
"description": "配置 AI 模型提供商和 API 密钥",
"add": "添加提供商",
"custom": "自定义",
"notRequired": "非必填",
"empty": {
"title": "未配置提供商",
"desc": "添加 AI 提供商以开始使用 ClawX",
"cta": "添加您的第一个提供商"
},
"dialog": {
"title": "添加 AI 提供商",
"desc": "配置新的 AI 模型提供商",
"displayName": "显示名称",
"apiKey": "API 密钥",
"apiKeyStored": "您的 API 密钥存储在本地机器上。",
"baseUrl": "基础 URL",
"modelId": "模型 ID",
"cancel": "取消",
"change": "更换提供商",
"add": "添加提供商",
"save": "保存",
"validate": "验证"
},
"card": {
"default": "默认",
"configured": "已配置",
"noKey": "未设置 API 密钥",
"setDefault": "设为默认",
"editKey": "编辑 API 密钥",
"delete": "删除提供商"
},
"toast": {
"added": "提供商添加成功",
"failedAdd": "添加提供商失败",
"deleted": "提供商已删除",
"failedDelete": "删除提供商失败",
"defaultUpdated": "默认提供商已更新",
"failedDefault": "设置默认失败",
"updated": "提供商已更新",
"failedUpdate": "更新提供商失败",
"invalidKey": "无效的 API 密钥",
"modelRequired": "需要模型 ID"
}
"dialog": {
"title": "添加 AI 提供商",
"desc": "配置新的 AI 模型提供商",
"displayName": "显示名称",
"apiKey": "API 密钥",
"apiKeyStored": "您的 API 密钥存储在本地机器上。",
"baseUrl": "基础 URL",
"modelId": "模型 ID",
"cancel": "取消",
"change": "更换提供商",
"add": "添加提供商",
"save": "保存",
"validate": "验证"
},
"gateway": {
"title": "网关",
"description": "OpenClaw 网关设置",
"status": "状态",
"port": "端口",
"logs": "日志",
"appLogs": "应用日志",
"openFolder": "打开文件夹",
"autoStart": "自动启动网关",
"autoStartDesc": "ClawX 启动时自动启动网关"
"card": {
"default": "默认",
"configured": "已配置",
"noKey": "未设置 API 密钥",
"setDefault": "设为默认",
"editKey": "编辑 API 密钥",
"delete": "删除提供商"
},
"updates": {
"title": "更新",
"description": "保持 ClawX 最新",
"autoCheck": "自动检查更新",
"autoCheckDesc": "启动时检查更新",
"autoDownload": "自动更新",
"autoDownloadDesc": "自动下载并安装更新",
"status": {
"checking": "正在检查更新...",
"downloading": "正在下载更新...",
"available": "可用更新v{{version}}",
"downloaded": "准备安装v{{version}}",
"autoInstalling": "将在 {{seconds}} 秒后重启并安装更新...",
"failed": "检查更新失败",
"latest": "您已拥有最新版本",
"check": "检查更新以获取最新功能"
},
"action": {
"checking": "检查中...",
"downloading": "下载中...",
"download": "下载更新",
"install": "安装并重启",
"cancelAutoInstall": "取消",
"retry": "重试",
"check": "检查更新"
},
"currentVersion": "当前版本",
"whatsNew": "更新内容:",
"errorDetails": "错误详情:",
"help": "开启自动更新后,更新将自动下载并安装。"
"toast": {
"added": "提供商添加成功",
"failedAdd": "添加提供商失败",
"deleted": "提供商已删除",
"failedDelete": "删除提供商失败",
"defaultUpdated": "默认提供商已更新",
"failedDefault": "设置默认失败",
"updated": "提供商已更新",
"failedUpdate": "更新提供商失败",
"invalidKey": "无效的 API 密钥",
"modelRequired": "需要模型 ID"
},
"advanced": {
"title": "高级",
"description": "高级选项",
"devMode": "开发者模式",
"devModeDesc": "显示开发者工具和快捷方式"
},
"developer": {
"title": "开发者",
"description": "开发者高级选项",
"console": "OpenClaw 控制台",
"consoleDesc": "访问原生 OpenClaw 管理界面",
"openConsole": "打开开发者控制台",
"consoleNote": "使用注入的网关令牌打开控制台",
"gatewayToken": "网关令牌",
"gatewayTokenDesc": "如果需要,将此粘贴到控制台设置中",
"tokenUnavailable": "令牌不可用",
"tokenCopied": "网关令牌已复制",
"cli": "OpenClaw CLI",
"cliDesc": "复制命令以运行 OpenClaw无需修改 PATH。",
"cliPowershell": "PowerShell 命令。",
"cmdUnavailable": "命令不可用",
"cmdCopied": "CLI 命令已复制",
"installCmd": "安装 \"openclaw\" 命令",
"installCmdDesc": "安装 ~/.local/bin/openclaw无需管理员权限",
"installTitle": "安装 OpenClaw 命令",
"installMessage": "安装 \"openclaw\" 命令?",
"installDetail": "这将创建 ~/.local/bin/openclaw。确保 ~/.local/bin 在您的 PATH 中。"
},
"about": {
"title": "关于",
"appName": "ClawX",
"tagline": "图形化 AI 助手",
"basedOn": "基于 OpenClaw",
"version": "版本 {{version}}",
"docs": "官网",
"github": "GitHub"
"oauth": {
"loginMode": "OAuth 登录",
"apikeyMode": "API 密钥",
"loginPrompt": "此提供商需要通过浏览器登录授权。",
"loginButton": "浏览器登录",
"waiting": "等待中...",
"openLoginPage": "打开登录页面",
"waitingApproval": "等待浏览器中的授权...",
"cancel": "取消",
"codeCopied": "代码已复制到剪贴板",
"authFailed": "认证失败",
"tryAgain": "重试",
"approveLogin": "确认登录",
"step1": "复制下方的授权码。",
"step2": "在浏览器中打开登录页面。",
"step3": "粘贴授权码以批准访问。",
"requestingCode": "正在获取安全登录码..."
}
},
"gateway": {
"title": "网关",
"description": "OpenClaw 网关设置",
"status": "状态",
"port": "端口",
"logs": "日志",
"appLogs": "应用日志",
"openFolder": "打开文件夹",
"autoStart": "自动启动网关",
"autoStartDesc": "ClawX 启动时自动启动网关"
},
"updates": {
"title": "更新",
"description": "保持 ClawX 最新",
"autoCheck": "自动检查更新",
"autoCheckDesc": "启动时检查更新",
"autoDownload": "自动更新",
"autoDownloadDesc": "自动下载并安装更新",
"status": {
"checking": "正在检查更新...",
"downloading": "正在下载更新...",
"available": "可用更新v{{version}}",
"downloaded": "准备安装v{{version}}",
"autoInstalling": "将在 {{seconds}} 秒后重启并安装更新...",
"failed": "检查更新失败",
"latest": "您已拥有最新版本",
"check": "检查更新以获取最新功能"
},
"action": {
"checking": "检查中...",
"downloading": "下载中...",
"download": "下载更新",
"install": "安装并重启",
"cancelAutoInstall": "取消",
"retry": "重试",
"check": "检查更新"
},
"currentVersion": "当前版本",
"whatsNew": "更新内容:",
"errorDetails": "错误详情:",
"help": "开启自动更新后,更新将自动下载并安装。"
},
"advanced": {
"title": "高级",
"description": "高级选项",
"devMode": "开发者模式",
"devModeDesc": "显示开发者工具和快捷方式"
},
"developer": {
"title": "开发者",
"description": "开发者高级选项",
"console": "OpenClaw 控制台",
"consoleDesc": "访问原生 OpenClaw 管理界面",
"openConsole": "打开开发者控制台",
"consoleNote": "使用注入的网关令牌打开控制台",
"gatewayToken": "网关令牌",
"gatewayTokenDesc": "如果需要,将此粘贴到控制台设置中",
"tokenUnavailable": "令牌不可用",
"tokenCopied": "网关令牌已复制",
"cli": "OpenClaw CLI",
"cliDesc": "复制命令以运行 OpenClaw无需修改 PATH。",
"cliPowershell": "PowerShell 命令。",
"cmdUnavailable": "命令不可用",
"cmdCopied": "CLI 命令已复制",
"installCmd": "安装 \"openclaw\" 命令",
"installCmdDesc": "安装 ~/.local/bin/openclaw无需管理员权限",
"installTitle": "安装 OpenClaw 命令",
"installMessage": "安装 \"openclaw\" 命令?",
"installDetail": "这将创建 ~/.local/bin/openclaw。确保 ~/.local/bin 在您的 PATH 中。"
},
"about": {
"title": "关于",
"appName": "ClawX",
"tagline": "图形化 AI 助手",
"basedOn": "基于 OpenClaw",
"version": "版本 {{version}}",
"docs": "官网",
"github": "GitHub"
}
}

View File

@@ -12,6 +12,8 @@ export const PROVIDER_TYPES = [
'openrouter',
'moonshot',
'siliconflow',
'minimax-portal',
'qwen-portal',
'ollama',
'custom',
] as const;
@@ -51,6 +53,10 @@ export interface ProviderTypeInfo {
modelIdPlaceholder?: string;
/** Default model ID to pre-fill */
defaultModelId?: string;
/** Whether this provider uses OAuth device flow instead of an API key */
isOAuth?: boolean;
/** Whether this provider also accepts a direct API key (in addition to OAuth) */
supportsApiKey?: boolean;
}
import { providerIcons } from '@/assets/providers';
@@ -63,6 +69,8 @@ export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [
{ id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...', model: 'Multi-Model', requiresApiKey: true },
{ id: 'moonshot', name: 'Moonshot (CN)', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.5' },
{ id: 'siliconflow', name: 'SiliconFlow (CN)', icon: '🌊', placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, defaultBaseUrl: 'https://api.siliconflow.cn/v1', defaultModelId: 'Pro/moonshotai/Kimi-K2.5' },
{ id: 'minimax-portal', name: 'MiniMax (CN)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.1' },
{ id: 'qwen-portal', name: 'Qwen (CN)', icon: '☁️', placeholder: 'sk-...', model: 'Qwen', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'coder-model' },
{ id: 'ollama', name: 'Ollama', icon: '🦙', placeholder: 'Not required', requiresApiKey: false, defaultBaseUrl: 'http://localhost:11434', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'qwen3:latest' },
{ id: 'custom', name: 'Custom', icon: '⚙️', placeholder: 'API key...', requiresApiKey: true, showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'your-provider/model-id' },
];

View File

@@ -19,6 +19,7 @@ import {
XCircle,
ExternalLink,
BookOpen,
Copy,
} from 'lucide-react';
import { TitleBar } from '@/components/layout/TitleBar';
import { Button } from '@/components/ui/button';
@@ -715,6 +716,84 @@ function ProviderContent({
const [providerMenuOpen, setProviderMenuOpen] = useState(false);
const providerMenuRef = useRef<HTMLDivElement | null>(null);
const [authMode, setAuthMode] = useState<'oauth' | 'apikey'>('oauth');
// OAuth Flow State
const [oauthFlowing, setOauthFlowing] = useState(false);
const [oauthData, setOauthData] = useState<{
verificationUri: string;
userCode: string;
expiresIn: number;
} | null>(null);
const [oauthError, setOauthError] = useState<string | null>(null);
// Manage OAuth events
useEffect(() => {
const handleCode = (data: unknown) => {
setOauthData(data as { verificationUri: string; userCode: string; expiresIn: number });
setOauthError(null);
};
const handleSuccess = async () => {
setOauthFlowing(false);
setOauthData(null);
setKeyValid(true);
if (selectedProvider) {
try {
await window.electron.ipcRenderer.invoke('provider:setDefault', selectedProvider);
} catch (error) {
console.error('Failed to set default provider:', error);
}
}
onConfiguredChange(true);
toast.success(t('provider.valid'));
};
const handleError = (data: unknown) => {
setOauthError((data as { message: string }).message);
setOauthData(null);
};
window.electron.ipcRenderer.on('oauth:code', handleCode);
window.electron.ipcRenderer.on('oauth:success', handleSuccess);
window.electron.ipcRenderer.on('oauth:error', handleError);
return () => {
// Clean up manually if the API provides removeListener, though `on` in preloads might not return an unsub.
// Easiest is to just let it be, or if they have `off`:
if (typeof window.electron.ipcRenderer.off === 'function') {
window.electron.ipcRenderer.off('oauth:code', handleCode);
window.electron.ipcRenderer.off('oauth:success', handleSuccess);
window.electron.ipcRenderer.off('oauth:error', handleError);
}
};
}, [onConfiguredChange, t, selectedProvider]);
const handleStartOAuth = async () => {
if (!selectedProvider) return;
setOauthFlowing(true);
setOauthData(null);
setOauthError(null);
// Default to global region for MiniMax in setup
const region = 'global';
try {
await window.electron.ipcRenderer.invoke('provider:requestOAuth', selectedProvider, region);
} catch (e) {
setOauthError(String(e));
setOauthFlowing(false);
}
};
const handleCancelOAuth = async () => {
setOauthFlowing(false);
setOauthData(null);
setOauthError(null);
await window.electron.ipcRenderer.invoke('provider:cancelOAuth');
};
// On mount, try to restore previously configured provider
useEffect(() => {
let cancelled = false;
@@ -819,6 +898,9 @@ function ProviderContent({
const showBaseUrlField = selectedProviderData?.showBaseUrl ?? false;
const showModelIdField = selectedProviderData?.showModelId ?? false;
const requiresKey = selectedProviderData?.requiresApiKey ?? false;
const isOAuth = selectedProviderData?.isOAuth ?? false;
const supportsApiKey = selectedProviderData?.supportsApiKey ?? false;
const useOAuthFlow = isOAuth && (!supportsApiKey || authMode === 'oauth');
const handleValidateAndSave = async () => {
if (!selectedProvider) return;
@@ -904,7 +986,8 @@ function ProviderContent({
const canSubmit =
selectedProvider
&& (requiresKey ? apiKey.length > 0 : true)
&& (showModelIdField ? modelId.trim().length > 0 : true);
&& (showModelIdField ? modelId.trim().length > 0 : true)
&& !useOAuthFlow;
const handleSelectProvider = (providerId: string) => {
onSelectProvider(providerId);
@@ -913,6 +996,7 @@ function ProviderContent({
onApiKeyChange('');
setKeyValid(null);
setProviderMenuOpen(false);
setAuthMode('oauth');
};
return (
@@ -1047,8 +1131,32 @@ function ProviderContent({
</div>
)}
{/* Auth mode toggle for providers supporting both */}
{isOAuth && supportsApiKey && (
<div className="flex rounded-lg border overflow-hidden text-sm">
<button
onClick={() => setAuthMode('oauth')}
className={cn(
'flex-1 py-2 px-3 transition-colors',
authMode === 'oauth' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground'
)}
>
{t('settings:aiProviders.oauth.loginMode')}
</button>
<button
onClick={() => setAuthMode('apikey')}
className={cn(
'flex-1 py-2 px-3 transition-colors',
authMode === 'apikey' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground'
)}
>
{t('settings:aiProviders.oauth.apikeyMode')}
</button>
</div>
)}
{/* API Key field (hidden for ollama) */}
{requiresKey && (
{(!isOAuth || (supportsApiKey && authMode === 'apikey')) && requiresKey && (
<div className="space-y-2">
<Label htmlFor="apiKey">{t('provider.apiKey')}</Label>
<div className="relative">
@@ -1076,11 +1184,104 @@ function ProviderContent({
</div>
)}
{/* Device OAuth Trigger */}
{useOAuthFlow && (
<div className="space-y-4 pt-2">
<div className="rounded-lg bg-blue-500/10 border border-blue-500/20 p-4 text-center">
<p className="text-sm text-blue-200 mb-3 block">
This provider requires signing in via your browser.
</p>
<Button
onClick={handleStartOAuth}
disabled={oauthFlowing}
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
>
{oauthFlowing ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Waiting...</>
) : (
'Login with Browser'
)}
</Button>
</div>
{/* OAuth Active State Modal / Inline View */}
{oauthFlowing && (
<div className="mt-4 p-4 border rounded-xl bg-card relative overflow-hidden">
{/* Background pulse effect */}
<div className="absolute inset-0 bg-primary/5 animate-pulse" />
<div className="relative z-10 flex flex-col items-center justify-center text-center space-y-4">
{oauthError ? (
<div className="text-red-400 space-y-2">
<XCircle className="h-8 w-8 mx-auto" />
<p className="font-medium">Authentication Failed</p>
<p className="text-sm opacity-80">{oauthError}</p>
<Button variant="outline" size="sm" onClick={handleCancelOAuth} className="mt-2">
Try Again
</Button>
</div>
) : !oauthData ? (
<div className="space-y-3 py-4">
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto" />
<p className="text-sm text-muted-foreground animate-pulse">Requesting secure login code...</p>
</div>
) : (
<div className="space-y-4 w-full">
<div className="space-y-1">
<h3 className="font-medium text-lg">Approve Login</h3>
<div className="text-sm text-muted-foreground text-left mt-2 space-y-1">
<p>1. Copy the authorization code below.</p>
<p>2. Open the login page in your browser.</p>
<p>3. Paste the code to approve access.</p>
</div>
</div>
<div className="flex items-center justify-center gap-2 p-3 bg-background border rounded-lg">
<code className="text-2xl font-mono tracking-widest font-bold text-primary">
{oauthData.userCode}
</code>
<Button
variant="ghost"
size="icon"
onClick={() => {
navigator.clipboard.writeText(oauthData.userCode);
toast.success('Code copied to clipboard');
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<Button
variant="secondary"
className="w-full"
onClick={() => window.electron.ipcRenderer.invoke('shell:openExternal', oauthData.verificationUri)}
>
<ExternalLink className="h-4 w-4 mr-2" />
Open Login Page
</Button>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground pt-2">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Waiting for approval in browser...</span>
</div>
<Button variant="ghost" size="sm" className="w-full mt-2" onClick={handleCancelOAuth}>
Cancel
</Button>
</div>
)}
</div>
</div>
)}
</div>
)}
{/* Validate & Save */}
<Button
onClick={handleValidateAndSave}
disabled={!canSubmit || validating}
className="w-full"
className={cn("w-full", useOAuthFlow && "hidden")}
>
{validating ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />