app / src /components /proxy /DroidSyncModal.tsx
AZILS's picture
Upload 323 files
a21c316 verified
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]);
// 初始加载 settings.json
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>
);
}