| import { useState, useEffect, useCallback } from 'react'; |
| import { useTranslation } from 'react-i18next'; |
| import { |
| Terminal, |
| CheckCircle2, |
| AlertCircle, |
| RefreshCw, |
| Cpu, |
| Globe, |
| CodeXml, |
| Loader2, |
| Eye, |
| RotateCcw, |
| Copy, |
| X, |
| Bot, |
| Trash2 |
| } from 'lucide-react'; |
| import { copyToClipboard } from '../../utils/clipboard'; |
| import { request as invoke } from '../../utils/request'; |
| import { showToast } from '../common/ToastContainer'; |
| import ModalDialog from '../common/ModalDialog'; |
| import { cn } from '../../utils/cn'; |
| import { DroidSyncModal } from './DroidSyncModal'; |
| import { OpenCodeSyncModal } from './OpenCodeSyncModal'; |
| import { useProxyModels } from '../../hooks/useProxyModels'; |
| import GroupedSelect from '../common/GroupedSelect'; |
|
|
| interface CliSyncCardProps { |
| proxyUrl: string; |
| apiKey: string; |
| className?: string; |
| } |
|
|
| type CliAppType = 'Claude' | 'Codex' | 'Gemini' | 'OpenCode' | 'Droid'; |
|
|
| interface CliStatus { |
| installed: boolean; |
| version: string | null; |
| is_synced: boolean; |
| has_backup: boolean; |
| current_base_url: string | null; |
| files: string[]; |
| synced_count?: number; |
| } |
|
|
| export const CliSyncCard = ({ proxyUrl, apiKey, className }: CliSyncCardProps) => { |
| const { t } = useTranslation(); |
| const [statuses, setStatuses] = useState<Record<CliAppType, CliStatus | null>>({ |
| Claude: null, |
| Codex: null, |
| Gemini: null, |
| OpenCode: null, |
| Droid: null |
| }); |
| const [loading, setLoading] = useState<Record<CliAppType, boolean>>({ |
| Claude: false, |
| Codex: false, |
| Gemini: false, |
| OpenCode: false, |
| Droid: false |
| }); |
| const [syncing, setSyncing] = useState<Record<CliAppType, boolean>>({ |
| Claude: false, |
| Codex: false, |
| Gemini: false, |
| OpenCode: false, |
| Droid: false |
| }); |
| const [syncAccounts, setSyncAccounts] = useState(false); |
| const [droidSyncModal, setDroidSyncModal] = useState(false); |
| const [selectedModels, setSelectedModels] = useState<Record<CliAppType, string>>({ |
| Claude: 'claude-3-5-sonnet-latest', |
| Codex: 'gpt-4o', |
| Gemini: 'gemini-1.5-pro', |
| OpenCode: '', |
| Droid: '' |
| }); |
| const [viewingConfig, setViewingConfig] = useState<{ |
| app: CliAppType, |
| content: string, |
| fileName: string, |
| allFiles: string[] |
| } | null>(null); |
| const [restoreConfirmApp, setRestoreConfirmApp] = useState<CliAppType | null>(null); |
| const [syncConfirmApp, setSyncConfirmApp] = useState<CliAppType | null>(null); |
| const [openCodeSyncModal, setOpenCodeSyncModal] = useState(false); |
| const [clearConfirmApp, setClearConfirmApp] = useState<CliAppType | null>(null); |
|
|
| const { models: proxyModels } = useProxyModels(); |
|
|
| const modelOptions = proxyModels.map(m => ({ |
| value: m.id, |
| label: m.name, |
| group: m.group || 'General' |
| })); |
|
|
| |
| const getFormattedProxyUrl = useCallback((app: CliAppType) => { |
| if (!proxyUrl) return ''; |
| const base = proxyUrl.trimEnd().replace(/\/+$/, ''); |
| |
| if (app === 'Codex' || app === 'OpenCode') { |
| return base.endsWith('/v1') ? base : `${base}/v1`; |
| } |
| |
| return base.replace(/\/v1$/, ''); |
| }, [proxyUrl]); |
|
|
| const checkStatus = useCallback(async (app: CliAppType) => { |
| setLoading(prev => ({ ...prev, [app]: true })); |
| try { |
| const formattedUrl = getFormattedProxyUrl(app); |
| let command: string; |
| let params: Record<string, unknown>; |
| if (app === 'Droid') { |
| command = 'get_droid_sync_status'; |
| params = { proxyUrl: formattedUrl }; |
| } else if (app === 'OpenCode') { |
| command = 'get_opencode_sync_status'; |
| params = { proxyUrl: formattedUrl }; |
| } else { |
| command = 'get_cli_sync_status'; |
| params = { appType: app, proxyUrl: formattedUrl }; |
| } |
|
|
| const status = await invoke<CliStatus>(command, params); |
| setStatuses(prev => ({ ...prev, [app]: status })); |
| } catch (error) { |
| console.error(`Failed to check ${app} status:`, error); |
| } finally { |
| setLoading(prev => ({ ...prev, [app]: false })); |
| } |
| }, [getFormattedProxyUrl]); |
|
|
| const handleSync = async (app: CliAppType) => { |
| if (app === 'Droid') { |
| setDroidSyncModal(true); |
| return; |
| } |
| if (app === 'OpenCode') { |
| setOpenCodeSyncModal(true); |
| return; |
| } |
| setSyncConfirmApp(app); |
| }; |
|
|
| const executeSync = async () => { |
| const app = syncConfirmApp; |
| if (!app) return; |
| setSyncConfirmApp(null); |
|
|
| if (!proxyUrl || !apiKey) { |
| showToast(t('proxy.cli_sync.toast.config_missing', { defaultValue: '请先生成 API Key 并启动服务' }), 'error'); |
| return; |
| } |
|
|
| try { |
| const formattedUrl = getFormattedProxyUrl(app); |
| const command = app === 'OpenCode' ? 'execute_opencode_sync' : 'execute_cli_sync'; |
| const params = app === 'OpenCode' |
| ? { proxyUrl: formattedUrl, apiKey: apiKey, syncAccounts: syncAccounts } |
| : { appType: app, proxyUrl: formattedUrl, apiKey: apiKey, model: selectedModels[app] }; |
|
|
| await invoke(command, params); |
| showToast(t(app === 'OpenCode' ? 'proxy.opencode_sync.toast.sync_success' : 'proxy.cli_sync.toast.sync_success', { name: app, defaultValue: `${app} synced successfully` }), 'success'); |
| await checkStatus(app); |
| } catch (error: any) { |
| showToast(t(app === 'OpenCode' ? 'proxy.opencode_sync.toast.sync_error' : 'proxy.cli_sync.toast.sync_error', { name: app, error: error.toString(), defaultValue: `Sync failed: ${error.toString()}` }), 'error'); |
| } finally { |
| setSyncing(prev => ({ ...prev, [app]: false })); |
| } |
| }; |
|
|
| const handleRestore = (app: CliAppType) => { |
| setRestoreConfirmApp(app); |
| }; |
|
|
| const executeRestore = async () => { |
| if (!restoreConfirmApp) return; |
| const app = restoreConfirmApp; |
| setRestoreConfirmApp(null); |
|
|
| setSyncing(prev => ({ ...prev, [app]: true })); |
| try { |
| const command = app === 'Droid' ? 'execute_droid_restore' : app === 'OpenCode' ? 'execute_opencode_restore' : 'execute_cli_restore'; |
| const params = (app === 'Droid' || app === 'OpenCode') ? {} : { appType: app }; |
| await invoke(command, params); |
| showToast(t('common.success'), 'success'); |
| await checkStatus(app); |
| } catch (error: any) { |
| showToast(error.toString(), 'error'); |
| } finally { |
| setSyncing(prev => ({ ...prev, [app]: false })); |
| } |
| }; |
|
|
| const handleClear = (app: CliAppType) => { |
| setClearConfirmApp(app); |
| }; |
|
|
| const executeClear = async () => { |
| if (!clearConfirmApp) return; |
| const app = clearConfirmApp; |
| setClearConfirmApp(null); |
|
|
| setSyncing(prev => ({ ...prev, [app]: true })); |
| try { |
| const formattedUrl = getFormattedProxyUrl(app); |
| await invoke('execute_opencode_clear', { proxyUrl: formattedUrl, clearLegacy: true }); |
| showToast(t('proxy.opencode_sync.toast.clear_success', { defaultValue: 'OpenCode cleared successfully' }), 'success'); |
| await checkStatus(app); |
| } catch (error: any) { |
| showToast(t('proxy.opencode_sync.toast.clear_error', { defaultValue: `Clear failed: ${error.toString()}` }), 'error'); |
| } finally { |
| setSyncing(prev => ({ ...prev, [app]: false })); |
| } |
| }; |
|
|
| const handleViewConfig = async (app: CliAppType, fileName?: string) => { |
| try { |
| const status = statuses[app]; |
| if (!status) return; |
|
|
| const targetFile = fileName || status.files[0]; |
| let command: string; |
| let params: Record<string, unknown>; |
| if (app === 'Droid') { |
| command = 'get_droid_config_content'; |
| params = {}; |
| } else if (app === 'OpenCode') { |
| command = 'get_opencode_config_content'; |
| params = { request: { fileName: targetFile } }; |
| } else { |
| command = 'get_cli_config_content'; |
| params = { appType: app, fileName: targetFile }; |
| } |
|
|
| const content = await invoke<string>(command, params); |
| setViewingConfig({ |
| app, |
| content, |
| fileName: targetFile, |
| allFiles: status.files |
| }); |
| } catch (error: any) { |
| showToast(error.toString(), 'error'); |
| } |
| }; |
|
|
| useEffect(() => { |
| checkStatus('Claude'); |
| checkStatus('Codex'); |
| checkStatus('Gemini'); |
| checkStatus('OpenCode'); |
| checkStatus('Droid'); |
| }, [checkStatus]); |
|
|
| const renderCliItem = (app: CliAppType, icon: React.ReactNode, name: string) => { |
| const status = statuses[app]; |
| const isAppLoading = loading[app]; |
| const isAppSyncing = syncing[app]; |
|
|
| return ( |
| <div className="flex flex-col bg-white/50 dark:bg-gray-800/40 rounded-xl border border-gray-100 dark:border-white/5 p-4 shadow-sm hover:shadow-lg hover:border-blue-200/50 dark:hover:border-blue-500/30 transition-all duration-300 group"> |
| <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-y-3 gap-x-2 mb-4"> |
| <div className="flex items-center gap-3 min-w-0"> |
| <div className="p-2.5 bg-gray-50 dark:bg-base-300 rounded-lg shrink-0 group-hover:scale-110 transition-transform duration-300"> |
| {icon} |
| </div> |
| <div className="min-w-0"> |
| <h4 className="text-sm font-bold text-gray-900 dark:text-gray-100 leading-tight truncate"> |
| {t('proxy.cli_sync.card_title', { name })} |
| </h4> |
| <div className="mt-1 flex items-center gap-1.5 overflow-hidden"> |
| {isAppLoading ? ( |
| <div className="flex items-center gap-1 text-[10px] text-gray-400"> |
| <Loader2 size={10} className="animate-spin" /> |
| {t('proxy.cli_sync.status.detecting')} |
| </div> |
| ) : status?.installed ? ( |
| <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-bold whitespace-nowrap"> |
| {t('proxy.cli_sync.status.installed', { version: status.version })} |
| </span> |
| ) : ( |
| <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-400 font-medium whitespace-nowrap"> |
| {t('proxy.cli_sync.status.not_installed')} |
| </span> |
| )} |
| </div> |
| </div> |
| </div> |
| |
| {/* Show Sync Status if installed OR if it's OpenCode (which we now allow configuring even if not installed) */} |
| {!isAppLoading && (status?.installed || app === 'OpenCode' && status?.current_base_url) && ( |
| <div className={cn( |
| "inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold tracking-wide transition-all h-6 shrink-0 whitespace-nowrap shadow-sm", |
| status.is_synced |
| ? "bg-gradient-to-r from-green-500 to-emerald-600 text-white" |
| : "bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-500 border border-amber-200/50 dark:border-amber-800/30" |
| )}> |
| {status.is_synced ? ( |
| <><CheckCircle2 size={12} className="shrink-0" /> {t('proxy.cli_sync.status.synced', { defaultValue: '已同步' })}</> |
| ) : ( |
| <><AlertCircle size={12} className="shrink-0" /> {t('proxy.cli_sync.status.not_synced', { defaultValue: '未同步' })}</> |
| )} |
| </div> |
| )} |
| </div> |
|
|
| <div className="mt-auto space-y-3"> |
| <div className="p-2.5 bg-gray-50/80 dark:bg-gray-900/40 rounded-lg border border-dashed border-gray-200 dark:border-white/10"> |
| <div className="flex justify-between items-start mb-1"> |
| <div className="text-[9px] text-gray-400 dark:text-gray-500 uppercase font-bold tracking-wider">{t('proxy.cli_sync.status.current_base_url')}</div> |
| </div> |
| <div className="text-[10px] font-mono truncate text-gray-500 dark:text-gray-400 italic"> |
| {status?.current_base_url || '---'} |
| </div> |
| </div> |
| |
| {/* Claude, Codex, Gemini 的模型选择 */} |
| {(status?.installed || app === 'OpenCode') && (app === 'Claude' || app === 'Codex' || app === 'Gemini') && ( |
| <div className="space-y-1"> |
| <div className="text-[9px] text-gray-400 dark:text-gray-500 uppercase font-bold tracking-wider px-1"> |
| {t('proxy.cli_sync.model_select', { defaultValue: 'Select Model' })} |
| </div> |
| <GroupedSelect |
| value={selectedModels[app]} |
| onChange={(val) => setSelectedModels(prev => ({ ...prev, [app]: val }))} |
| options={modelOptions} |
| className="w-full !h-8 !text-[11px] !rounded-lg" |
| allowCustomInput={true} |
| /> |
| </div> |
| )} |
| |
| {/* OpenCode 独有的账号同步选项 - Allow even if not installed */} |
| {app === 'OpenCode' && ( |
| <div className="flex items-center gap-2 p-2 bg-gray-50/50 dark:bg-gray-900/20 rounded-lg"> |
| <input |
| type="checkbox" |
| id="opencode-sync-accounts" |
| checked={syncAccounts} |
| onChange={(e) => setSyncAccounts(e.target.checked)} |
| className="checkbox checkbox-xs checkbox-primary" |
| /> |
| <label htmlFor="opencode-sync-accounts" className="text-[10px] text-gray-600 dark:text-gray-400 cursor-pointer select-none"> |
| {t('proxy.opencode_sync.sync_accounts', { defaultValue: 'Sync accounts to antigravity-accounts.json' })} |
| </label> |
| </div> |
| )} |
| |
| <div className="flex items-center gap-2"> |
| {(status?.installed || app === 'OpenCode') && ( |
| <> |
| {/* 对于 OpenCode,如果未同步,则不显示查看按钮(因为文件尚未生成,后端会报错) */} |
| {(app !== 'OpenCode' || status?.is_synced) && ( |
| <button |
| onClick={() => handleViewConfig(app)} |
| className="p-1 text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors" |
| title={t(app === 'OpenCode' ? 'proxy.opencode_sync.btn_view' : 'proxy.cli_sync.btn_view', { defaultValue: 'View Config' })} |
| > |
| <Eye size={14} /> |
| </button> |
| )} |
| <button |
| onClick={() => handleRestore(app)} |
| className="p-1 text-gray-400 hover:text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-900/20 rounded transition-colors" |
| title={t(app === 'OpenCode' ? 'proxy.opencode_sync.btn_restore' : 'proxy.cli_sync.btn_restore', { defaultValue: 'Restore' })} |
| > |
| <RotateCcw size={14} /> |
| </button> |
| {/* OpenCode 独有的 Clear 按钮 */} |
| {app === 'OpenCode' && ( |
| <button |
| onClick={() => handleClear(app)} |
| className="p-1 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors" |
| title={t('proxy.opencode_sync.btn_clear', { defaultValue: 'Clear' })} |
| > |
| <Trash2 size={14} /> |
| </button> |
| )} |
| </> |
| )} |
| <button |
| onClick={() => handleSync(app)} |
| disabled={(app !== 'OpenCode' && !status?.installed) || isAppSyncing || isAppLoading} |
| className={cn( |
| "btn btn-sm flex-1 gap-2 rounded-xl transition-all font-bold shadow-sm", |
| status?.is_synced |
| ? "btn-ghost border-gray-200 dark:border-base-400 text-gray-500 hover:bg-gray-100" |
| : "btn-primary hover:shadow-lg shadow-blue-500/20" |
| )} |
| > |
| {isAppSyncing ? ( |
| <Loader2 size={14} className="animate-spin" /> |
| ) : ( |
| <RefreshCw size={14} className={cn(isAppLoading && "animate-spin-once")} /> |
| )} |
| {t('proxy.cli_sync.btn_sync')} |
| </button> |
| </div> |
| </div> |
| </div> |
| ); |
| }; |
|
|
| return ( |
| <div className={cn("space-y-4", className)}> |
| <div className="px-1 flex items-center justify-between"> |
| <div className="flex items-center gap-2 text-gray-400"> |
| <Terminal size={14} /> |
| <span className="text-[10px] font-bold uppercase tracking-widest"> |
| {t('proxy.cli_sync.title')} |
| </span> |
| </div> |
| <p className="text-[10px] text-gray-400 dark:text-gray-500 italic"> |
| {t('proxy.cli_sync.subtitle')} |
| </p> |
| </div> |
| |
| <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> |
| {renderCliItem('Claude', <CodeXml size={20} className="text-purple-500" />, 'Claude Code')} |
| {renderCliItem('Codex', <Cpu size={20} className="text-blue-500" />, 'Codex AI')} |
| {renderCliItem('Gemini', <Globe size={20} className="text-green-500" />, 'Gemini CLI')} |
| {renderCliItem('OpenCode', <CodeXml size={20} className="text-blue-500" />, 'OpenCode')} |
| {renderCliItem('Droid', <Bot size={20} className="text-orange-500" />, 'Droid')} |
| </div> |
| |
| {/* Config Viewer Modal */} |
| {viewingConfig && ( |
| <div className="fixed inset-0 z-[300] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200"> |
| <div className="bg-white dark:bg-base-100 rounded-2xl shadow-2xl border border-gray-200 dark:border-base-300 w-full max-w-2xl overflow-hidden animate-in zoom-in-95 duration-200"> |
| <div className="px-6 py-4 border-b border-gray-100 dark:border-base-200 flex items-center justify-between bg-gray-50/50 dark:bg-base-200/50"> |
| <div> |
| <h3 className="font-bold text-gray-900 dark:text-base-content flex items-center gap-2"> |
| <CodeXml size={18} className="text-blue-500" /> |
| {t('proxy.cli_sync.modal.view_title', { name: viewingConfig.app })} |
| </h3> |
| <div className="mt-2 flex gap-2"> |
| {viewingConfig.allFiles.map(file => ( |
| <button |
| key={file} |
| onClick={() => handleViewConfig(viewingConfig.app, file)} |
| className={cn( |
| "px-3 py-1 text-[10px] font-bold rounded-lg transition-all border", |
| viewingConfig.fileName === file |
| ? "bg-blue-500 text-white border-blue-500" |
| : "bg-white dark:bg-base-300 text-gray-400 border-gray-100 dark:border-base-400 hover:border-blue-200" |
| )} |
| > |
| {file} |
| </button> |
| ))} |
| </div> |
| </div> |
| <div className="flex items-center gap-2"> |
| <button |
| onClick={async () => { |
| const success = await copyToClipboard(viewingConfig.content); |
| if (success) { |
| showToast(t('proxy.cli_sync.modal.copy_success'), 'success'); |
| } |
| }} |
| className="btn btn-ghost btn-sm hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20" |
| > |
| <Copy size={16} /> |
| </button> |
| <button |
| onClick={() => setViewingConfig(null)} |
| className="btn btn-ghost btn-sm hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20" |
| > |
| <X size={18} /> |
| </button> |
| </div> |
| </div> |
| <div className="p-6"> |
| <div className="bg-gray-900 rounded-xl p-4 overflow-auto max-h-[50vh] border border-gray-800 shadow-inner"> |
| <pre className="text-xs font-mono text-gray-300 leading-relaxed"> |
| {viewingConfig.content} |
| </pre> |
| </div> |
| </div> |
| </div> |
| </div> |
| )} |
| {/* 恢复默认/备份确认弹窗 */} |
| <ModalDialog |
| isOpen={!!restoreConfirmApp} |
| title={statuses[restoreConfirmApp!]?.has_backup |
| ? t('proxy.cli_sync.btn_restore_backup') |
| : t('proxy.cli_sync.btn_restore') || t('proxy.cli_sync.title')} |
| message={restoreConfirmApp |
| ? (statuses[restoreConfirmApp!]?.has_backup |
| ? t('proxy.cli_sync.restore_backup_confirm') |
| : t('proxy.cli_sync.restore_confirm', { name: restoreConfirmApp })) |
| : ''} |
| onConfirm={executeRestore} |
| onCancel={() => setRestoreConfirmApp(null)} |
| isDestructive={true} |
| /> |
| |
| {/* 同步配置确认弹窗 (Issue #756) */} |
| <ModalDialog |
| isOpen={!!syncConfirmApp} |
| title={t('proxy.cli_sync.sync_confirm_title')} |
| message={syncConfirmApp ? t('proxy.cli_sync.sync_confirm_message', { name: syncConfirmApp }) : ''} |
| onConfirm={executeSync} |
| onCancel={() => setSyncConfirmApp(null)} |
| isDestructive={true} |
| /> |
| |
| {/* Clear 确认弹窗 - 仅 OpenCode */} |
| <ModalDialog |
| isOpen={!!clearConfirmApp} |
| title={t('proxy.opencode_sync.clear_confirm_title', { defaultValue: 'Clear OpenCode Configuration' })} |
| message={t('proxy.opencode_sync.clear_confirm_message', { defaultValue: 'This will clear all OpenCode configurations including legacy settings. Are you sure?' })} |
| onConfirm={executeClear} |
| onCancel={() => setClearConfirmApp(null)} |
| isDestructive={true} |
| /> |
| |
| {/* Droid 模型添加弹窗 */} |
| {droidSyncModal && ( |
| <DroidSyncModal |
| proxyUrl={proxyUrl} |
| apiKey={apiKey} |
| getFormattedProxyUrl={getFormattedProxyUrl} |
| onClose={() => setDroidSyncModal(false)} |
| onSyncDone={() => checkStatus('Droid')} |
| /> |
| )} |
| |
| {/* OpenCode 模型选择弹窗 */} |
| {openCodeSyncModal && ( |
| <OpenCodeSyncModal |
| proxyUrl={proxyUrl} |
| apiKey={apiKey} |
| getFormattedProxyUrl={getFormattedProxyUrl} |
| onClose={() => setOpenCodeSyncModal(false)} |
| onSyncDone={() => checkStatus('OpenCode')} |
| /> |
| )} |
| </div> |
| ); |
| }; |
|
|
|
|
| export default CliSyncCard; |
|
|