feat: support OAuth & API key for Qwen/MiniMax providers (#177)
This commit is contained in:
committed by
GitHub
Unverified
parent
e1ae68ce7e
commit
7b16b6af14
@@ -27,7 +27,7 @@ import { getOpenClawCliCommand, installOpenClawCliMac } from '../utils/openclaw-
|
|||||||
import { getSetting } from '../utils/store';
|
import { getSetting } from '../utils/store';
|
||||||
import {
|
import {
|
||||||
saveProviderKeyToOpenClaw,
|
saveProviderKeyToOpenClaw,
|
||||||
removeProviderKeyFromOpenClaw,
|
removeProviderFromOpenClaw,
|
||||||
setOpenClawDefaultModel,
|
setOpenClawDefaultModel,
|
||||||
setOpenClawDefaultModelWithOverride,
|
setOpenClawDefaultModelWithOverride,
|
||||||
updateAgentModelProvider,
|
updateAgentModelProvider,
|
||||||
@@ -47,6 +47,7 @@ import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-set
|
|||||||
import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config';
|
import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config';
|
||||||
import { whatsAppLoginManager } from '../utils/whatsapp-login';
|
import { whatsAppLoginManager } from '../utils/whatsapp-login';
|
||||||
import { getProviderConfig } from '../utils/provider-registry';
|
import { getProviderConfig } from '../utils/provider-registry';
|
||||||
|
import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register all IPC handlers
|
* Register all IPC handlers
|
||||||
@@ -95,6 +96,9 @@ export function registerIpcHandlers(
|
|||||||
// WhatsApp handlers
|
// WhatsApp handlers
|
||||||
registerWhatsAppHandlers(mainWindow);
|
registerWhatsAppHandlers(mainWindow);
|
||||||
|
|
||||||
|
// Device OAuth handlers (Code Plan)
|
||||||
|
registerDeviceOAuthHandlers(mainWindow);
|
||||||
|
|
||||||
// File staging handlers (upload/send separation)
|
// File staging handlers (upload/send separation)
|
||||||
registerFileHandlers();
|
registerFileHandlers();
|
||||||
}
|
}
|
||||||
@@ -777,6 +781,35 @@ function registerWhatsAppHandlers(mainWindow: BrowserWindow): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device OAuth Handlers (Code Plan)
|
||||||
|
*/
|
||||||
|
function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void {
|
||||||
|
deviceOAuthManager.setWindow(mainWindow);
|
||||||
|
|
||||||
|
// Request Provider OAuth initialization
|
||||||
|
ipcMain.handle('provider:requestOAuth', async (_, provider: OAuthProviderType, region?: 'global' | 'cn') => {
|
||||||
|
try {
|
||||||
|
logger.info(`provider:requestOAuth for ${provider}`);
|
||||||
|
await deviceOAuthManager.startFlow(provider, region);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('provider:requestOAuth failed', error);
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel Provider OAuth
|
||||||
|
ipcMain.handle('provider:cancelOAuth', async () => {
|
||||||
|
try {
|
||||||
|
await deviceOAuthManager.stopFlow();
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('provider:cancelOAuth failed', error);
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provider-related IPC handlers
|
* Provider-related IPC handlers
|
||||||
@@ -822,12 +855,12 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
|||||||
const existing = await getProvider(providerId);
|
const existing = await getProvider(providerId);
|
||||||
await deleteProvider(providerId);
|
await deleteProvider(providerId);
|
||||||
|
|
||||||
// Best-effort cleanup in OpenClaw auth profiles
|
// Best-effort cleanup in OpenClaw auth profiles & openclaw.json config
|
||||||
if (existing?.type) {
|
if (existing?.type) {
|
||||||
try {
|
try {
|
||||||
removeProviderKeyFromOpenClaw(existing.type);
|
removeProviderFromOpenClaw(existing.type);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to remove key from OpenClaw auth-profiles:', err);
|
console.warn('Failed to completely remove provider from OpenClaw:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -891,7 +924,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
|||||||
saveProviderKeyToOpenClaw(nextConfig.type, trimmedKey);
|
saveProviderKeyToOpenClaw(nextConfig.type, trimmedKey);
|
||||||
} else {
|
} else {
|
||||||
await deleteApiKey(providerId);
|
await deleteApiKey(providerId);
|
||||||
removeProviderKeyFromOpenClaw(nextConfig.type);
|
removeProviderFromOpenClaw(nextConfig.type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -942,7 +975,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
|||||||
saveProviderKeyToOpenClaw(previousProviderType, previousKey);
|
saveProviderKeyToOpenClaw(previousProviderType, previousKey);
|
||||||
} else {
|
} else {
|
||||||
await deleteApiKey(providerId);
|
await deleteApiKey(providerId);
|
||||||
removeProviderKeyFromOpenClaw(previousProviderType);
|
removeProviderFromOpenClaw(previousProviderType);
|
||||||
}
|
}
|
||||||
} catch (rollbackError) {
|
} catch (rollbackError) {
|
||||||
console.warn('Failed to rollback provider updateWithKey:', rollbackError);
|
console.warn('Failed to rollback provider updateWithKey:', rollbackError);
|
||||||
@@ -962,9 +995,11 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
|||||||
const provider = await getProvider(providerId);
|
const provider = await getProvider(providerId);
|
||||||
const providerType = provider?.type || providerId;
|
const providerType = provider?.type || providerId;
|
||||||
try {
|
try {
|
||||||
removeProviderKeyFromOpenClaw(providerType);
|
if (providerType) {
|
||||||
|
removeProviderFromOpenClaw(providerType);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to remove key from OpenClaw auth-profiles:', err);
|
console.warn('Failed to completely remove provider from OpenClaw:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -992,29 +1027,66 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
|||||||
const provider = await getProvider(providerId);
|
const provider = await getProvider(providerId);
|
||||||
if (provider) {
|
if (provider) {
|
||||||
try {
|
try {
|
||||||
// If the provider has a user-specified model (e.g. siliconflow),
|
// OAuth providers (qwen-portal, minimax-portal) have their openclaw.json
|
||||||
// build the full model string: "providerType/modelId"
|
// model config already written by `openclaw models auth login --set-default`.
|
||||||
const modelOverride = provider.model
|
// Non-OAuth providers need us to write it here.
|
||||||
? `${provider.type}/${provider.model}`
|
const OAUTH_PROVIDER_TYPES = ['qwen-portal', 'minimax-portal'];
|
||||||
: undefined;
|
const isOAuthProvider = OAUTH_PROVIDER_TYPES.includes(provider.type);
|
||||||
|
|
||||||
if (provider.type === 'custom' || provider.type === 'ollama') {
|
if (!isOAuthProvider) {
|
||||||
// For runtime-configured providers, use user-entered base URL/api.
|
// If the provider has a user-specified model (e.g. siliconflow),
|
||||||
// Do NOT set apiKeyEnv — the OpenClaw gateway resolves custom
|
// build the full model string: "providerType/modelId"
|
||||||
// provider keys via auth-profiles, not the config apiKey field.
|
// Guard against double-prefixing: provider.model may already
|
||||||
setOpenClawDefaultModelWithOverride(provider.type, modelOverride, {
|
// include the provider type (e.g. "siliconflow/DeepSeek-V3").
|
||||||
baseUrl: provider.baseUrl,
|
const modelOverride = provider.model
|
||||||
api: 'openai-completions',
|
? (provider.model.startsWith(`${provider.type}/`)
|
||||||
});
|
? provider.model
|
||||||
|
: `${provider.type}/${provider.model}`)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (provider.type === 'custom' || provider.type === 'ollama') {
|
||||||
|
// For runtime-configured providers, use user-entered base URL/api.
|
||||||
|
// Do NOT set apiKeyEnv — the OpenClaw gateway resolves custom
|
||||||
|
// provider keys via auth-profiles, not the config apiKey field.
|
||||||
|
setOpenClawDefaultModelWithOverride(provider.type, modelOverride, {
|
||||||
|
baseUrl: provider.baseUrl,
|
||||||
|
api: 'openai-completions',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setOpenClawDefaultModel(provider.type, modelOverride);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep auth-profiles in sync with the default provider instance.
|
||||||
|
// This is especially important when multiple custom providers exist.
|
||||||
|
const providerKey = await getApiKey(providerId);
|
||||||
|
if (providerKey) {
|
||||||
|
saveProviderKeyToOpenClaw(provider.type, providerKey);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setOpenClawDefaultModel(provider.type, modelOverride);
|
// OAuth providers (minimax-portal, qwen-portal): write the provider config
|
||||||
}
|
// using the model and baseUrl stored by device-oauth.ts at login time.
|
||||||
|
// These providers use their own API format (not standard OpenAI completions).
|
||||||
|
const defaultBaseUrl = provider.type === 'minimax-portal'
|
||||||
|
? 'https://api.minimax.io/anthropic'
|
||||||
|
: 'https://portal.qwen.ai/v1';
|
||||||
|
const api: 'anthropic-messages' | 'openai-completions' = provider.type === 'minimax-portal'
|
||||||
|
? 'anthropic-messages'
|
||||||
|
: 'openai-completions';
|
||||||
|
|
||||||
// Keep auth-profiles in sync with the default provider instance.
|
let baseUrl = provider.baseUrl || defaultBaseUrl;
|
||||||
// This is especially important when multiple custom providers exist.
|
if (provider.type === 'minimax-portal' && baseUrl && !baseUrl.endsWith('/anthropic')) {
|
||||||
const providerKey = await getApiKey(providerId);
|
baseUrl = baseUrl.replace(/\/$/, '') + '/anthropic';
|
||||||
if (providerKey) {
|
}
|
||||||
saveProviderKeyToOpenClaw(provider.type, providerKey);
|
|
||||||
|
setOpenClawDefaultModelWithOverride(provider.type, undefined, {
|
||||||
|
baseUrl,
|
||||||
|
api,
|
||||||
|
// OAuth placeholder — Gateway uses this to look up OAuth credentials
|
||||||
|
// from auth-profiles.json instead of a static API key.
|
||||||
|
apiKeyEnv: provider.type === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Configured openclaw.json for OAuth provider "${provider.type}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For custom/ollama providers, also update the per-agent models.json
|
// For custom/ollama providers, also update the per-agent models.json
|
||||||
@@ -1056,6 +1128,8 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Get default provider
|
// Get default provider
|
||||||
ipcMain.handle('provider:getDefault', async () => {
|
ipcMain.handle('provider:getDefault', async () => {
|
||||||
return await getDefaultProvider();
|
return await getDefaultProvider();
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ const electronAPI = {
|
|||||||
'provider:setDefault',
|
'provider:setDefault',
|
||||||
'provider:getDefault',
|
'provider:getDefault',
|
||||||
'provider:validateKey',
|
'provider:validateKey',
|
||||||
|
'provider:requestOAuth',
|
||||||
|
'provider:cancelOAuth',
|
||||||
// Cron
|
// Cron
|
||||||
'cron:list',
|
'cron:list',
|
||||||
'cron:create',
|
'cron:create',
|
||||||
@@ -163,6 +165,9 @@ const electronAPI = {
|
|||||||
'update:error',
|
'update:error',
|
||||||
'update:auto-install-countdown',
|
'update:auto-install-countdown',
|
||||||
'cron:updated',
|
'cron:updated',
|
||||||
|
'oauth:code',
|
||||||
|
'oauth:success',
|
||||||
|
'oauth:error',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (validChannels.includes(channel)) {
|
if (validChannels.includes(channel)) {
|
||||||
@@ -202,6 +207,9 @@ const electronAPI = {
|
|||||||
'update:downloaded',
|
'update:downloaded',
|
||||||
'update:error',
|
'update:error',
|
||||||
'update:auto-install-countdown',
|
'update:auto-install-countdown',
|
||||||
|
'oauth:code',
|
||||||
|
'oauth:success',
|
||||||
|
'oauth:error',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (validChannels.includes(channel)) {
|
if (validChannels.includes(channel)) {
|
||||||
|
|||||||
322
electron/utils/device-oauth.ts
Normal file
322
electron/utils/device-oauth.ts
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
/**
|
||||||
|
* Device OAuth Manager
|
||||||
|
*
|
||||||
|
* Delegates MiniMax and Qwen OAuth to the OpenClaw extension oauth.ts functions
|
||||||
|
* imported directly from the bundled openclaw package at build time.
|
||||||
|
*
|
||||||
|
* This approach:
|
||||||
|
* - Avoids hardcoding client_id (lives in openclaw extension)
|
||||||
|
* - Avoids duplicating HTTP OAuth logic
|
||||||
|
* - Avoids spawning CLI process (which requires interactive TTY)
|
||||||
|
* - Works identically on macOS, Windows, and Linux
|
||||||
|
*
|
||||||
|
* The extension oauth.ts files only use `node:crypto` and global `fetch` —
|
||||||
|
* they are pure Node.js HTTP functions, no TTY, no prompter needed.
|
||||||
|
*
|
||||||
|
* We provide our own callbacks (openUrl/note/progress) that hook into
|
||||||
|
* the Electron IPC system to display UI in the ClawX frontend.
|
||||||
|
*/
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { BrowserWindow, shell } from 'electron';
|
||||||
|
import { logger } from './logger';
|
||||||
|
import { saveProvider, getProvider, ProviderConfig } from './secure-storage';
|
||||||
|
import { getProviderDefaultModel } from './provider-registry';
|
||||||
|
import { isOpenClawPresent } from './paths';
|
||||||
|
import {
|
||||||
|
loginMiniMaxPortalOAuth,
|
||||||
|
type MiniMaxOAuthToken,
|
||||||
|
type MiniMaxRegion,
|
||||||
|
} from '../../node_modules/openclaw/extensions/minimax-portal-auth/oauth';
|
||||||
|
import {
|
||||||
|
loginQwenPortalOAuth,
|
||||||
|
type QwenOAuthToken,
|
||||||
|
} from '../../node_modules/openclaw/extensions/qwen-portal-auth/oauth';
|
||||||
|
import { saveOAuthTokenToOpenClaw, setOpenClawDefaultModelWithOverride } from './openclaw-auth';
|
||||||
|
|
||||||
|
export type OAuthProviderType = 'minimax-portal' | 'qwen-portal';
|
||||||
|
export type { MiniMaxRegion };
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// DeviceOAuthManager
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class DeviceOAuthManager extends EventEmitter {
|
||||||
|
private activeProvider: OAuthProviderType | null = null;
|
||||||
|
private active: boolean = false;
|
||||||
|
private mainWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
|
setWindow(window: BrowserWindow) {
|
||||||
|
this.mainWindow = window;
|
||||||
|
}
|
||||||
|
|
||||||
|
async startFlow(provider: OAuthProviderType, region: MiniMaxRegion = 'global'): Promise<boolean> {
|
||||||
|
if (this.active) {
|
||||||
|
await this.stopFlow();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.active = true;
|
||||||
|
this.activeProvider = provider;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (provider === 'minimax-portal') {
|
||||||
|
await this.runMiniMaxFlow(region);
|
||||||
|
} else if (provider === 'qwen-portal') {
|
||||||
|
await this.runQwenFlow();
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported OAuth provider: ${provider}`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (!this.active) {
|
||||||
|
// Flow was cancelled — not an error
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
logger.error(`[DeviceOAuth] Flow error for ${provider}:`, error);
|
||||||
|
this.emitError(error instanceof Error ? error.message : String(error));
|
||||||
|
this.active = false;
|
||||||
|
this.activeProvider = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopFlow(): Promise<void> {
|
||||||
|
this.active = false;
|
||||||
|
this.activeProvider = null;
|
||||||
|
logger.info('[DeviceOAuth] Flow explicitly stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────
|
||||||
|
// MiniMax flow
|
||||||
|
// ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async runMiniMaxFlow(region: MiniMaxRegion): Promise<void> {
|
||||||
|
if (!isOpenClawPresent()) {
|
||||||
|
throw new Error('OpenClaw package not found');
|
||||||
|
}
|
||||||
|
const provider = this.activeProvider!;
|
||||||
|
|
||||||
|
const token: MiniMaxOAuthToken = await loginMiniMaxPortalOAuth({
|
||||||
|
region,
|
||||||
|
openUrl: async (url) => {
|
||||||
|
logger.info(`[DeviceOAuth] MiniMax opening browser: ${url}`);
|
||||||
|
// Open the authorization URL in the system browser
|
||||||
|
shell.openExternal(url).catch((err) =>
|
||||||
|
logger.warn(`[DeviceOAuth] Failed to open browser:`, err)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
note: async (message, _title) => {
|
||||||
|
if (!this.active) return;
|
||||||
|
// The extension calls note() with a message containing
|
||||||
|
// the user_code and verification_uri — parse them for the UI
|
||||||
|
const { verificationUri, userCode } = this.parseNote(message);
|
||||||
|
if (verificationUri && userCode) {
|
||||||
|
this.emitCode({ provider, verificationUri, userCode, expiresIn: 300 });
|
||||||
|
} else {
|
||||||
|
logger.info(`[DeviceOAuth] MiniMax note: ${message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
progress: {
|
||||||
|
update: (msg) => logger.info(`[DeviceOAuth] MiniMax progress: ${msg}`),
|
||||||
|
stop: (msg) => logger.info(`[DeviceOAuth] MiniMax progress done: ${msg ?? ''}`),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!this.active) return;
|
||||||
|
|
||||||
|
await this.onSuccess('minimax-portal', {
|
||||||
|
access: token.access,
|
||||||
|
refresh: token.refresh,
|
||||||
|
expires: token.expires,
|
||||||
|
// MiniMax returns a per-account resourceUrl as the API base URL
|
||||||
|
resourceUrl: token.resourceUrl,
|
||||||
|
// MiniMax uses Anthropic Messages API format
|
||||||
|
api: 'anthropic-messages',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────
|
||||||
|
// Qwen flow
|
||||||
|
// ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async runQwenFlow(): Promise<void> {
|
||||||
|
if (!isOpenClawPresent()) {
|
||||||
|
throw new Error('OpenClaw package not found');
|
||||||
|
}
|
||||||
|
const provider = this.activeProvider!;
|
||||||
|
|
||||||
|
const token: QwenOAuthToken = await loginQwenPortalOAuth({
|
||||||
|
openUrl: async (url) => {
|
||||||
|
logger.info(`[DeviceOAuth] Qwen opening browser: ${url}`);
|
||||||
|
shell.openExternal(url).catch((err) =>
|
||||||
|
logger.warn(`[DeviceOAuth] Failed to open browser:`, err)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
note: async (message, _title) => {
|
||||||
|
if (!this.active) return;
|
||||||
|
const { verificationUri, userCode } = this.parseNote(message);
|
||||||
|
if (verificationUri && userCode) {
|
||||||
|
this.emitCode({ provider, verificationUri, userCode, expiresIn: 300 });
|
||||||
|
} else {
|
||||||
|
logger.info(`[DeviceOAuth] Qwen note: ${message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
progress: {
|
||||||
|
update: (msg) => logger.info(`[DeviceOAuth] Qwen progress: ${msg}`),
|
||||||
|
stop: (msg) => logger.info(`[DeviceOAuth] Qwen progress done: ${msg ?? ''}`),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!this.active) return;
|
||||||
|
|
||||||
|
await this.onSuccess('qwen-portal', {
|
||||||
|
access: token.access,
|
||||||
|
refresh: token.refresh,
|
||||||
|
expires: token.expires,
|
||||||
|
// Qwen returns a per-account resourceUrl as the API base URL
|
||||||
|
resourceUrl: token.resourceUrl,
|
||||||
|
// Qwen uses OpenAI Completions API format
|
||||||
|
api: 'openai-completions',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────
|
||||||
|
// Success handler
|
||||||
|
// ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async onSuccess(providerType: OAuthProviderType, token: {
|
||||||
|
access: string;
|
||||||
|
refresh: string;
|
||||||
|
expires: number;
|
||||||
|
resourceUrl?: string;
|
||||||
|
api: 'anthropic-messages' | 'openai-completions';
|
||||||
|
}) {
|
||||||
|
this.active = false;
|
||||||
|
this.activeProvider = null;
|
||||||
|
logger.info(`[DeviceOAuth] Successfully completed OAuth for ${providerType}`);
|
||||||
|
|
||||||
|
// 1. Write OAuth token to OpenClaw's auth-profiles.json in native OAuth format
|
||||||
|
// (matches what `openclaw models auth login` → upsertAuthProfile writes)
|
||||||
|
try {
|
||||||
|
saveOAuthTokenToOpenClaw(providerType, {
|
||||||
|
access: token.access,
|
||||||
|
refresh: token.refresh,
|
||||||
|
expires: token.expires,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`[DeviceOAuth] Failed to save OAuth token to OpenClaw:`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Write openclaw.json: set default model + provider config (baseUrl/api/models)
|
||||||
|
// This mirrors what the OpenClaw plugin's configPatch does after CLI login.
|
||||||
|
// The baseUrl comes from token.resourceUrl (per-account URL from the OAuth server)
|
||||||
|
// or falls back to the provider's default public endpoint.
|
||||||
|
// Note: MiniMax Anthropic-compatible API requires the /anthropic suffix.
|
||||||
|
const defaultBaseUrl = providerType === 'minimax-portal'
|
||||||
|
? 'https://api.minimax.io/anthropic'
|
||||||
|
: 'https://portal.qwen.ai/v1';
|
||||||
|
|
||||||
|
let baseUrl = token.resourceUrl || defaultBaseUrl;
|
||||||
|
|
||||||
|
// If MiniMax returned a resourceUrl (e.g. https://api.minimax.io) but no /anthropic suffix,
|
||||||
|
// we must append it because we use the 'anthropic-messages' API mode
|
||||||
|
if (providerType === 'minimax-portal' && baseUrl && !baseUrl.endsWith('/anthropic')) {
|
||||||
|
baseUrl = baseUrl.replace(/\/$/, '') + '/anthropic';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setOpenClawDefaultModelWithOverride(providerType, undefined, {
|
||||||
|
baseUrl,
|
||||||
|
api: token.api,
|
||||||
|
// OAuth placeholder — tells Gateway to resolve credentials
|
||||||
|
// from auth-profiles.json (type: 'oauth') instead of a static API key.
|
||||||
|
// This matches what the OpenClaw plugin's configPatch writes:
|
||||||
|
// minimax-portal → apiKey: 'minimax-oauth'
|
||||||
|
// qwen-portal → apiKey: 'qwen-oauth'
|
||||||
|
apiKeyEnv: providerType === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`[DeviceOAuth] Failed to configure openclaw models:`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Save provider record in ClawX's own store so UI shows it as configured
|
||||||
|
const existing = await getProvider(providerType);
|
||||||
|
const providerConfig: ProviderConfig = {
|
||||||
|
id: providerType,
|
||||||
|
name: providerType === 'minimax-portal' ? 'MiniMax' : 'Qwen',
|
||||||
|
type: providerType,
|
||||||
|
enabled: existing?.enabled ?? true,
|
||||||
|
baseUrl: existing?.baseUrl,
|
||||||
|
model: existing?.model || getProviderDefaultModel(providerType),
|
||||||
|
createdAt: existing?.createdAt || new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await saveProvider(providerConfig);
|
||||||
|
|
||||||
|
// 4. Emit success to frontend
|
||||||
|
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||||
|
this.mainWindow.webContents.send('oauth:success', { provider: providerType, success: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse user_code and verification_uri from the note message sent by
|
||||||
|
* the OpenClaw extension's loginXxxPortalOAuth function.
|
||||||
|
*
|
||||||
|
* Note format (minimax-portal-auth/oauth.ts):
|
||||||
|
* "Open https://platform.minimax.io/oauth-authorize?user_code=dyMj_wOhpK&client=... to approve access.\n"
|
||||||
|
* "If prompted, enter the code dyMj_wOhpK.\n"
|
||||||
|
* ...
|
||||||
|
*
|
||||||
|
* user_code format: mixed-case alphanumeric with underscore, e.g. "dyMj_wOhpK"
|
||||||
|
*/
|
||||||
|
private parseNote(message: string): { verificationUri?: string; userCode?: string } {
|
||||||
|
// Primary: extract URL (everything between "Open " and " to")
|
||||||
|
const urlMatch = message.match(/Open\s+(https?:\/\/\S+?)\s+to/i);
|
||||||
|
const verificationUri = urlMatch?.[1];
|
||||||
|
|
||||||
|
let userCode: string | undefined;
|
||||||
|
|
||||||
|
// Method 1: extract user_code from URL query param (most reliable)
|
||||||
|
if (verificationUri) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(verificationUri);
|
||||||
|
const qp = parsed.searchParams.get('user_code');
|
||||||
|
if (qp) userCode = qp;
|
||||||
|
} catch {
|
||||||
|
// fall through to text-based extraction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: text-based extraction — matches mixed-case alnum + underscore/hyphen codes
|
||||||
|
if (!userCode) {
|
||||||
|
const codeMatch = message.match(/enter.*?code\s+([A-Za-z0-9][A-Za-z0-9_-]{3,})/i);
|
||||||
|
if (codeMatch?.[1]) userCode = codeMatch[1].replace(/\.$/, ''); // strip trailing period
|
||||||
|
}
|
||||||
|
|
||||||
|
return { verificationUri, userCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitCode(data: {
|
||||||
|
provider: string;
|
||||||
|
verificationUri: string;
|
||||||
|
userCode: string;
|
||||||
|
expiresIn: number;
|
||||||
|
}) {
|
||||||
|
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||||
|
this.mainWindow.webContents.send('oauth:code', data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitError(message: string) {
|
||||||
|
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||||
|
this.mainWindow.webContents.send('oauth:error', { message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deviceOAuthManager = new DeviceOAuthManager();
|
||||||
@@ -24,12 +24,23 @@ interface AuthProfileEntry {
|
|||||||
key: string;
|
key: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth profile entry for an OAuth token (matches OpenClaw plugin format)
|
||||||
|
*/
|
||||||
|
interface OAuthProfileEntry {
|
||||||
|
type: 'oauth';
|
||||||
|
provider: string;
|
||||||
|
access: string;
|
||||||
|
refresh: string;
|
||||||
|
expires: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth profiles store format
|
* Auth profiles store format
|
||||||
*/
|
*/
|
||||||
interface AuthProfilesStore {
|
interface AuthProfilesStore {
|
||||||
version: number;
|
version: number;
|
||||||
profiles: Record<string, AuthProfileEntry>;
|
profiles: Record<string, AuthProfileEntry | OAuthProfileEntry>;
|
||||||
order?: Record<string, string[]>;
|
order?: Record<string, string[]>;
|
||||||
lastGood?: Record<string, string>;
|
lastGood?: Record<string, string>;
|
||||||
}
|
}
|
||||||
@@ -96,6 +107,52 @@ function discoverAgentIds(): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save an OAuth token to OpenClaw's auth-profiles.json.
|
||||||
|
* Writes in OpenClaw's native OAuth credential format (type: 'oauth'),
|
||||||
|
* matching exactly what `openclaw models auth login` (upsertAuthProfile) writes.
|
||||||
|
*
|
||||||
|
* @param provider - Provider type (e.g. 'minimax-portal', 'qwen-portal')
|
||||||
|
* @param token - OAuth token from the provider's login function
|
||||||
|
* @param agentId - Optional single agent ID. When omitted, writes to every agent.
|
||||||
|
*/
|
||||||
|
export function saveOAuthTokenToOpenClaw(
|
||||||
|
provider: string,
|
||||||
|
token: { access: string; refresh: string; expires: number },
|
||||||
|
agentId?: string
|
||||||
|
): void {
|
||||||
|
const agentIds = agentId ? [agentId] : discoverAgentIds();
|
||||||
|
if (agentIds.length === 0) agentIds.push('main');
|
||||||
|
|
||||||
|
for (const id of agentIds) {
|
||||||
|
const store = readAuthProfiles(id);
|
||||||
|
const profileId = `${provider}:default`;
|
||||||
|
|
||||||
|
const entry: OAuthProfileEntry = {
|
||||||
|
type: 'oauth',
|
||||||
|
provider,
|
||||||
|
access: token.access,
|
||||||
|
refresh: token.refresh,
|
||||||
|
expires: token.expires,
|
||||||
|
};
|
||||||
|
|
||||||
|
store.profiles[profileId] = entry;
|
||||||
|
|
||||||
|
if (!store.order) store.order = {};
|
||||||
|
if (!store.order[provider]) store.order[provider] = [];
|
||||||
|
if (!store.order[provider].includes(profileId)) {
|
||||||
|
store.order[provider].push(profileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!store.lastGood) store.lastGood = {};
|
||||||
|
store.lastGood[provider] = profileId;
|
||||||
|
|
||||||
|
writeAuthProfiles(store, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Saved OAuth token for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save a provider API key to OpenClaw's auth-profiles.json
|
* Save a provider API key to OpenClaw's auth-profiles.json
|
||||||
* This writes the key in the format OpenClaw expects so the gateway
|
* This writes the key in the format OpenClaw expects so the gateway
|
||||||
@@ -109,10 +166,20 @@ function discoverAgentIds(): string[] {
|
|||||||
* @param agentId - Optional single agent ID. When omitted, writes to every agent.
|
* @param agentId - Optional single agent ID. When omitted, writes to every agent.
|
||||||
*/
|
*/
|
||||||
export function saveProviderKeyToOpenClaw(
|
export function saveProviderKeyToOpenClaw(
|
||||||
|
|
||||||
provider: string,
|
provider: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
agentId?: string
|
agentId?: string
|
||||||
): void {
|
): void {
|
||||||
|
// OAuth providers (qwen-portal, minimax-portal) typically have their credentials
|
||||||
|
// managed by OpenClaw plugins via `openclaw models auth login`.
|
||||||
|
// Skip only if there's no explicit API key — meaning the user is using OAuth.
|
||||||
|
// If the user provided an actual API key, write it normally.
|
||||||
|
const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal'];
|
||||||
|
if (OAUTH_PROVIDERS.includes(provider) && !apiKey) {
|
||||||
|
console.log(`Skipping auth-profiles write for OAuth provider "${provider}" (no API key provided, using OAuth)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const agentIds = agentId ? [agentId] : discoverAgentIds();
|
const agentIds = agentId ? [agentId] : discoverAgentIds();
|
||||||
if (agentIds.length === 0) agentIds.push('main');
|
if (agentIds.length === 0) agentIds.push('main');
|
||||||
|
|
||||||
@@ -158,6 +225,13 @@ export function removeProviderKeyFromOpenClaw(
|
|||||||
provider: string,
|
provider: string,
|
||||||
agentId?: string
|
agentId?: string
|
||||||
): void {
|
): void {
|
||||||
|
// OAuth providers have their credentials managed by OpenClaw plugins.
|
||||||
|
// Do NOT delete their auth-profiles entries.
|
||||||
|
const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal'];
|
||||||
|
if (OAUTH_PROVIDERS.includes(provider)) {
|
||||||
|
console.log(`Skipping auth-profiles removal for OAuth provider "${provider}" (managed by OpenClaw plugin)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const agentIds = agentId ? [agentId] : discoverAgentIds();
|
const agentIds = agentId ? [agentId] : discoverAgentIds();
|
||||||
if (agentIds.length === 0) agentIds.push('main');
|
if (agentIds.length === 0) agentIds.push('main');
|
||||||
|
|
||||||
@@ -183,6 +257,60 @@ export function removeProviderKeyFromOpenClaw(
|
|||||||
console.log(`Removed API key for provider "${provider}" from OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
|
console.log(`Removed API key for provider "${provider}" from OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a provider completely from OpenClaw (delete config, disable plugins, delete keys)
|
||||||
|
*/
|
||||||
|
export function removeProviderFromOpenClaw(provider: string): void {
|
||||||
|
// 1. Remove from auth-profiles.json
|
||||||
|
const agentIds = discoverAgentIds();
|
||||||
|
if (agentIds.length === 0) agentIds.push('main');
|
||||||
|
for (const id of agentIds) {
|
||||||
|
const store = readAuthProfiles(id);
|
||||||
|
const profileId = `${provider}:default`;
|
||||||
|
if (store.profiles[profileId]) {
|
||||||
|
delete store.profiles[profileId];
|
||||||
|
if (store.order?.[provider]) {
|
||||||
|
store.order[provider] = store.order[provider].filter((aid) => aid !== profileId);
|
||||||
|
if (store.order[provider].length === 0) delete store.order[provider];
|
||||||
|
}
|
||||||
|
if (store.lastGood?.[provider] === profileId) delete store.lastGood[provider];
|
||||||
|
writeAuthProfiles(store, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Remove from openclaw.json
|
||||||
|
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
||||||
|
try {
|
||||||
|
if (existsSync(configPath)) {
|
||||||
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
let modified = false;
|
||||||
|
|
||||||
|
// Disable plugin (for OAuth like qwen-portal-auth)
|
||||||
|
if (config.plugins?.entries) {
|
||||||
|
const pluginName = `${provider}-auth`;
|
||||||
|
if (config.plugins.entries[pluginName]) {
|
||||||
|
config.plugins.entries[pluginName].enabled = false;
|
||||||
|
modified = true;
|
||||||
|
console.log(`Disabled OpenClaw plugin: ${pluginName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from models.providers
|
||||||
|
if (config.models?.providers?.[provider]) {
|
||||||
|
delete config.models.providers[provider];
|
||||||
|
modified = true;
|
||||||
|
console.log(`Removed OpenClaw provider config: ${provider}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modified) {
|
||||||
|
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Failed to remove provider ${provider} from openclaw.json:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build environment variables object with all stored API keys
|
* Build environment variables object with all stored API keys
|
||||||
* for passing to the Gateway process
|
* for passing to the Gateway process
|
||||||
@@ -386,6 +514,16 @@ export function setOpenClawDefaultModelWithOverride(
|
|||||||
}
|
}
|
||||||
config.gateway = gateway;
|
config.gateway = gateway;
|
||||||
|
|
||||||
|
// Ensure the extension plugin is marked as enabled in openclaw.json
|
||||||
|
// Without this, the OpenClaw Gateway will silently wipe the provider config on startup
|
||||||
|
if (provider === 'minimax-portal' || provider === 'qwen-portal') {
|
||||||
|
const plugins = (config.plugins || {}) as Record<string, unknown>;
|
||||||
|
const entries = (plugins.entries || {}) as Record<string, unknown>;
|
||||||
|
entries[`${provider}-auth`] = { enabled: true };
|
||||||
|
plugins.entries = entries;
|
||||||
|
config.plugins = plugins;
|
||||||
|
}
|
||||||
|
|
||||||
const dir = join(configPath, '..');
|
const dir = join(configPath, '..');
|
||||||
if (!existsSync(dir)) {
|
if (!existsSync(dir)) {
|
||||||
mkdirSync(dir, { recursive: true });
|
mkdirSync(dir, { recursive: true });
|
||||||
@@ -397,6 +535,52 @@ export function setOpenClawDefaultModelWithOverride(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a set of all active provider IDs configured in openclaw.json and auth-profiles.json.
|
||||||
|
* This is used to sync ClawX's local provider list with the actual OpenClaw engine state.
|
||||||
|
*/
|
||||||
|
export function getActiveOpenClawProviders(): Set<string> {
|
||||||
|
const activeProviders = new Set<string>();
|
||||||
|
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
||||||
|
|
||||||
|
// 1. Read openclaw.json models.providers
|
||||||
|
try {
|
||||||
|
if (existsSync(configPath)) {
|
||||||
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
const providers = config.models?.providers;
|
||||||
|
if (providers && typeof providers === 'object') {
|
||||||
|
for (const key of Object.keys(providers)) {
|
||||||
|
activeProviders.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to read openclaw.json for active providers:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Read openclaw.json plugins.entries for OAuth providers
|
||||||
|
try {
|
||||||
|
if (existsSync(configPath)) {
|
||||||
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
const plugins = config.plugins?.entries;
|
||||||
|
if (plugins && typeof plugins === 'object') {
|
||||||
|
for (const [pluginId, meta] of Object.entries(plugins)) {
|
||||||
|
// If the plugin ends with -auth and is enabled, it's an OAuth provider
|
||||||
|
// e.g. 'qwen-portal-auth' implies provider 'qwen-portal'
|
||||||
|
if (pluginId.endsWith('-auth') && (meta as Record<string, unknown>).enabled) {
|
||||||
|
const providerId = pluginId.replace(/-auth$/, '');
|
||||||
|
activeProviders.add(providerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to read openclaw.json for active plugins:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return activeProviders;
|
||||||
|
}
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
// Re-export for backwards compatibility
|
||||||
/**
|
/**
|
||||||
* Write the ClawX gateway token into ~/.openclaw/openclaw.json so the
|
* Write the ClawX gateway token into ~/.openclaw/openclaw.json so the
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export const BUILTIN_PROVIDER_TYPES = [
|
|||||||
'openrouter',
|
'openrouter',
|
||||||
'moonshot',
|
'moonshot',
|
||||||
'siliconflow',
|
'siliconflow',
|
||||||
|
'minimax-portal',
|
||||||
|
'qwen-portal',
|
||||||
'ollama',
|
'ollama',
|
||||||
] as const;
|
] as const;
|
||||||
export type BuiltinProviderType = (typeof BUILTIN_PROVIDER_TYPES)[number];
|
export type BuiltinProviderType = (typeof BUILTIN_PROVIDER_TYPES)[number];
|
||||||
@@ -94,6 +96,12 @@ const REGISTRY: Record<string, ProviderBackendMeta> = {
|
|||||||
apiKeyEnv: 'SILICONFLOW_API_KEY',
|
apiKeyEnv: 'SILICONFLOW_API_KEY',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'minimax-portal': {
|
||||||
|
defaultModel: 'minimax-portal/MiniMax-M2.1',
|
||||||
|
},
|
||||||
|
'qwen-portal': {
|
||||||
|
defaultModel: 'qwen-portal/coder-model',
|
||||||
|
},
|
||||||
custom: {
|
custom: {
|
||||||
envVar: 'CUSTOM_API_KEY',
|
envVar: 'CUSTOM_API_KEY',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
* Keys are stored in plain text alongside provider configs in a single electron-store.
|
* Keys are stored in plain text alongside provider configs in a single electron-store.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ProviderType } from './provider-registry';
|
||||||
|
import { getActiveOpenClawProviders } from './openclaw-auth';
|
||||||
|
|
||||||
// Lazy-load electron-store (ESM module)
|
// Lazy-load electron-store (ESM module)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let providerStore: any = null;
|
let providerStore: any = null;
|
||||||
@@ -29,7 +32,7 @@ async function getProviderStore() {
|
|||||||
export interface ProviderConfig {
|
export interface ProviderConfig {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'anthropic' | 'openai' | 'google' | 'openrouter' | 'moonshot' | 'siliconflow' | 'ollama' | 'custom';
|
type: ProviderType;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -204,14 +207,32 @@ export async function getProviderWithKeyInfo(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all providers with key info (for UI display)
|
* Get all providers with key info (for UI display)
|
||||||
|
* Also synchronizes ClawX local provider list with OpenClaw's actual config.
|
||||||
*/
|
*/
|
||||||
export async function getAllProvidersWithKeyInfo(): Promise<
|
export async function getAllProvidersWithKeyInfo(): Promise<
|
||||||
Array<ProviderConfig & { hasKey: boolean; keyMasked: string | null }>
|
Array<ProviderConfig & { hasKey: boolean; keyMasked: string | null }>
|
||||||
> {
|
> {
|
||||||
const providers = await getAllProviders();
|
const providers = await getAllProviders();
|
||||||
const results: Array<ProviderConfig & { hasKey: boolean; keyMasked: string | null }> = [];
|
const results: Array<ProviderConfig & { hasKey: boolean; keyMasked: string | null }> = [];
|
||||||
|
const activeOpenClawProviders = getActiveOpenClawProviders();
|
||||||
|
|
||||||
|
// We need to avoid deleting native ones like 'anthropic' or 'google'
|
||||||
|
// that don't need to exist in openclaw.json models.providers
|
||||||
|
const OpenClawBuiltinList = [
|
||||||
|
'anthropic', 'openai', 'google', 'moonshot', 'siliconflow', 'ollama'
|
||||||
|
];
|
||||||
|
|
||||||
for (const provider of providers) {
|
for (const provider of providers) {
|
||||||
|
// Sync check: If it's a custom/OAuth provider and it no longer exists in OpenClaw config
|
||||||
|
// (e.g. wiped by Gateway due to missing plugin, or manually deleted by user)
|
||||||
|
// we should remove it from ClawX UI to stay consistent.
|
||||||
|
const isBuiltin = OpenClawBuiltinList.includes(provider.type);
|
||||||
|
if (!isBuiltin && !activeOpenClawProviders.has(provider.type) && !activeOpenClawProviders.has(provider.id)) {
|
||||||
|
console.log(`[Sync] Provider ${provider.id} (${provider.type}) missing from OpenClaw, dropping from ClawX UI`);
|
||||||
|
await deleteProvider(provider.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const apiKey = await getApiKey(provider.id);
|
const apiKey = await getApiKey(provider.id);
|
||||||
let keyMasked: string | null = null;
|
let keyMasked: string | null = null;
|
||||||
|
|
||||||
@@ -232,3 +253,4 @@ export async function getAllProvidersWithKeyInfo(): Promise<
|
|||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Providers Settings Component
|
* Providers Settings Component
|
||||||
* Manage AI provider configurations and API keys
|
* Manage AI provider configurations and API keys
|
||||||
*/
|
*/
|
||||||
import { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Star,
|
Star,
|
||||||
Key,
|
Key,
|
||||||
|
ExternalLink,
|
||||||
|
Copy,
|
||||||
|
XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -76,8 +79,8 @@ export function ProvidersSettings() {
|
|||||||
apiKey.trim() || undefined
|
apiKey.trim() || undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-set as default if this is the first provider
|
// Auto-set as default if no default is currently configured
|
||||||
if (providers.length === 0) {
|
if (!defaultProviderId) {
|
||||||
await setDefaultProvider(id);
|
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 justify-between rounded-md bg-muted/50 px-3 py-2">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Key className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
{typeInfo?.isOAuth ? (
|
||||||
<span className="text-sm font-mono text-muted-foreground truncate">
|
<>
|
||||||
{provider.hasKey
|
<Key className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
? (provider.keyMasked && provider.keyMasked.length > 12
|
<Badge variant="secondary" className="text-xs shrink-0">{t('aiProviders.card.configured')}</Badge>
|
||||||
? `${provider.keyMasked.substring(0, 4)}...${provider.keyMasked.substring(provider.keyMasked.length - 4)}`
|
</>
|
||||||
: provider.keyMasked)
|
) : (
|
||||||
: t('aiProviders.card.noKey')}
|
<>
|
||||||
</span>
|
<Key className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
{provider.hasKey && (
|
<span className="text-sm font-mono text-muted-foreground truncate">
|
||||||
<Badge variant="secondary" className="text-xs shrink-0">{t('aiProviders.card.configured')}</Badge>
|
{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>
|
||||||
<div className="flex gap-0.5 shrink-0 ml-2">
|
<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 [saving, setSaving] = useState(false);
|
||||||
const [validationError, setValidationError] = useState<string | null>(null);
|
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 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.
|
// Only custom can be added multiple times.
|
||||||
const availableTypes = PROVIDER_TYPE_INFO.filter(
|
const availableTypes = PROVIDER_TYPE_INFO.filter(
|
||||||
@@ -562,35 +663,62 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* Auth mode toggle for providers supporting both */}
|
||||||
<Label htmlFor="apiKey">{t('aiProviders.dialog.apiKey')}</Label>
|
{isOAuth && supportsApiKey && (
|
||||||
<div className="relative">
|
<div className="flex rounded-lg border overflow-hidden text-sm">
|
||||||
<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
|
<button
|
||||||
type="button"
|
onClick={() => setAuthMode('oauth')}
|
||||||
onClick={() => setShowKey(!showKey)}
|
className={cn(
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
'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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{validationError && (
|
)}
|
||||||
<p className="text-xs text-destructive">{validationError}</p>
|
|
||||||
)}
|
{/* API Key input — shown for non-OAuth providers or when apikey mode is selected */}
|
||||||
<p className="text-xs text-muted-foreground">
|
{(!isOAuth || (supportsApiKey && authMode === 'apikey')) && (
|
||||||
{t('aiProviders.dialog.apiKeyStored')}
|
<div className="space-y-2">
|
||||||
</p>
|
<Label htmlFor="apiKey">{t('aiProviders.dialog.apiKey')}</Label>
|
||||||
</div>
|
<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 && (
|
{typeInfo?.showBaseUrl && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -618,6 +746,98 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -629,6 +849,7 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
|
className={cn(useOAuthFlow && "hidden")}
|
||||||
disabled={!selectedType || saving || ((typeInfo?.showModelId ?? false) && modelId.trim().length === 0)}
|
disabled={!selectedType || saving || ((typeInfo?.showModelId ?? false) && modelId.trim().length === 0)}
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
|
|||||||
@@ -1,138 +1,156 @@
|
|||||||
{
|
{
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
"subtitle": "Configure your ClawX experience",
|
"subtitle": "Configure your ClawX experience",
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Appearance",
|
"title": "Appearance",
|
||||||
"description": "Customize the look and feel",
|
"description": "Customize the look and feel",
|
||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
"light": "Light",
|
"light": "Light",
|
||||||
"dark": "Dark",
|
"dark": "Dark",
|
||||||
"system": "System",
|
"system": "System",
|
||||||
"language": "Language"
|
"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": {
|
"dialog": {
|
||||||
"title": "AI Providers",
|
"title": "Add AI Provider",
|
||||||
"description": "Configure your AI model providers and API keys",
|
"desc": "Configure a new AI model provider",
|
||||||
"add": "Add Provider",
|
"displayName": "Display Name",
|
||||||
"custom": "Custom",
|
"apiKey": "API Key",
|
||||||
"notRequired": "Not required",
|
"apiKeyStored": "Your API key is stored locally on your machine.",
|
||||||
"empty": {
|
"baseUrl": "Base URL",
|
||||||
"title": "No providers configured",
|
"modelId": "Model ID",
|
||||||
"desc": "Add an AI provider to start using ClawX",
|
"cancel": "Cancel",
|
||||||
"cta": "Add Your First Provider"
|
"change": "Change provider",
|
||||||
},
|
"add": "Add Provider",
|
||||||
"dialog": {
|
"save": "Save",
|
||||||
"title": "Add AI Provider",
|
"validate": "Validate"
|
||||||
"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"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"gateway": {
|
"card": {
|
||||||
"title": "Gateway",
|
"default": "Default",
|
||||||
"description": "OpenClaw Gateway settings",
|
"configured": "Configured",
|
||||||
"status": "Status",
|
"noKey": "No API key set",
|
||||||
"port": "Port",
|
"setDefault": "Set as default",
|
||||||
"logs": "Logs",
|
"editKey": "Edit API key",
|
||||||
"appLogs": "Application Logs",
|
"delete": "Delete provider"
|
||||||
"openFolder": "Open Folder",
|
|
||||||
"autoStart": "Auto-start Gateway",
|
|
||||||
"autoStartDesc": "Start Gateway when ClawX launches"
|
|
||||||
},
|
},
|
||||||
"updates": {
|
"toast": {
|
||||||
"title": "Updates",
|
"added": "Provider added successfully",
|
||||||
"description": "Keep ClawX up to date",
|
"failedAdd": "Failed to add provider",
|
||||||
"autoCheck": "Auto-check for updates",
|
"deleted": "Provider deleted",
|
||||||
"autoCheckDesc": "Check for updates on startup",
|
"failedDelete": "Failed to delete provider",
|
||||||
"autoDownload": "Auto-update",
|
"defaultUpdated": "Default provider updated",
|
||||||
"autoDownloadDesc": "Automatically download and install updates",
|
"failedDefault": "Failed to set default",
|
||||||
"status": {
|
"updated": "Provider updated",
|
||||||
"checking": "Checking for updates...",
|
"failedUpdate": "Failed to update provider",
|
||||||
"downloading": "Downloading update...",
|
"invalidKey": "Invalid API key",
|
||||||
"available": "Update available: v{{version}}",
|
"modelRequired": "Model ID is required"
|
||||||
"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": {
|
"oauth": {
|
||||||
"title": "Advanced",
|
"loginMode": "OAuth Login",
|
||||||
"description": "Power-user options",
|
"apikeyMode": "API Key",
|
||||||
"devMode": "Developer Mode",
|
"loginPrompt": "This provider requires signing in via your browser.",
|
||||||
"devModeDesc": "Show developer tools and shortcuts"
|
"loginButton": "Login with Browser",
|
||||||
},
|
"waiting": "Waiting...",
|
||||||
"developer": {
|
"openLoginPage": "Open Login Page",
|
||||||
"title": "Developer",
|
"waitingApproval": "Waiting for approval in browser...",
|
||||||
"description": "Advanced options for developers",
|
"cancel": "Cancel",
|
||||||
"console": "OpenClaw Console",
|
"codeCopied": "Code copied to clipboard",
|
||||||
"consoleDesc": "Access the native OpenClaw management interface",
|
"authFailed": "Authentication Failed",
|
||||||
"openConsole": "Open Developer Console",
|
"tryAgain": "Try Again",
|
||||||
"consoleNote": "Opens the Control UI with gateway token injected",
|
"approveLogin": "Approve Login",
|
||||||
"gatewayToken": "Gateway Token",
|
"step1": "Copy the authorization code below.",
|
||||||
"gatewayTokenDesc": "Paste this into Control UI settings if prompted",
|
"step2": "Open the login page in your browser.",
|
||||||
"tokenUnavailable": "Token unavailable",
|
"step3": "Paste the code to approve access.",
|
||||||
"tokenCopied": "Gateway token copied",
|
"requestingCode": "Requesting secure login code..."
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,138 +1,156 @@
|
|||||||
{
|
{
|
||||||
"title": "設定",
|
"title": "設定",
|
||||||
"subtitle": "ClawX の体験をカスタマイズ",
|
"subtitle": "ClawX の体験をカスタマイズ",
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "外観",
|
"title": "外観",
|
||||||
"description": "外観とスタイルをカスタマイズ",
|
"description": "外観とスタイルをカスタマイズ",
|
||||||
"theme": "テーマ",
|
"theme": "テーマ",
|
||||||
"light": "ライト",
|
"light": "ライト",
|
||||||
"dark": "ダーク",
|
"dark": "ダーク",
|
||||||
"system": "システム",
|
"system": "システム",
|
||||||
"language": "言語"
|
"language": "言語"
|
||||||
|
},
|
||||||
|
"aiProviders": {
|
||||||
|
"title": "AI プロバイダー",
|
||||||
|
"description": "AI モデルプロバイダーと API キーを設定",
|
||||||
|
"add": "プロバイダーを追加",
|
||||||
|
"custom": "カスタム",
|
||||||
|
"notRequired": "不要",
|
||||||
|
"empty": {
|
||||||
|
"title": "プロバイダーが構成されていません",
|
||||||
|
"desc": "ClawX の使用を開始するには AI プロバイダーを追加してください",
|
||||||
|
"cta": "最初のプロバイダーを追加"
|
||||||
},
|
},
|
||||||
"aiProviders": {
|
"dialog": {
|
||||||
"title": "AI プロバイダー",
|
"title": "AI プロバイダーを追加",
|
||||||
"description": "AI モデルプロバイダーと API キーを設定",
|
"desc": "新しい AI モデルプロバイダーを構成",
|
||||||
"add": "プロバイダーを追加",
|
"displayName": "表示名",
|
||||||
"custom": "カスタム",
|
"apiKey": "API キー",
|
||||||
"notRequired": "不要",
|
"apiKeyStored": "API キーはローカルマシンに保存されます。",
|
||||||
"empty": {
|
"baseUrl": "ベース URL",
|
||||||
"title": "プロバイダーが構成されていません",
|
"modelId": "モデル ID",
|
||||||
"desc": "ClawX の使用を開始するには AI プロバイダーを追加してください",
|
"cancel": "キャンセル",
|
||||||
"cta": "最初のプロバイダーを追加"
|
"change": "プロバイダーを変更",
|
||||||
},
|
"add": "プロバイダーを追加",
|
||||||
"dialog": {
|
"save": "保存",
|
||||||
"title": "AI プロバイダーを追加",
|
"validate": "検証"
|
||||||
"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 が必要です"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"gateway": {
|
"card": {
|
||||||
"title": "ゲートウェイ",
|
"default": "デフォルト",
|
||||||
"description": "OpenClaw ゲートウェイ設定",
|
"configured": "構成済み",
|
||||||
"status": "ステータス",
|
"noKey": "API キー未設定",
|
||||||
"port": "ポート",
|
"setDefault": "デフォルトに設定",
|
||||||
"logs": "ログ",
|
"editKey": "API キーを編集",
|
||||||
"appLogs": "アプリケーションログ",
|
"delete": "プロバイダーを削除"
|
||||||
"openFolder": "フォルダーを開く",
|
|
||||||
"autoStart": "ゲートウェイ自動起動",
|
|
||||||
"autoStartDesc": "ClawX 起動時にゲートウェイを自動起動"
|
|
||||||
},
|
},
|
||||||
"updates": {
|
"toast": {
|
||||||
"title": "アップデート",
|
"added": "プロバイダーが正常に追加されました",
|
||||||
"description": "ClawX を最新に保つ",
|
"failedAdd": "プロバイダーの追加に失敗しました",
|
||||||
"autoCheck": "自動更新チェック",
|
"deleted": "プロバイダーが削除されました",
|
||||||
"autoCheckDesc": "起動時に更新を確認",
|
"failedDelete": "プロバイダーの削除に失敗しました",
|
||||||
"autoDownload": "自動アップデート",
|
"defaultUpdated": "デフォルトプロバイダーが更新されました",
|
||||||
"autoDownloadDesc": "更新を自動的にダウンロードしてインストール",
|
"failedDefault": "デフォルトの設定に失敗しました",
|
||||||
"status": {
|
"updated": "プロバイダーが更新されました",
|
||||||
"checking": "更新を確認中...",
|
"failedUpdate": "プロバイダーの更新に失敗しました",
|
||||||
"downloading": "更新をダウンロード中...",
|
"invalidKey": "無効な API キー",
|
||||||
"available": "更新あり: v{{version}}",
|
"modelRequired": "モデル ID が必要です"
|
||||||
"downloaded": "インストール準備完了: v{{version}}",
|
|
||||||
"autoInstalling": "{{seconds}} 秒後に再起動して更新をインストールします...",
|
|
||||||
"failed": "更新の確認に失敗しました",
|
|
||||||
"latest": "最新バージョンです",
|
|
||||||
"check": "更新を確認して最新の機能を入手"
|
|
||||||
},
|
|
||||||
"action": {
|
|
||||||
"checking": "確認中...",
|
|
||||||
"downloading": "ダウンロード中...",
|
|
||||||
"download": "更新をダウンロード",
|
|
||||||
"install": "インストールして再起動",
|
|
||||||
"cancelAutoInstall": "キャンセル",
|
|
||||||
"retry": "再試行",
|
|
||||||
"check": "更新を確認"
|
|
||||||
},
|
|
||||||
"currentVersion": "現在のバージョン",
|
|
||||||
"whatsNew": "更新内容:",
|
|
||||||
"errorDetails": "エラー詳細:",
|
|
||||||
"help": "自動アップデートが有効な場合、更新は自動的にダウンロードされインストールされます。"
|
|
||||||
},
|
},
|
||||||
"advanced": {
|
"oauth": {
|
||||||
"title": "詳細設定",
|
"loginMode": "OAuthログイン",
|
||||||
"description": "上級ユーザー向けオプション",
|
"apikeyMode": "APIキー",
|
||||||
"devMode": "開発者モード",
|
"loginPrompt": "このプロバイダーはブラウザからのサインインが必要です。",
|
||||||
"devModeDesc": "開発者ツールとショートカットを表示"
|
"loginButton": "ブラウザでログイン",
|
||||||
},
|
"waiting": "待機中...",
|
||||||
"developer": {
|
"openLoginPage": "ログインページを開く",
|
||||||
"title": "開発者",
|
"waitingApproval": "ブラウザの承認を待っています...",
|
||||||
"description": "開発者向け詳細オプション",
|
"cancel": "キャンセル",
|
||||||
"console": "OpenClaw コンソール",
|
"codeCopied": "コードをクリップボードにコピーしました",
|
||||||
"consoleDesc": "ネイティブ OpenClaw 管理インターフェースにアクセス",
|
"authFailed": "認証に失敗しました",
|
||||||
"openConsole": "開発者コンソールを開く",
|
"tryAgain": "再試行",
|
||||||
"consoleNote": "ゲートウェイトークンを注入して Control UI を開きます",
|
"approveLogin": "ログインを承認",
|
||||||
"gatewayToken": "ゲートウェイトークン",
|
"step1": "以下の認証コードをコピーしてください。",
|
||||||
"gatewayTokenDesc": "Control UI の設定に求められた場合、これを貼り付けてください",
|
"step2": "ブラウザでログインページを開いてください。",
|
||||||
"tokenUnavailable": "トークンが利用できません",
|
"step3": "コードを貼り付けてアクセスを承認してください。",
|
||||||
"tokenCopied": "ゲートウェイトークンをコピーしました",
|
"requestingCode": "セキュアログインコードを取得中..."
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,138 +1,156 @@
|
|||||||
{
|
{
|
||||||
"title": "设置",
|
"title": "设置",
|
||||||
"subtitle": "配置您的 ClawX 体验",
|
"subtitle": "配置您的 ClawX 体验",
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "外观",
|
"title": "外观",
|
||||||
"description": "自定义外观和风格",
|
"description": "自定义外观和风格",
|
||||||
"theme": "主题",
|
"theme": "主题",
|
||||||
"light": "浅色",
|
"light": "浅色",
|
||||||
"dark": "深色",
|
"dark": "深色",
|
||||||
"system": "跟随系统",
|
"system": "跟随系统",
|
||||||
"language": "语言"
|
"language": "语言"
|
||||||
|
},
|
||||||
|
"aiProviders": {
|
||||||
|
"title": "AI 模型提供商",
|
||||||
|
"description": "配置 AI 模型提供商和 API 密钥",
|
||||||
|
"add": "添加提供商",
|
||||||
|
"custom": "自定义",
|
||||||
|
"notRequired": "非必填",
|
||||||
|
"empty": {
|
||||||
|
"title": "未配置提供商",
|
||||||
|
"desc": "添加 AI 提供商以开始使用 ClawX",
|
||||||
|
"cta": "添加您的第一个提供商"
|
||||||
},
|
},
|
||||||
"aiProviders": {
|
"dialog": {
|
||||||
"title": "AI 模型提供商",
|
"title": "添加 AI 提供商",
|
||||||
"description": "配置 AI 模型提供商和 API 密钥",
|
"desc": "配置新的 AI 模型提供商",
|
||||||
"add": "添加提供商",
|
"displayName": "显示名称",
|
||||||
"custom": "自定义",
|
"apiKey": "API 密钥",
|
||||||
"notRequired": "非必填",
|
"apiKeyStored": "您的 API 密钥存储在本地机器上。",
|
||||||
"empty": {
|
"baseUrl": "基础 URL",
|
||||||
"title": "未配置提供商",
|
"modelId": "模型 ID",
|
||||||
"desc": "添加 AI 提供商以开始使用 ClawX",
|
"cancel": "取消",
|
||||||
"cta": "添加您的第一个提供商"
|
"change": "更换提供商",
|
||||||
},
|
"add": "添加提供商",
|
||||||
"dialog": {
|
"save": "保存",
|
||||||
"title": "添加 AI 提供商",
|
"validate": "验证"
|
||||||
"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"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"gateway": {
|
"card": {
|
||||||
"title": "网关",
|
"default": "默认",
|
||||||
"description": "OpenClaw 网关设置",
|
"configured": "已配置",
|
||||||
"status": "状态",
|
"noKey": "未设置 API 密钥",
|
||||||
"port": "端口",
|
"setDefault": "设为默认",
|
||||||
"logs": "日志",
|
"editKey": "编辑 API 密钥",
|
||||||
"appLogs": "应用日志",
|
"delete": "删除提供商"
|
||||||
"openFolder": "打开文件夹",
|
|
||||||
"autoStart": "自动启动网关",
|
|
||||||
"autoStartDesc": "ClawX 启动时自动启动网关"
|
|
||||||
},
|
},
|
||||||
"updates": {
|
"toast": {
|
||||||
"title": "更新",
|
"added": "提供商添加成功",
|
||||||
"description": "保持 ClawX 最新",
|
"failedAdd": "添加提供商失败",
|
||||||
"autoCheck": "自动检查更新",
|
"deleted": "提供商已删除",
|
||||||
"autoCheckDesc": "启动时检查更新",
|
"failedDelete": "删除提供商失败",
|
||||||
"autoDownload": "自动更新",
|
"defaultUpdated": "默认提供商已更新",
|
||||||
"autoDownloadDesc": "自动下载并安装更新",
|
"failedDefault": "设置默认失败",
|
||||||
"status": {
|
"updated": "提供商已更新",
|
||||||
"checking": "正在检查更新...",
|
"failedUpdate": "更新提供商失败",
|
||||||
"downloading": "正在下载更新...",
|
"invalidKey": "无效的 API 密钥",
|
||||||
"available": "可用更新:v{{version}}",
|
"modelRequired": "需要模型 ID"
|
||||||
"downloaded": "准备安装:v{{version}}",
|
|
||||||
"autoInstalling": "将在 {{seconds}} 秒后重启并安装更新...",
|
|
||||||
"failed": "检查更新失败",
|
|
||||||
"latest": "您已拥有最新版本",
|
|
||||||
"check": "检查更新以获取最新功能"
|
|
||||||
},
|
|
||||||
"action": {
|
|
||||||
"checking": "检查中...",
|
|
||||||
"downloading": "下载中...",
|
|
||||||
"download": "下载更新",
|
|
||||||
"install": "安装并重启",
|
|
||||||
"cancelAutoInstall": "取消",
|
|
||||||
"retry": "重试",
|
|
||||||
"check": "检查更新"
|
|
||||||
},
|
|
||||||
"currentVersion": "当前版本",
|
|
||||||
"whatsNew": "更新内容:",
|
|
||||||
"errorDetails": "错误详情:",
|
|
||||||
"help": "开启自动更新后,更新将自动下载并安装。"
|
|
||||||
},
|
},
|
||||||
"advanced": {
|
"oauth": {
|
||||||
"title": "高级",
|
"loginMode": "OAuth 登录",
|
||||||
"description": "高级选项",
|
"apikeyMode": "API 密钥",
|
||||||
"devMode": "开发者模式",
|
"loginPrompt": "此提供商需要通过浏览器登录授权。",
|
||||||
"devModeDesc": "显示开发者工具和快捷方式"
|
"loginButton": "浏览器登录",
|
||||||
},
|
"waiting": "等待中...",
|
||||||
"developer": {
|
"openLoginPage": "打开登录页面",
|
||||||
"title": "开发者",
|
"waitingApproval": "等待浏览器中的授权...",
|
||||||
"description": "开发者高级选项",
|
"cancel": "取消",
|
||||||
"console": "OpenClaw 控制台",
|
"codeCopied": "代码已复制到剪贴板",
|
||||||
"consoleDesc": "访问原生 OpenClaw 管理界面",
|
"authFailed": "认证失败",
|
||||||
"openConsole": "打开开发者控制台",
|
"tryAgain": "重试",
|
||||||
"consoleNote": "使用注入的网关令牌打开控制台",
|
"approveLogin": "确认登录",
|
||||||
"gatewayToken": "网关令牌",
|
"step1": "复制下方的授权码。",
|
||||||
"gatewayTokenDesc": "如果需要,将此粘贴到控制台设置中",
|
"step2": "在浏览器中打开登录页面。",
|
||||||
"tokenUnavailable": "令牌不可用",
|
"step3": "粘贴授权码以批准访问。",
|
||||||
"tokenCopied": "网关令牌已复制",
|
"requestingCode": "正在获取安全登录码..."
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,8 @@ export const PROVIDER_TYPES = [
|
|||||||
'openrouter',
|
'openrouter',
|
||||||
'moonshot',
|
'moonshot',
|
||||||
'siliconflow',
|
'siliconflow',
|
||||||
|
'minimax-portal',
|
||||||
|
'qwen-portal',
|
||||||
'ollama',
|
'ollama',
|
||||||
'custom',
|
'custom',
|
||||||
] as const;
|
] as const;
|
||||||
@@ -51,6 +53,10 @@ export interface ProviderTypeInfo {
|
|||||||
modelIdPlaceholder?: string;
|
modelIdPlaceholder?: string;
|
||||||
/** Default model ID to pre-fill */
|
/** Default model ID to pre-fill */
|
||||||
defaultModelId?: string;
|
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';
|
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: '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: '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: '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: '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' },
|
{ id: 'custom', name: 'Custom', icon: '⚙️', placeholder: 'API key...', requiresApiKey: true, showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'your-provider/model-id' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
Copy,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { TitleBar } from '@/components/layout/TitleBar';
|
import { TitleBar } from '@/components/layout/TitleBar';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -715,6 +716,84 @@ function ProviderContent({
|
|||||||
const [providerMenuOpen, setProviderMenuOpen] = useState(false);
|
const [providerMenuOpen, setProviderMenuOpen] = useState(false);
|
||||||
const providerMenuRef = useRef<HTMLDivElement | null>(null);
|
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
|
// On mount, try to restore previously configured provider
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -819,6 +898,9 @@ function ProviderContent({
|
|||||||
const showBaseUrlField = selectedProviderData?.showBaseUrl ?? false;
|
const showBaseUrlField = selectedProviderData?.showBaseUrl ?? false;
|
||||||
const showModelIdField = selectedProviderData?.showModelId ?? false;
|
const showModelIdField = selectedProviderData?.showModelId ?? false;
|
||||||
const requiresKey = selectedProviderData?.requiresApiKey ?? 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 () => {
|
const handleValidateAndSave = async () => {
|
||||||
if (!selectedProvider) return;
|
if (!selectedProvider) return;
|
||||||
@@ -904,7 +986,8 @@ function ProviderContent({
|
|||||||
const canSubmit =
|
const canSubmit =
|
||||||
selectedProvider
|
selectedProvider
|
||||||
&& (requiresKey ? apiKey.length > 0 : true)
|
&& (requiresKey ? apiKey.length > 0 : true)
|
||||||
&& (showModelIdField ? modelId.trim().length > 0 : true);
|
&& (showModelIdField ? modelId.trim().length > 0 : true)
|
||||||
|
&& !useOAuthFlow;
|
||||||
|
|
||||||
const handleSelectProvider = (providerId: string) => {
|
const handleSelectProvider = (providerId: string) => {
|
||||||
onSelectProvider(providerId);
|
onSelectProvider(providerId);
|
||||||
@@ -913,6 +996,7 @@ function ProviderContent({
|
|||||||
onApiKeyChange('');
|
onApiKeyChange('');
|
||||||
setKeyValid(null);
|
setKeyValid(null);
|
||||||
setProviderMenuOpen(false);
|
setProviderMenuOpen(false);
|
||||||
|
setAuthMode('oauth');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1047,8 +1131,32 @@ function ProviderContent({
|
|||||||
</div>
|
</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) */}
|
{/* API Key field (hidden for ollama) */}
|
||||||
{requiresKey && (
|
{(!isOAuth || (supportsApiKey && authMode === 'apikey')) && requiresKey && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="apiKey">{t('provider.apiKey')}</Label>
|
<Label htmlFor="apiKey">{t('provider.apiKey')}</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -1076,11 +1184,104 @@ function ProviderContent({
|
|||||||
</div>
|
</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 */}
|
{/* Validate & Save */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleValidateAndSave}
|
onClick={handleValidateAndSave}
|
||||||
disabled={!canSubmit || validating}
|
disabled={!canSubmit || validating}
|
||||||
className="w-full"
|
className={cn("w-full", useOAuthFlow && "hidden")}
|
||||||
>
|
>
|
||||||
{validating ? (
|
{validating ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
|||||||
Reference in New Issue
Block a user