diff --git a/src/components/ConfigExporter.jsx b/src/components/ConfigExporter.jsx new file mode 100644 index 0000000..9661635 --- /dev/null +++ b/src/components/ConfigExporter.jsx @@ -0,0 +1,678 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import toast from 'react-hot-toast'; +import { getTokenSupportedModels } from '../api'; +import { useSite } from '../context/SiteContext'; + +const TOOLS = [ + { id: 'claudecode', name: 'Claude Code', path: '~/.claude/settings.json' }, + { id: 'ccswitch', name: 'CC Switch', path: 'ccswitch://v1/import' }, + { id: 'openclaw', name: 'OpenClaw', path: '~/.openclaw/openclaw.json' }, + { id: 'opencode', name: 'OpenCode', path: '~/.config/opencode/opencode.json' }, + { id: 'cursor', name: 'Cursor', path: 'Settings -> Models -> OpenAI API Key' }, + { id: 'curl', name: 'cURL', path: 'Terminal' }, + { id: 'python', name: 'Python SDK', path: 'main.py' }, + { id: 'anthropic', name: 'Anthropic SDK', path: 'main.py' }, +]; + +const CCSWITCH_APPS = [ + { id: 'codex', name: 'Codex', endpointType: 'openai' }, + { id: 'claude', name: 'Claude Code', endpointType: 'anthropic' }, + { id: 'opencode', name: 'OpenCode', endpointType: 'openai' }, + { id: 'openclaw', name: 'OpenClaw', endpointType: 'openclaw' }, +]; + +const CCSWITCH_RELEASE_URL = + 'https://github.com/farion1231/cc-switch/releases/latest'; +const CCSWITCH_REPO_URL = 'https://github.com/farion1231/cc-switch'; + +const ConfigExporter = ({ tokens = [] }) => { + const { t } = useTranslation(); + const { site } = useSite(); + const [selectedTokenId, setSelectedTokenId] = useState(null); + const [availableModels, setAvailableModels] = useState([]); + const [selectedModel, setSelectedModel] = useState(''); + const [selectedTool, setSelectedTool] = useState('claudecode'); + const [selectedCCSwitchApp, setSelectedCCSwitchApp] = useState('codex'); + const [loadingModels, setLoadingModels] = useState(false); + const [modelsError, setModelsError] = useState(false); + const [copied, setCopied] = useState(false); + const [launchingCCSwitch, setLaunchingCCSwitch] = useState(false); + const [showCCSwitchDownload, setShowCCSwitchDownload] = useState(false); + + const serverAddress = window.location.origin; + + const selectedToken = useMemo( + () => tokens.find((token) => token.id === selectedTokenId) || null, + [tokens, selectedTokenId], + ); + + const selectedToolMeta = useMemo( + () => TOOLS.find((tool) => tool.id === selectedTool) || TOOLS[0], + [selectedTool], + ); + + useEffect(() => { + if (tokens.length === 0) { + setSelectedTokenId(null); + setAvailableModels([]); + setSelectedModel(''); + return; + } + + const stillExists = tokens.some((token) => token.id === selectedTokenId); + if (stillExists) return; + + const preferred = tokens.find((token) => token.status === 1) || tokens[0]; + setSelectedTokenId(preferred.id); + }, [tokens, selectedTokenId]); + + useEffect(() => { + if (!selectedToken?.id) { + setAvailableModels([]); + setSelectedModel(''); + return; + } + + let cancelled = false; + const loadModels = async () => { + setLoadingModels(true); + setModelsError(false); + try { + const res = await getTokenSupportedModels(selectedToken.id); + if (cancelled) return; + + if (res.data.success) { + const models = res.data.data?.models || []; + setAvailableModels(models); + setSelectedModel((prev) => + prev && models.includes(prev) ? prev : models[0] || '', + ); + } else { + setAvailableModels([]); + setSelectedModel(''); + setModelsError(true); + } + } catch (e) { + if (cancelled) return; + setAvailableModels([]); + setSelectedModel(''); + setModelsError(true); + } + if (!cancelled) { + setLoadingModels(false); + } + }; + + loadModels(); + return () => { + cancelled = true; + }; + }, [selectedToken?.id]); + + const getProviderInfo = (modelName) => { + const lower = modelName.toLowerCase(); + if (lower.includes('claude')) { + return { provider: 'anthropic', api: 'anthropic-messages' }; + } + if ( + lower.includes('gpt') || + lower.includes('o1') || + lower.includes('o3') || + lower.includes('o4') + ) { + return { provider: 'openai', api: 'openai-chat' }; + } + if (lower.includes('gemini')) { + return { provider: 'google', api: 'google-chat' }; + } + return { provider: 'openai', api: 'openai-chat' }; + }; + + const getCCSwitchEndpoint = () => { + const app = CCSWITCH_APPS.find( + (item) => item.id === selectedCCSwitchApp, + ); + if (app?.endpointType === 'anthropic' || app?.endpointType === 'openclaw') { + return `${serverAddress}/`; + } + return `${serverAddress}/v1`; + }; + + const generateCCSwitchLink = () => { + if (!selectedToken || !selectedModel) return ''; + const params = new URLSearchParams({ + resource: 'provider', + app: selectedCCSwitchApp, + name: site?.name || window.location.hostname, + homepage: serverAddress, + endpoint: getCCSwitchEndpoint(), + apiKey: `sk-${selectedToken.key}`, + model: selectedModel, + enabled: 'true', + notes: `${site?.name || window.location.hostname} ยท ${selectedModel}`, + }); + return `ccswitch://v1/import?${params.toString()}`; + }; + + const generateConfig = () => { + if (!selectedToken || !selectedModel) return ''; + + const apiKey = `sk-${selectedToken.key}`; + + switch (selectedTool) { + case 'claudecode': + return `{ + "env": { + "ANTHROPIC_API_KEY": "${apiKey}", + "ANTHROPIC_BASE_URL": "${serverAddress}/", + "ANTHROPIC_MODEL": "${selectedModel}" + } +}`; + case 'ccswitch': + return generateCCSwitchLink(); + case 'openclaw': { + const info = getProviderInfo(selectedModel); + return `{ + "provider": "${info.provider}", + "base_url": "${serverAddress}/", + "api": "${info.api}", + "api_key": "${apiKey}", + "model": { + "id": "${selectedModel}", + "name": "${selectedModel}" + } +}`; + } + case 'opencode': + return `{ + "provider": { + "openai": { + "options": { + "baseURL": "${serverAddress}/v1", + "apiKey": "${apiKey}" + }, + "models": { + "${selectedModel}": { + "name": "${selectedModel}", + "options": { + "store": false + } + } + } + } + }, + "$schema": "https://opencode.ai/config.json" +}`; + case 'cursor': + return `API Key: ${apiKey} +Base URL: ${serverAddress}/v1 +Model: ${selectedModel}`; + case 'curl': + return `curl ${serverAddress}/v1/chat/completions \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer ${apiKey}" \\ + -d '{ + "model": "${selectedModel}", + "messages": [ + {"role": "user", "content": "Hello!"} + ] + }'`; + case 'python': + return `from openai import OpenAI + +client = OpenAI( + api_key="${apiKey}", + base_url="${serverAddress}/v1" +) + +response = client.chat.completions.create( + model="${selectedModel}", + messages=[ + {"role": "user", "content": "Hello!"} + ] +) + +print(response.choices[0].message.content)`; + case 'anthropic': + return `import anthropic + +client = anthropic.Anthropic( + api_key="${apiKey}", + base_url="${serverAddress}/" +) + +message = client.messages.create( + model="${selectedModel}", + max_tokens=1024, + messages=[ + {"role": "user", "content": "Hello!"} + ] +) + +print(message.content[0].text)`; + default: + return ''; + } + }; + + const getFilename = () => { + switch (selectedTool) { + case 'curl': + return 'api-call.sh'; + case 'python': + case 'anthropic': + return 'main.py'; + case 'cursor': + return 'cursor-config.txt'; + default: + return ( + selectedToolMeta.path.split('/').pop() || `${selectedToolMeta.id}.json` + ); + } + }; + + const copyToClipboard = async (text) => { + try { + await navigator.clipboard.writeText(text); + } catch (e) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } + }; + + const handleCopy = async () => { + const config = generateConfig(); + if (!config) return; + await copyToClipboard(config); + setCopied(true); + toast.success( + t(selectedTool === 'ccswitch' ? 'config.importLinkCopied' : 'config.copied'), + ); + window.setTimeout(() => setCopied(false), 2000); + }; + + const handleDownload = () => { + const config = generateConfig(); + if (!config) return; + + const blob = new Blob([config], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = getFilename(); + a.click(); + URL.revokeObjectURL(url); + toast.success(t('config.downloaded')); + }; + + const handleImportCCSwitch = () => { + const deeplink = generateCCSwitchLink(); + if (!deeplink) return; + + setShowCCSwitchDownload(false); + setLaunchingCCSwitch(true); + + let dismissed = false; + let timerId = null; + + const cleanup = () => { + window.removeEventListener('blur', handleBlur); + document.removeEventListener('visibilitychange', handleVisibilityChange); + if (timerId) { + window.clearTimeout(timerId); + } + }; + + const handleSuccess = () => { + if (dismissed) return; + dismissed = true; + cleanup(); + setLaunchingCCSwitch(false); + }; + + const handleBlur = () => { + handleSuccess(); + }; + + const handleVisibilityChange = () => { + if (document.hidden) { + handleSuccess(); + } + }; + + window.addEventListener('blur', handleBlur); + document.addEventListener('visibilitychange', handleVisibilityChange); + + timerId = window.setTimeout(() => { + if (dismissed) return; + cleanup(); + setLaunchingCCSwitch(false); + setShowCCSwitchDownload(true); + }, 1500); + + window.location.href = deeplink; + }; + + const config = generateConfig(); + + if (tokens.length === 0) { + return ( +
+ + + +

+ {t('config.noKeyPrompt')} +

+

{t('config.noKeyDesc')}

+
+ ); + } + + return ( + <> +
+
+

+ + + + {t('config.title')} +

+

{t('config.subtitle')}

+
+ +
+
+ + +
+ +
+ + +
+ +
+ +
+ {TOOLS.map((tool) => ( + + ))} +
+
+ + {selectedTool === 'ccswitch' && ( +
+
+
+

+ {t('config.selectCCSwitchApp')} +

+

+ {t('config.ccswitchHint')} +

+
+
+ {CCSWITCH_APPS.map((app) => ( + + ))} +
+
+
+ )} +
+ +
+
+
+
+ + + +
+ + {selectedToolMeta.path} + +
+
+ + {selectedTool === 'ccswitch' ? ( + + ) : ( + + )} +
+
+
+            {config || t('tokens.noSupportedModels')}
+          
+
+
+ + {showCCSwitchDownload && ( +
setShowCCSwitchDownload(false)} + > +
e.stopPropagation()} + > +

+ {t('config.ccswitchNotInstalledTitle')} +

+

+ {t('config.ccswitchNotInstalledDesc')} +

+
+ + {t('config.downloadCCSwitch')} + + + {t('config.openCCSwitchRepo')} + +
+
+ +
+
+
+ )} + + ); +}; + +export default ConfigExporter; diff --git a/src/pages/Tokens.jsx b/src/pages/Tokens.jsx index 82ae245..4ab6d3c 100644 --- a/src/pages/Tokens.jsx +++ b/src/pages/Tokens.jsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { getTokens, createToken, updateToken, deleteToken, getSiteKeyGroups, getTokenSupportedModels } from '../api'; +import ConfigExporter from '../components/ConfigExporter'; import toast from 'react-hot-toast'; export default function Tokens() { @@ -448,6 +449,10 @@ export default function Tokens() { )} + +
+ +
); }