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>(new Set()); const [previewModels, setPreviewModels] = useState([]); const [expanded, setExpanded] = useState>(new Set()); const [syncing, setSyncing] = useState(false); const [configLoaded, setConfigLoaded] = useState(false); const [currentConfig, setCurrentConfig] = useState | null>(null); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 3 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); const rebuildPreview = useCallback((selectedIds: Set, existingConfig: Record | null) => { const base = getFormattedProxyUrl('Droid').replace(/\/+$/, ''); const existing = existingConfig ?? {}; const existingModels = Array.isArray((existing as Record).customModels) ? [...(existing as Record).customModels as Record[]] : []; const existingEntries: PreviewModelEntry[] = existingModels.map((m, i) => ({ ...(m as PreviewModelEntry), _uid: `existing-${i}`, isAg: ((m as Record).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]); // 初始加载 settings.json if (!configLoaded) { setConfigLoaded(true); invoke('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() : 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('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 (
{/* Header */}

{t('proxy.droid_sync.modal_title', { defaultValue: '添加模型到 Droid' })}

~/.factory/settings.json

{/* 模型选择区 */}
{t('proxy.droid_sync.select_models', { defaultValue: '选择要添加的模型' })} {selectedModels.size}/{antigravityModels.length}
{groups.map(group => { const groupModels = antigravityModels.filter(m => m.group === group); return (
{group}
{groupModels.map(m => { const selected = selectedModels.has(m.id); return ( ); })}
); })}
{/* Preview 主体区 */}
customModels Preview {existingCount > 0 && {existingCount} existing} {existingCount > 0 && agCount > 0 && +} {agCount > 0 && {agCount} new} {previewModels.length} total
m._uid)} strategy={verticalListSortingStrategy}>
{previewModels.map(entry => ( { 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)} /> ))}
{previewModels.length === 0 && (
{t('proxy.droid_sync.no_models', { defaultValue: '请在上方选择要添加的模型' })}
)}
{/* Footer */}
); }