| import { useState, useCallback } from 'react'; |
| import { useTranslation } from 'react-i18next'; |
| import { RefreshCw, X, Bot } from 'lucide-react'; |
| import { |
| DndContext, closestCenter, KeyboardSensor, PointerSensor, |
| useSensor, useSensors, type DragEndEvent, |
| } from '@dnd-kit/core'; |
| import { |
| arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, |
| } from '@dnd-kit/sortable'; |
| import { cn } from '../../utils/cn'; |
| import { request as invoke } from '../../utils/request'; |
| import { showToast } from '../common/ToastContainer'; |
| import { useProxyModels } from '../../hooks/useProxyModels'; |
| import { SortableModelItem, type PreviewModelEntry } from './SortableModelItem'; |
|
|
| interface DroidSyncModalProps { |
| proxyUrl: string; |
| apiKey: string; |
| getFormattedProxyUrl: (app: 'Claude' | 'Codex' | 'Gemini' | 'OpenCode' | 'Droid') => string; |
| onClose: () => void; |
| onSyncDone: () => void; |
| } |
|
|
| function buildDroidModel(modelId: string, modelName: string) { |
| const isClaude = modelId.startsWith('claude-'); |
| const isThinking = modelId.includes('thinking'); |
| if (isClaude) { |
| return { |
| model: modelId, |
| displayName: `AG-${modelName}`, |
| provider: 'anthropic', |
| noImageSupport: false, |
| maxOutputTokens: 64000, |
| ...(isThinking ? { extraArgs: { thinking: { type: 'enabled', budget_tokens: 32000 } } } : {}), |
| }; |
| } |
| return { |
| model: modelId, |
| displayName: `AG-${modelName}`, |
| provider: 'generic-chat-completion-api', |
| noImageSupport: !modelId.includes('image'), |
| }; |
| } |
|
|
| export function DroidSyncModal({ apiKey, getFormattedProxyUrl, onClose, onSyncDone }: DroidSyncModalProps) { |
| const { t } = useTranslation(); |
| const { models: antigravityModels } = useProxyModels(); |
| const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set()); |
| const [previewModels, setPreviewModels] = useState<PreviewModelEntry[]>([]); |
| const [expanded, setExpanded] = useState<Set<string>>(new Set()); |
| const [syncing, setSyncing] = useState(false); |
| const [configLoaded, setConfigLoaded] = useState(false); |
| const [currentConfig, setCurrentConfig] = useState<Record<string, unknown> | null>(null); |
|
|
| const sensors = useSensors( |
| useSensor(PointerSensor, { activationConstraint: { distance: 3 } }), |
| useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), |
| ); |
|
|
| const rebuildPreview = useCallback((selectedIds: Set<string>, existingConfig: Record<string, unknown> | null) => { |
| const base = getFormattedProxyUrl('Droid').replace(/\/+$/, ''); |
| const existing = existingConfig ?? {}; |
| const existingModels = Array.isArray((existing as Record<string, unknown>).customModels) |
| ? [...(existing as Record<string, unknown>).customModels as Record<string, unknown>[]] |
| : []; |
|
|
| const existingEntries: PreviewModelEntry[] = existingModels.map((m, i) => ({ |
| ...(m as PreviewModelEntry), |
| _uid: `existing-${i}`, |
| isAg: ((m as Record<string, unknown>).id as string || '').startsWith('custom:AG-'), |
| index: i, |
| })); |
|
|
| const existingAgModels = new Set(existingEntries.filter(e => e.isAg).map(e => e.model)); |
| const selected = antigravityModels.filter(m => selectedIds.has(m.id)); |
| const newEntries: PreviewModelEntry[] = selected |
| .filter(m => { |
| const cfg = buildDroidModel(m.id, m.name); |
| return !existingAgModels.has(cfg.model); |
| }) |
| .map((m, i) => { |
| const cfg = buildDroidModel(m.id, m.name); |
| const actualBase = cfg.provider === 'generic-chat-completion-api' |
| ? (base.endsWith('/v1') ? base : `${base}/v1`) : base; |
| const entry: PreviewModelEntry = { |
| _uid: `new-${i}`, |
| model: cfg.model, |
| id: `custom:${cfg.displayName.replace(/\s/g, '-')}`, |
| index: 0, |
| baseUrl: actualBase, |
| apiKey: apiKey, |
| displayName: cfg.displayName, |
| noImageSupport: cfg.noImageSupport ?? false, |
| provider: cfg.provider, |
| isAg: true, |
| }; |
| if ('maxOutputTokens' in cfg) entry.maxOutputTokens = cfg.maxOutputTokens; |
| if ('extraArgs' in cfg) entry.extraArgs = cfg.extraArgs; |
| return entry; |
| }); |
|
|
| const merged = [...existingEntries, ...newEntries]; |
| merged.forEach((m, i) => { |
| m.index = i; |
| if (m.isAg) m.id = `custom:${m.displayName.replace(/\s/g, '-')}-${i}`; |
| }); |
| setPreviewModels(merged); |
| }, [antigravityModels, apiKey, getFormattedProxyUrl]); |
|
|
| |
| if (!configLoaded) { |
| setConfigLoaded(true); |
| invoke<string>('get_droid_config_content', {}) |
| .then(content => { |
| const parsed = JSON.parse(content); |
| setCurrentConfig(parsed); |
| rebuildPreview(new Set(), parsed); |
| }) |
| .catch(() => rebuildPreview(new Set(), null)); |
| } |
|
|
| const reindexId = (id: string, newIdx: number) => id.replace(/-\d+$/, `-${newIdx}`); |
|
|
| const allSelected = antigravityModels.length > 0 && antigravityModels.every(m => selectedModels.has(m.id)); |
| const toggleAll = () => { |
| const next = allSelected ? new Set<string>() : new Set(antigravityModels.map(m => m.id)); |
| setSelectedModels(next); |
| rebuildPreview(next, currentConfig); |
| }; |
|
|
| const toggleModel = (modelListId: string) => { |
| const next = new Set(selectedModels); |
| const adding = !next.has(modelListId); |
| if (adding) next.add(modelListId); else next.delete(modelListId); |
| setSelectedModels(next); |
|
|
| if (adding) { |
| const m = antigravityModels.find(x => x.id === modelListId); |
| if (!m) return; |
| const base = getFormattedProxyUrl('Droid').replace(/\/+$/, ''); |
| const cfg = buildDroidModel(m.id, m.name); |
| const actualBase = cfg.provider === 'generic-chat-completion-api' |
| ? (base.endsWith('/v1') ? base : `${base}/v1`) : base; |
| const newIdx = previewModels.length; |
| const entry: PreviewModelEntry = { |
| _uid: `new-${Date.now()}-${m.id}`, |
| model: cfg.model, |
| id: `custom:${cfg.displayName.replace(/\s/g, '-')}-${newIdx}`, |
| index: newIdx, |
| baseUrl: actualBase, |
| apiKey: apiKey, |
| displayName: cfg.displayName, |
| noImageSupport: cfg.noImageSupport ?? false, |
| provider: cfg.provider, |
| isAg: true, |
| }; |
| if ('maxOutputTokens' in cfg) entry.maxOutputTokens = cfg.maxOutputTokens; |
| if ('extraArgs' in cfg) entry.extraArgs = cfg.extraArgs; |
| setPreviewModels([...previewModels, entry]); |
| } else { |
| const m = antigravityModels.find(x => x.id === modelListId); |
| if (!m) return; |
| const cfg = buildDroidModel(m.id, m.name); |
| setPreviewModels( |
| previewModels.filter(e => !(e.isAg && e.model === cfg.model)).map((m, i) => ({ |
| ...m, index: i, id: reindexId(m.id, i), |
| })) |
| ); |
| } |
| }; |
|
|
| const handleDragEnd = (event: DragEndEvent) => { |
| const { active, over } = event; |
| if (!over || active.id === over.id) return; |
| const oldIdx = previewModels.findIndex(m => m._uid === active.id); |
| const newIdx = previewModels.findIndex(m => m._uid === over.id); |
| if (oldIdx < 0 || newIdx < 0) return; |
| setPreviewModels(arrayMove([...previewModels], oldIdx, newIdx).map((m, i) => ({ |
| ...m, index: i, id: reindexId(m.id, i), |
| }))); |
| }; |
|
|
| const handleRemoveModel = (uid: string) => { |
| setPreviewModels( |
| previewModels.filter(m => m._uid !== uid).map((m, i) => ({ |
| ...m, index: i, id: reindexId(m.id, i), |
| })) |
| ); |
| }; |
|
|
| const executeDroidSync = async () => { |
| if (!previewModels.some(m => m.isAg)) { |
| showToast(t('proxy.droid_sync.toast.no_models_selected', { defaultValue: '请至少选择一个模型' }), 'error'); |
| return; |
| } |
| setSyncing(true); |
| try { |
| const customModels = previewModels.map(m => { |
| const { _uid, isAg, ...rest } = m; |
| return rest; |
| }); |
| const added = await invoke<number>('execute_droid_sync', { customModels }); |
| showToast(t('proxy.droid_sync.toast.sync_success_count', { count: added, defaultValue: `已添加 ${added} 个模型到 Droid` }), 'success'); |
| onSyncDone(); |
| onClose(); |
| } catch (error: any) { |
| showToast(t('proxy.droid_sync.toast.sync_error', { error: error.toString(), defaultValue: `同步失败: ${error.toString()}` }), 'error'); |
| } finally { |
| setSyncing(false); |
| } |
| }; |
|
|
| const groups = [...new Set(antigravityModels.map(m => m.group))]; |
| const existingCount = previewModels.filter(m => !m.isAg).length; |
| const agCount = previewModels.filter(m => m.isAg).length; |
|
|
| return ( |
| <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 max-h-[85vh] overflow-hidden animate-in zoom-in-95 duration-200 flex flex-col"> |
| {/* Header */} |
| <div className="px-5 pt-4 pb-3 shrink-0"> |
| <div className="flex items-center justify-between"> |
| <div className="flex items-center gap-2.5"> |
| <div className="p-2 bg-orange-50 dark:bg-orange-900/20 rounded-lg"> |
| <Bot size={18} className="text-orange-500" /> |
| </div> |
| <div> |
| <h3 className="text-sm font-bold text-gray-900 dark:text-base-content"> |
| {t('proxy.droid_sync.modal_title', { defaultValue: '添加模型到 Droid' })} |
| </h3> |
| <p className="text-[10px] text-gray-400 mt-0.5">~/.factory/settings.json</p> |
| </div> |
| </div> |
| <button onClick={onClose} className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-base-300 transition-colors"> |
| <X size={16} className="text-gray-400" /> |
| </button> |
| </div> |
| </div> |
| |
| {/* 模型选择区 */} |
| <div className="px-5 pb-3 shrink-0 border-b border-gray-100 dark:border-base-200"> |
| <div className="flex items-center justify-between mb-2"> |
| <span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider"> |
| {t('proxy.droid_sync.select_models', { defaultValue: '选择要添加的模型' })} |
| <span className="ml-2 text-gray-300">{selectedModels.size}/{antigravityModels.length}</span> |
| </span> |
| <button onClick={toggleAll} className="text-[10px] text-blue-500 hover:text-blue-600 font-medium transition-colors"> |
| {allSelected ? t('common.deselect_all', { defaultValue: '取消全选' }) : t('common.select_all', { defaultValue: '全选' })} |
| </button> |
| </div> |
| <div className="space-y-2 max-h-[25vh] overflow-auto"> |
| {groups.map(group => { |
| const groupModels = antigravityModels.filter(m => m.group === group); |
| return ( |
| <div key={group}> |
| <div className="text-[9px] font-bold text-gray-400 uppercase tracking-widest mb-1">{group}</div> |
| <div className="flex flex-wrap gap-1.5"> |
| {groupModels.map(m => { |
| const selected = selectedModels.has(m.id); |
| return ( |
| <button |
| key={m.id} |
| onClick={() => toggleModel(m.id)} |
| className={cn( |
| "px-2.5 py-1 rounded-md text-[11px] font-medium transition-all duration-150 border", |
| selected |
| ? "bg-orange-500 text-white border-orange-500" |
| : "bg-gray-50 dark:bg-base-200 text-gray-500 dark:text-gray-400 border-gray-200 dark:border-base-300 hover:border-orange-300" |
| )} |
| > |
| {m.name} |
| </button> |
| ); |
| })} |
| </div> |
| </div> |
| ); |
| })} |
| </div> |
| </div> |
| |
| {/* Preview 主体区 */} |
| <div className="flex-1 min-h-0 flex flex-col"> |
| <div className="px-5 py-2 flex items-center justify-between shrink-0"> |
| <span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider"> |
| customModels Preview |
| <span className="ml-2 font-normal"> |
| {existingCount > 0 && <span className="text-gray-300">{existingCount} existing</span>} |
| {existingCount > 0 && agCount > 0 && <span className="text-gray-200 mx-1">+</span>} |
| {agCount > 0 && <span className="text-orange-400">{agCount} new</span>} |
| </span> |
| </span> |
| <span className="text-[9px] font-mono text-gray-300">{previewModels.length} total</span> |
| </div> |
| <div className="px-4 pb-3 overflow-auto flex-1"> |
| <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> |
| <SortableContext items={previewModels.map(m => m._uid)} strategy={verticalListSortingStrategy}> |
| <div className="space-y-1.5"> |
| {previewModels.map(entry => ( |
| <SortableModelItem |
| key={entry._uid} |
| entry={entry} |
| collapsed={!expanded.has(entry._uid)} |
| onToggle={() => { |
| const next = new Set(expanded); |
| if (next.has(entry._uid)) next.delete(entry._uid); else next.add(entry._uid); |
| setExpanded(next); |
| }} |
| onRemove={() => handleRemoveModel(entry._uid)} |
| /> |
| ))} |
| </div> |
| </SortableContext> |
| </DndContext> |
| {previewModels.length === 0 && ( |
| <div className="text-center text-xs text-gray-400 py-8"> |
| {t('proxy.droid_sync.no_models', { defaultValue: '请在上方选择要添加的模型' })} |
| </div> |
| )} |
| </div> |
| </div> |
| |
| {/* Footer */} |
| <div className="px-5 py-3 border-t border-gray-100 dark:border-base-200 flex items-center justify-end gap-2 shrink-0"> |
| <button className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-base-300 transition-colors" onClick={onClose}> |
| {t('common.cancel', { defaultValue: '取消' })} |
| </button> |
| <button |
| className={cn( |
| "px-4 py-1.5 text-xs font-bold rounded-lg transition-all flex items-center gap-1.5", |
| previewModels.some(m => m.isAg) |
| ? "bg-orange-500 hover:bg-orange-600 active:bg-orange-700 text-white shadow-sm" |
| : "bg-gray-200 dark:bg-gray-700 text-gray-400 cursor-not-allowed" |
| )} |
| disabled={!previewModels.some(m => m.isAg) || syncing} |
| onClick={executeDroidSync} |
| > |
| <RefreshCw size={12} className={syncing ? 'animate-spin' : ''} /> |
| {t('proxy.droid_sync.btn_confirm_sync', { defaultValue: '写入配置' })} |
| </button> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|