349 lines
13 KiB
TypeScript
349 lines
13 KiB
TypeScript
'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>
|
|
)}
|
|
|
|
{/* Managed providers are admin-owned: the operator's key and base URL are
|
|
authoritative and not overridable here, so the editing inputs are hidden. */}
|
|
{!isServerConfigured && (
|
|
<>
|
|
{/* 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={
|
|
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}
|
|
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={currentProvider?.defaultBaseUrl || t('settings.enterCustomBaseUrl')}
|
|
className="h-8"
|
|
/>
|
|
{(() => {
|
|
const effectiveBaseUrl =
|
|
currentConfig?.baseUrl || 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>
|
|
);
|
|
}
|