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>({ Claude: null, Codex: null, Gemini: null, OpenCode: null, Droid: null }); const [loading, setLoading] = useState>({ Claude: false, Codex: false, Gemini: false, OpenCode: false, Droid: false }); const [syncing, setSyncing] = useState>({ Claude: false, Codex: false, Gemini: false, OpenCode: false, Droid: false }); const [syncAccounts, setSyncAccounts] = useState(false); const [droidSyncModal, setDroidSyncModal] = useState(false); const [selectedModels, setSelectedModels] = useState>({ 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(null); const [syncConfirmApp, setSyncConfirmApp] = useState(null); const [openCodeSyncModal, setOpenCodeSyncModal] = useState(false); const [clearConfirmApp, setClearConfirmApp] = useState(null); const { models: proxyModels } = useProxyModels(); const modelOptions = proxyModels.map(m => ({ value: m.id, label: m.name, group: m.group || 'General' })); // 根据不同的 CLI 应用格式化 Proxy URL const getFormattedProxyUrl = useCallback((app: CliAppType) => { if (!proxyUrl) return ''; const base = proxyUrl.trimEnd().replace(/\/+$/, ''); // Codex & OpenCode (OpenAI 协议) 通常需要带 /v1 if (app === 'Codex' || app === 'OpenCode') { return base.endsWith('/v1') ? base : `${base}/v1`; } // Claude 和 Gemini 的 SDK 通常会自动处理版本路径或不需要 /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; 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(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; 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(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 (
{icon}

{t('proxy.cli_sync.card_title', { name })}

{isAppLoading ? (
{t('proxy.cli_sync.status.detecting')}
) : status?.installed ? ( {t('proxy.cli_sync.status.installed', { version: status.version })} ) : ( {t('proxy.cli_sync.status.not_installed')} )}
{/* 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) && (
{status.is_synced ? ( <> {t('proxy.cli_sync.status.synced', { defaultValue: '已同步' })} ) : ( <> {t('proxy.cli_sync.status.not_synced', { defaultValue: '未同步' })} )}
)}
{t('proxy.cli_sync.status.current_base_url')}
{status?.current_base_url || '---'}
{/* Claude, Codex, Gemini 的模型选择 */} {(status?.installed || app === 'OpenCode') && (app === 'Claude' || app === 'Codex' || app === 'Gemini') && (
{t('proxy.cli_sync.model_select', { defaultValue: 'Select Model' })}
setSelectedModels(prev => ({ ...prev, [app]: val }))} options={modelOptions} className="w-full !h-8 !text-[11px] !rounded-lg" allowCustomInput={true} />
)} {/* OpenCode 独有的账号同步选项 - Allow even if not installed */} {app === 'OpenCode' && (
setSyncAccounts(e.target.checked)} className="checkbox checkbox-xs checkbox-primary" />
)}
{(status?.installed || app === 'OpenCode') && ( <> {/* 对于 OpenCode,如果未同步,则不显示查看按钮(因为文件尚未生成,后端会报错) */} {(app !== 'OpenCode' || status?.is_synced) && ( )} {/* OpenCode 独有的 Clear 按钮 */} {app === 'OpenCode' && ( )} )}
); }; return (
{t('proxy.cli_sync.title')}

{t('proxy.cli_sync.subtitle')}

{renderCliItem('Claude', , 'Claude Code')} {renderCliItem('Codex', , 'Codex AI')} {renderCliItem('Gemini', , 'Gemini CLI')} {renderCliItem('OpenCode', , 'OpenCode')} {renderCliItem('Droid', , 'Droid')}
{/* Config Viewer Modal */} {viewingConfig && (

{t('proxy.cli_sync.modal.view_title', { name: viewingConfig.app })}

{viewingConfig.allFiles.map(file => ( ))}
                                    {viewingConfig.content}
                                
)} {/* 恢复默认/备份确认弹窗 */} setRestoreConfirmApp(null)} isDestructive={true} /> {/* 同步配置确认弹窗 (Issue #756) */} setSyncConfirmApp(null)} isDestructive={true} /> {/* Clear 确认弹窗 - 仅 OpenCode */} setClearConfirmApp(null)} isDestructive={true} /> {/* Droid 模型添加弹窗 */} {droidSyncModal && ( setDroidSyncModal(false)} onSyncDone={() => checkStatus('Droid')} /> )} {/* OpenCode 模型选择弹窗 */} {openCodeSyncModal && ( setOpenCodeSyncModal(false)} onSyncDone={() => checkStatus('OpenCode')} /> )}
); }; export default CliSyncCard;