studio/SettingsModal.tsx

252 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState } from 'react';
import { Settings, X, RefreshCw, Cpu, Server, Key, AlertTriangle, Code } from 'lucide-react';
import { ModelSettings } from '../types';
import { AVAILABLE_TEXT_MODELS, AVAILABLE_IMAGE_MODELS } from '../constants';
import { fetchAvailableGeminiModels } from '../services/gemini/client';
import { fetchOpenAIModels } from '../services/openai/client';
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
modelSettings: ModelSettings;
onUpdateSettings: (settings: ModelSettings) => void;
}
const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, modelSettings, onUpdateSettings }) => {
const [loadingGoogleModels, setLoadingGoogleModels] = useState(false);
const [loadingOpenAIModels, setLoadingOpenAIModels] = useState(false);
const [openAIError, setOpenAIError] = useState<string | null>(null);
useEffect(() => {
if (isOpen && modelSettings.provider === 'google' && modelSettings.googleApiKey) {
// Optional: auto-fetch on open if key exists?
// Better to let user click refresh to save quota/calls
}
}, [isOpen]);
const loadDynamicGoogleModels = async () => {
if (!modelSettings.googleApiKey) {
alert("請先輸入 Google API Key");
return;
}
setLoadingGoogleModels(true);
try {
const { textModels, imageModels } = await fetchAvailableGeminiModels(modelSettings.googleApiKey);
if (textModels.length > 0 || imageModels.length > 0) {
onUpdateSettings({
...modelSettings,
availableTextModels: textModels,
availableImageModels: imageModels
});
}
} catch (e: any) {
alert(`Google Models 更新失敗: ${e.message}`);
} finally {
setLoadingGoogleModels(false);
}
};
const loadDynamicOpenAIModels = async () => {
if (!modelSettings.openaiApiKey) {
alert("請先輸入 API Key");
return;
}
setLoadingOpenAIModels(true);
setOpenAIError(null);
try {
const models = await fetchOpenAIModels(modelSettings.openaiBaseUrl, modelSettings.openaiApiKey);
if (models.length > 0) {
onUpdateSettings({
...modelSettings,
availableOpenAIModels: models
});
} else {
setOpenAIError("未找到可用模型,請檢查 URL 或 Key");
}
} catch (e: any) {
console.error(e);
setOpenAIError(e.message);
} finally {
setLoadingOpenAIModels(false);
}
};
if (!isOpen) return null;
const googleTextModels = modelSettings.availableTextModels?.length ? modelSettings.availableTextModels : AVAILABLE_TEXT_MODELS;
const googleImageModels = modelSettings.availableImageModels?.length ? modelSettings.availableImageModels : AVAILABLE_IMAGE_MODELS;
const openAIModels = modelSettings.availableOpenAIModels || [];
return (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] flex items-center justify-center p-4">
<div className="glass-card rounded-3xl w-full max-w-lg p-8 shadow-2xl relative animate-fade-in-up flex flex-col max-h-[90vh]">
<button onClick={onClose} className="absolute top-4 right-4 text-zinc-500 hover:text-white">
<X className="w-5 h-5" />
</button>
<h3 className="text-xl font-bold text-white mb-6 flex items-center gap-2">
<Settings className="w-5 h-5 text-indigo-500" /> (System Settings)
</h3>
<div className="space-y-6 overflow-y-auto pr-2">
{/* Provider Toggle */}
<div className="bg-white/5 p-4 rounded-xl border border-white/10">
<label className="text-xs font-bold text-zinc-400 mb-3 uppercase tracking-wider flex items-center gap-2">
<Cpu className="w-4 h-4" /> AI (Inference Provider)
</label>
<div className="flex bg-black/40 rounded-lg p-1">
<button
onClick={() => onUpdateSettings({ ...modelSettings, provider: 'google' })}
className={`flex-1 py-2 rounded-md text-sm font-bold transition-all ${modelSettings.provider === 'google' ? 'bg-indigo-500 text-white shadow-lg' : 'text-zinc-500 hover:text-zinc-300'}`}
>
Google Gemini
</button>
<button
onClick={() => onUpdateSettings({ ...modelSettings, provider: 'openai' })}
className={`flex-1 py-2 rounded-md text-sm font-bold transition-all ${modelSettings.provider === 'openai' ? 'bg-indigo-500 text-white shadow-lg' : 'text-zinc-500 hover:text-zinc-300'}`}
>
OpenAI / Grok
</button>
</div>
</div>
{modelSettings.provider === 'google' ? (
// GOOGLE SETTINGS
<div className="space-y-4 animate-fade-in">
<div>
<label className="block text-xs font-bold text-zinc-400 mb-2 uppercase tracking-wider flex items-center gap-2"><Key className="w-3 h-3" /> Google API Key</label>
<input
type="password"
className="glass-input w-full rounded-xl p-3 text-sm font-mono text-indigo-300"
value={modelSettings.googleApiKey || ''}
onChange={(e) => onUpdateSettings({ ...modelSettings, googleApiKey: e.target.value })}
placeholder="AIza..."
/>
</div>
<div className="relative">
<div className="flex justify-between items-center mb-2">
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider"> (Text Model)</label>
<button onClick={loadDynamicGoogleModels} disabled={loadingGoogleModels} className="text-[10px] text-indigo-400 flex items-center gap-1 hover:text-indigo-300 bg-indigo-500/10 px-2 py-1 rounded">
<RefreshCw className={`w-3 h-3 ${loadingGoogleModels ? 'animate-spin' : ''}`} />
{loadingGoogleModels ? '抓取中...' : '刷新真實列表'}
</button>
</div>
<select
className="glass-input w-full rounded-xl p-3 text-sm bg-black"
value={modelSettings.textModel}
onChange={(e) => onUpdateSettings({ ...modelSettings, textModel: e.target.value })}
>
{googleTextModels.map(m => <option key={m.id} value={m.id} className="bg-zinc-900">{m.name}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-bold text-zinc-400 mb-2 uppercase tracking-wider"> (Image Model)</label>
<select
className="glass-input w-full rounded-xl p-3 text-sm bg-black"
value={modelSettings.imageModel}
onChange={(e) => onUpdateSettings({ ...modelSettings, imageModel: e.target.value })}
>
{googleImageModels.map(m => <option key={m.id} value={m.id} className="bg-zinc-900">{m.name}</option>)}
</select>
</div>
</div>
) : (
// OPENAI / GROK SETTINGS
<div className="space-y-4 animate-fade-in">
<div>
<label className="block text-xs font-bold text-zinc-400 mb-2 uppercase tracking-wider flex items-center gap-2"><Server className="w-3 h-3" /> API Base URL</label>
<input
type="text"
className="glass-input w-full rounded-xl p-3 text-sm font-mono text-emerald-300"
value={modelSettings.openaiBaseUrl}
onChange={(e) => onUpdateSettings({ ...modelSettings, openaiBaseUrl: e.target.value })}
placeholder="https://api.x.ai/v1"
/>
<p className="text-[10px] text-zinc-500 mt-1">例如: Grok (https://api.x.ai/v1), OpenAI (https://api.openai.com/v1)</p>
</div>
<div>
<label className="block text-xs font-bold text-zinc-400 mb-2 uppercase tracking-wider flex items-center gap-2"><Key className="w-3 h-3" /> API Key</label>
<input
type="password"
className="glass-input w-full rounded-xl p-3 text-sm font-mono"
value={modelSettings.openaiApiKey}
onChange={(e) => onUpdateSettings({ ...modelSettings, openaiApiKey: e.target.value })}
placeholder="sk-..."
/>
</div>
<div className="relative">
<div className="flex justify-between items-center mb-2">
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider">Model Name</label>
<button onClick={loadDynamicOpenAIModels} disabled={loadingOpenAIModels} className="text-[10px] text-emerald-400 flex items-center gap-1 hover:text-emerald-300 bg-emerald-500/10 px-2 py-1 rounded">
<RefreshCw className={`w-3 h-3 ${loadingOpenAIModels ? 'animate-spin' : ''}`} />
{loadingOpenAIModels ? '抓取中...' : '刷新模型列表'}
</button>
</div>
{openAIError && (
<div className="mb-2 text-[10px] text-red-400 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" /> {openAIError}
</div>
)}
{openAIModels.length > 0 ? (
<select
className="glass-input w-full rounded-xl p-3 text-sm bg-black"
value={modelSettings.openaiModel}
onChange={(e) => onUpdateSettings({ ...modelSettings, openaiModel: e.target.value })}
>
{openAIModels.map(m => (
<option key={m.id} value={m.id} className="bg-zinc-900">{m.name}</option>
))}
</select>
) : (
<input
type="text"
className="glass-input w-full rounded-xl p-3 text-sm font-mono"
value={modelSettings.openaiModel}
onChange={(e) => onUpdateSettings({ ...modelSettings, openaiModel: e.target.value })}
placeholder="grok-beta"
/>
)}
</div>
<div className="bg-yellow-500/10 border border-yellow-500/30 p-3 rounded-lg text-xs text-yellow-200">
OpenAI / Grok Google Gemini Image Model ( Google Key)
</div>
</div>
)}
{/* DEV MODE Toggle */}
<div className="bg-amber-500/10 border border-amber-500/30 p-4 rounded-xl">
<label className="text-xs font-bold text-amber-300 mb-3 uppercase tracking-wider flex items-center gap-2">
<Code className="w-4 h-4" /> (DEV MODE)
</label>
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="text-xs text-amber-200/80 mb-1">使</p>
<p className="text-[10px] text-amber-300/60">Use placeholder images when generation fails</p>
</div>
<button
onClick={() => onUpdateSettings({ ...modelSettings, devMode: !modelSettings.devMode })}
className={`relative w-14 h-8 rounded-full transition-all duration-300 ${
modelSettings.devMode ? 'bg-amber-500' : 'bg-zinc-700'
}`}
>
<div className={`absolute top-1 left-1 w-6 h-6 bg-white rounded-full transition-transform duration-300 ${
modelSettings.devMode ? 'translate-x-6' : 'translate-x-0'
}`} />
</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default SettingsModal;