| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351 |
- 'use client';
- import { useState, useCallback, useMemo } from 'react';
- import { Label } from '@/components/ui/label';
- import { Input } from '@/components/ui/input';
- import { Button } from '@/components/ui/button';
- import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
- import { useI18n } from '@/lib/hooks/use-i18n';
- import { useSettingsStore } from '@/lib/store/settings';
- import { VIDEO_PROVIDERS } from '@/lib/media/video-providers';
- import {
- Loader2,
- CheckCircle2,
- XCircle,
- Eye,
- EyeOff,
- Zap,
- Plus,
- Settings2,
- Trash2,
- } from 'lucide-react';
- import { cn } from '@/lib/utils';
- import type { VideoProviderId } from '@/lib/media/types';
- interface VideoSettingsProps {
- selectedProviderId: VideoProviderId;
- }
- export function VideoSettings({ selectedProviderId }: VideoSettingsProps) {
- const { t } = useI18n();
- const videoModelId = useSettingsStore((state) => state.videoModelId);
- const videoProvidersConfig = useSettingsStore((state) => state.videoProvidersConfig);
- const setVideoProviderConfig = useSettingsStore((state) => state.setVideoProviderConfig);
- const [showApiKey, setShowApiKey] = useState(false);
- const [testLoading, setTestLoading] = useState(false);
- const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
- const [testMessage, setTestMessage] = useState('');
- // Model dialog state
- const [showModelDialog, setShowModelDialog] = useState(false);
- const [editingModelIndex, setEditingModelIndex] = useState<number | null>(null);
- const [modelForm, setModelForm] = useState({ id: '', name: '' });
- // Reset test state when provider changes (derived state pattern)
- const [prevSelectedProviderId, setPrevSelectedProviderId] = useState(selectedProviderId);
- if (selectedProviderId !== prevSelectedProviderId) {
- setPrevSelectedProviderId(selectedProviderId);
- setTestStatus('idle');
- setTestMessage('');
- }
- const currentConfig = videoProvidersConfig[selectedProviderId];
- const currentProvider = VIDEO_PROVIDERS[selectedProviderId];
- const builtInModels = currentProvider?.models || [];
- const customModels = useMemo(
- () => currentConfig?.customModels || [],
- [currentConfig?.customModels],
- );
- const isServerConfigured = !!currentConfig?.isServerConfigured;
- const handleApiKeyChange = (apiKey: string) => {
- setVideoProviderConfig(selectedProviderId, { apiKey });
- };
- const handleBaseUrlChange = (baseUrl: string) => {
- setVideoProviderConfig(selectedProviderId, { baseUrl });
- };
- const handleTest = async () => {
- setTestLoading(true);
- setTestStatus('idle');
- setTestMessage('');
- try {
- const response = await fetch('/api/verify-video-provider', {
- method: 'POST',
- headers: {
- 'x-video-provider': selectedProviderId,
- 'x-video-model': videoModelId || '',
- 'x-api-key': currentConfig?.apiKey || '',
- 'x-base-url': currentConfig?.baseUrl || '',
- },
- });
- const data = await response.json();
- if (data.success) {
- setTestStatus('success');
- setTestMessage(t('settings.videoConnectivitySuccess'));
- } else {
- setTestStatus('error');
- setTestMessage(`${t('settings.videoConnectivityFailed')}: ${data.message}`);
- }
- } catch (err) {
- setTestStatus('error');
- setTestMessage(`${t('settings.videoConnectivityFailed')}: ${err}`);
- } finally {
- setTestLoading(false);
- }
- };
- // Model CRUD
- const handleOpenAddModel = () => {
- setEditingModelIndex(null);
- setModelForm({ id: '', name: '' });
- setShowModelDialog(true);
- };
- const handleOpenEditModel = (index: number) => {
- setEditingModelIndex(index);
- setModelForm({ ...customModels[index] });
- setShowModelDialog(true);
- };
- const handleSaveModel = useCallback(() => {
- if (!modelForm.id.trim()) return;
- const newCustomModels = [...customModels];
- if (editingModelIndex !== null) {
- newCustomModels[editingModelIndex] = {
- id: modelForm.id.trim(),
- name: modelForm.name.trim() || modelForm.id.trim(),
- };
- } else {
- newCustomModels.push({
- id: modelForm.id.trim(),
- name: modelForm.name.trim() || modelForm.id.trim(),
- });
- }
- setVideoProviderConfig(selectedProviderId, {
- customModels: newCustomModels,
- });
- setShowModelDialog(false);
- }, [modelForm, editingModelIndex, customModels, selectedProviderId, setVideoProviderConfig]);
- const handleDeleteModel = (index: number) => {
- const newCustomModels = customModels.filter((_, i) => i !== index);
- setVideoProviderConfig(selectedProviderId, {
- customModels: newCustomModels,
- });
- };
- return (
- <div className="space-y-6 max-w-3xl">
- {/* Server-configured notice */}
- {isServerConfigured && (
- <div className="rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 p-3 text-sm text-blue-700 dark:text-blue-300">
- {t('settings.serverConfiguredNotice')}
- </div>
- )}
- {/* API Key + Test inline */}
- <div className="space-y-2">
- <Label>API Key</Label>
- <div className="flex gap-2">
- <div className="relative flex-1">
- <Input
- name={`video-api-key-${selectedProviderId}`}
- type={showApiKey ? 'text' : 'password'}
- autoComplete="new-password"
- autoCapitalize="none"
- autoCorrect="off"
- spellCheck={false}
- placeholder={
- isServerConfigured
- ? t('settings.optionalOverride')
- : selectedProviderId === 'kling'
- ? 'accessKey:secretKey'
- : t('settings.enterApiKey')
- }
- value={currentConfig?.apiKey || ''}
- onChange={(e) => handleApiKeyChange(e.target.value)}
- className="h-8 pr-8"
- />
- <button
- type="button"
- onClick={() => setShowApiKey(!showApiKey)}
- className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
- >
- {showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
- </button>
- </div>
- <Button
- variant="outline"
- size="sm"
- onClick={handleTest}
- disabled={testLoading || (!currentConfig?.apiKey && !isServerConfigured)}
- className="gap-1.5"
- >
- {testLoading ? (
- <Loader2 className="h-3.5 w-3.5 animate-spin" />
- ) : (
- <>
- <Zap className="h-3.5 w-3.5" />
- {t('settings.testConnection')}
- </>
- )}
- </Button>
- </div>
- {testMessage && (
- <div
- className={cn(
- 'rounded-lg p-3 text-sm overflow-hidden',
- testStatus === 'success' &&
- 'bg-green-50 text-green-700 border border-green-200 dark:bg-green-950/50 dark:text-green-400 dark:border-green-800',
- testStatus === 'error' &&
- 'bg-red-50 text-red-700 border border-red-200 dark:bg-red-950/50 dark:text-red-400 dark:border-red-800',
- )}
- >
- <div className="flex items-start gap-2 min-w-0">
- {testStatus === 'success' && <CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" />}
- {testStatus === 'error' && <XCircle className="h-4 w-4 mt-0.5 shrink-0" />}
- <p className="flex-1 min-w-0 break-all">{testMessage}</p>
- </div>
- </div>
- )}
- </div>
- {/* Base URL */}
- <div className="space-y-2">
- <Label>Base URL</Label>
- <Input
- name={`video-base-url-${selectedProviderId}`}
- type="url"
- autoComplete="off"
- autoCapitalize="none"
- autoCorrect="off"
- spellCheck={false}
- value={currentConfig?.baseUrl || ''}
- onChange={(e) => handleBaseUrlChange(e.target.value)}
- placeholder={
- currentConfig?.serverBaseUrl ||
- currentProvider?.defaultBaseUrl ||
- t('settings.enterCustomBaseUrl')
- }
- className="h-8"
- />
- {(() => {
- const effectiveBaseUrl =
- currentConfig?.baseUrl ||
- currentConfig?.serverBaseUrl ||
- currentProvider?.defaultBaseUrl ||
- '';
- if (!effectiveBaseUrl) return null;
- return (
- <p className="text-xs text-muted-foreground break-all">
- {t('settings.requestUrl')}: {effectiveBaseUrl}
- </p>
- );
- })()}
- </div>
- {/* Model list */}
- <div className="space-y-3">
- <div className="flex items-center justify-between flex-wrap gap-2">
- <Label className="text-base">{t('settings.models')}</Label>
- <Button variant="outline" size="sm" onClick={handleOpenAddModel} className="gap-1.5">
- <Plus className="h-3.5 w-3.5" />
- {t('settings.addNewModel')}
- </Button>
- </div>
- <div className="space-y-1.5">
- {/* Built-in models */}
- {builtInModels.map((model) => (
- <div
- key={model.id}
- className="flex items-center justify-between p-3 rounded-lg border border-border/50 bg-card"
- >
- <div className="flex-1 min-w-0">
- <div className="font-mono text-sm font-medium">{model.name}</div>
- <div className="text-xs text-muted-foreground font-mono mt-0.5">{model.id}</div>
- </div>
- </div>
- ))}
- {/* Custom models */}
- {customModels.map((model, index) => (
- <div
- key={`custom-${index}`}
- className="flex items-center justify-between p-3 rounded-lg border border-border/50 bg-card"
- >
- <div className="flex-1 min-w-0">
- <div className="font-mono text-sm font-medium">{model.name}</div>
- <div className="text-xs text-muted-foreground font-mono mt-0.5">{model.id}</div>
- </div>
- <div className="flex items-center gap-1">
- <Button
- variant="outline"
- size="sm"
- className="h-8 px-2"
- onClick={() => handleOpenEditModel(index)}
- title={t('settings.editModel')}
- >
- <Settings2 className="h-3.5 w-3.5" />
- </Button>
- <Button
- variant="outline"
- size="sm"
- className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
- onClick={() => handleDeleteModel(index)}
- title={t('settings.deleteModel')}
- >
- <Trash2 className="h-3.5 w-3.5" />
- </Button>
- </div>
- </div>
- ))}
- </div>
- </div>
- {/* Add/Edit Model Dialog */}
- <Dialog open={showModelDialog} onOpenChange={setShowModelDialog}>
- <DialogContent className="sm:max-w-md">
- <DialogTitle>
- {editingModelIndex !== null ? t('settings.editModel') : t('settings.addNewModel')}
- </DialogTitle>
- <DialogDescription className="sr-only">
- {editingModelIndex !== null ? t('settings.editModel') : t('settings.addNewModel')}
- </DialogDescription>
- <div className="space-y-4 pt-2">
- <div className="space-y-2">
- <Label>{t('settings.modelId')}</Label>
- <Input
- value={modelForm.id}
- onChange={(e) => setModelForm((prev) => ({ ...prev, id: e.target.value }))}
- placeholder="e.g. my-custom-model-v1"
- className="h-8 font-mono text-sm"
- />
- </div>
- <div className="space-y-2">
- <Label>{t('settings.modelName')}</Label>
- <Input
- value={modelForm.name}
- onChange={(e) => setModelForm((prev) => ({ ...prev, name: e.target.value }))}
- placeholder="e.g. My Custom Model"
- className="h-8 text-sm"
- />
- </div>
- <div className="flex justify-end gap-2">
- <Button variant="outline" size="sm" onClick={() => setShowModelDialog(false)}>
- {t('settings.cancelEdit')}
- </Button>
- <Button size="sm" onClick={handleSaveModel} disabled={!modelForm.id.trim()}>
- {t('settings.saveModel')}
- </Button>
- </div>
- </div>
- </DialogContent>
- </Dialog>
- </div>
- );
- }
|