video-settings.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. 'use client';
  2. import { useState, useCallback, useMemo } from 'react';
  3. import { Label } from '@/components/ui/label';
  4. import { Input } from '@/components/ui/input';
  5. import { Button } from '@/components/ui/button';
  6. import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
  7. import { useI18n } from '@/lib/hooks/use-i18n';
  8. import { useSettingsStore } from '@/lib/store/settings';
  9. import { VIDEO_PROVIDERS } from '@/lib/media/video-providers';
  10. import {
  11. Loader2,
  12. CheckCircle2,
  13. XCircle,
  14. Eye,
  15. EyeOff,
  16. Zap,
  17. Plus,
  18. Settings2,
  19. Trash2,
  20. } from 'lucide-react';
  21. import { cn } from '@/lib/utils';
  22. import type { VideoProviderId } from '@/lib/media/types';
  23. interface VideoSettingsProps {
  24. selectedProviderId: VideoProviderId;
  25. }
  26. export function VideoSettings({ selectedProviderId }: VideoSettingsProps) {
  27. const { t } = useI18n();
  28. const videoModelId = useSettingsStore((state) => state.videoModelId);
  29. const videoProvidersConfig = useSettingsStore((state) => state.videoProvidersConfig);
  30. const setVideoProviderConfig = useSettingsStore((state) => state.setVideoProviderConfig);
  31. const [showApiKey, setShowApiKey] = useState(false);
  32. const [testLoading, setTestLoading] = useState(false);
  33. const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
  34. const [testMessage, setTestMessage] = useState('');
  35. // Model dialog state
  36. const [showModelDialog, setShowModelDialog] = useState(false);
  37. const [editingModelIndex, setEditingModelIndex] = useState<number | null>(null);
  38. const [modelForm, setModelForm] = useState({ id: '', name: '' });
  39. // Reset test state when provider changes (derived state pattern)
  40. const [prevSelectedProviderId, setPrevSelectedProviderId] = useState(selectedProviderId);
  41. if (selectedProviderId !== prevSelectedProviderId) {
  42. setPrevSelectedProviderId(selectedProviderId);
  43. setTestStatus('idle');
  44. setTestMessage('');
  45. }
  46. const currentConfig = videoProvidersConfig[selectedProviderId];
  47. const currentProvider = VIDEO_PROVIDERS[selectedProviderId];
  48. const builtInModels = currentProvider?.models || [];
  49. const customModels = useMemo(
  50. () => currentConfig?.customModels || [],
  51. [currentConfig?.customModels],
  52. );
  53. const isServerConfigured = !!currentConfig?.isServerConfigured;
  54. const handleApiKeyChange = (apiKey: string) => {
  55. setVideoProviderConfig(selectedProviderId, { apiKey });
  56. };
  57. const handleBaseUrlChange = (baseUrl: string) => {
  58. setVideoProviderConfig(selectedProviderId, { baseUrl });
  59. };
  60. const handleTest = async () => {
  61. setTestLoading(true);
  62. setTestStatus('idle');
  63. setTestMessage('');
  64. try {
  65. const response = await fetch('/api/verify-video-provider', {
  66. method: 'POST',
  67. headers: {
  68. 'x-video-provider': selectedProviderId,
  69. 'x-video-model': videoModelId || '',
  70. 'x-api-key': currentConfig?.apiKey || '',
  71. 'x-base-url': currentConfig?.baseUrl || '',
  72. },
  73. });
  74. const data = await response.json();
  75. if (data.success) {
  76. setTestStatus('success');
  77. setTestMessage(t('settings.videoConnectivitySuccess'));
  78. } else {
  79. setTestStatus('error');
  80. setTestMessage(`${t('settings.videoConnectivityFailed')}: ${data.message}`);
  81. }
  82. } catch (err) {
  83. setTestStatus('error');
  84. setTestMessage(`${t('settings.videoConnectivityFailed')}: ${err}`);
  85. } finally {
  86. setTestLoading(false);
  87. }
  88. };
  89. // Model CRUD
  90. const handleOpenAddModel = () => {
  91. setEditingModelIndex(null);
  92. setModelForm({ id: '', name: '' });
  93. setShowModelDialog(true);
  94. };
  95. const handleOpenEditModel = (index: number) => {
  96. setEditingModelIndex(index);
  97. setModelForm({ ...customModels[index] });
  98. setShowModelDialog(true);
  99. };
  100. const handleSaveModel = useCallback(() => {
  101. if (!modelForm.id.trim()) return;
  102. const newCustomModels = [...customModels];
  103. if (editingModelIndex !== null) {
  104. newCustomModels[editingModelIndex] = {
  105. id: modelForm.id.trim(),
  106. name: modelForm.name.trim() || modelForm.id.trim(),
  107. };
  108. } else {
  109. newCustomModels.push({
  110. id: modelForm.id.trim(),
  111. name: modelForm.name.trim() || modelForm.id.trim(),
  112. });
  113. }
  114. setVideoProviderConfig(selectedProviderId, {
  115. customModels: newCustomModels,
  116. });
  117. setShowModelDialog(false);
  118. }, [modelForm, editingModelIndex, customModels, selectedProviderId, setVideoProviderConfig]);
  119. const handleDeleteModel = (index: number) => {
  120. const newCustomModels = customModels.filter((_, i) => i !== index);
  121. setVideoProviderConfig(selectedProviderId, {
  122. customModels: newCustomModels,
  123. });
  124. };
  125. return (
  126. <div className="space-y-6 max-w-3xl">
  127. {/* Server-configured notice */}
  128. {isServerConfigured && (
  129. <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">
  130. {t('settings.serverConfiguredNotice')}
  131. </div>
  132. )}
  133. {/* API Key + Test inline */}
  134. <div className="space-y-2">
  135. <Label>API Key</Label>
  136. <div className="flex gap-2">
  137. <div className="relative flex-1">
  138. <Input
  139. name={`video-api-key-${selectedProviderId}`}
  140. type={showApiKey ? 'text' : 'password'}
  141. autoComplete="new-password"
  142. autoCapitalize="none"
  143. autoCorrect="off"
  144. spellCheck={false}
  145. placeholder={
  146. isServerConfigured
  147. ? t('settings.optionalOverride')
  148. : selectedProviderId === 'kling'
  149. ? 'accessKey:secretKey'
  150. : t('settings.enterApiKey')
  151. }
  152. value={currentConfig?.apiKey || ''}
  153. onChange={(e) => handleApiKeyChange(e.target.value)}
  154. className="h-8 pr-8"
  155. />
  156. <button
  157. type="button"
  158. onClick={() => setShowApiKey(!showApiKey)}
  159. className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
  160. >
  161. {showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
  162. </button>
  163. </div>
  164. <Button
  165. variant="outline"
  166. size="sm"
  167. onClick={handleTest}
  168. disabled={testLoading || (!currentConfig?.apiKey && !isServerConfigured)}
  169. className="gap-1.5"
  170. >
  171. {testLoading ? (
  172. <Loader2 className="h-3.5 w-3.5 animate-spin" />
  173. ) : (
  174. <>
  175. <Zap className="h-3.5 w-3.5" />
  176. {t('settings.testConnection')}
  177. </>
  178. )}
  179. </Button>
  180. </div>
  181. {testMessage && (
  182. <div
  183. className={cn(
  184. 'rounded-lg p-3 text-sm overflow-hidden',
  185. testStatus === 'success' &&
  186. 'bg-green-50 text-green-700 border border-green-200 dark:bg-green-950/50 dark:text-green-400 dark:border-green-800',
  187. testStatus === 'error' &&
  188. 'bg-red-50 text-red-700 border border-red-200 dark:bg-red-950/50 dark:text-red-400 dark:border-red-800',
  189. )}
  190. >
  191. <div className="flex items-start gap-2 min-w-0">
  192. {testStatus === 'success' && <CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" />}
  193. {testStatus === 'error' && <XCircle className="h-4 w-4 mt-0.5 shrink-0" />}
  194. <p className="flex-1 min-w-0 break-all">{testMessage}</p>
  195. </div>
  196. </div>
  197. )}
  198. </div>
  199. {/* Base URL */}
  200. <div className="space-y-2">
  201. <Label>Base URL</Label>
  202. <Input
  203. name={`video-base-url-${selectedProviderId}`}
  204. type="url"
  205. autoComplete="off"
  206. autoCapitalize="none"
  207. autoCorrect="off"
  208. spellCheck={false}
  209. value={currentConfig?.baseUrl || ''}
  210. onChange={(e) => handleBaseUrlChange(e.target.value)}
  211. placeholder={
  212. currentConfig?.serverBaseUrl ||
  213. currentProvider?.defaultBaseUrl ||
  214. t('settings.enterCustomBaseUrl')
  215. }
  216. className="h-8"
  217. />
  218. {(() => {
  219. const effectiveBaseUrl =
  220. currentConfig?.baseUrl ||
  221. currentConfig?.serverBaseUrl ||
  222. currentProvider?.defaultBaseUrl ||
  223. '';
  224. if (!effectiveBaseUrl) return null;
  225. return (
  226. <p className="text-xs text-muted-foreground break-all">
  227. {t('settings.requestUrl')}: {effectiveBaseUrl}
  228. </p>
  229. );
  230. })()}
  231. </div>
  232. {/* Model list */}
  233. <div className="space-y-3">
  234. <div className="flex items-center justify-between flex-wrap gap-2">
  235. <Label className="text-base">{t('settings.models')}</Label>
  236. <Button variant="outline" size="sm" onClick={handleOpenAddModel} className="gap-1.5">
  237. <Plus className="h-3.5 w-3.5" />
  238. {t('settings.addNewModel')}
  239. </Button>
  240. </div>
  241. <div className="space-y-1.5">
  242. {/* Built-in models */}
  243. {builtInModels.map((model) => (
  244. <div
  245. key={model.id}
  246. className="flex items-center justify-between p-3 rounded-lg border border-border/50 bg-card"
  247. >
  248. <div className="flex-1 min-w-0">
  249. <div className="font-mono text-sm font-medium">{model.name}</div>
  250. <div className="text-xs text-muted-foreground font-mono mt-0.5">{model.id}</div>
  251. </div>
  252. </div>
  253. ))}
  254. {/* Custom models */}
  255. {customModels.map((model, index) => (
  256. <div
  257. key={`custom-${index}`}
  258. className="flex items-center justify-between p-3 rounded-lg border border-border/50 bg-card"
  259. >
  260. <div className="flex-1 min-w-0">
  261. <div className="font-mono text-sm font-medium">{model.name}</div>
  262. <div className="text-xs text-muted-foreground font-mono mt-0.5">{model.id}</div>
  263. </div>
  264. <div className="flex items-center gap-1">
  265. <Button
  266. variant="outline"
  267. size="sm"
  268. className="h-8 px-2"
  269. onClick={() => handleOpenEditModel(index)}
  270. title={t('settings.editModel')}
  271. >
  272. <Settings2 className="h-3.5 w-3.5" />
  273. </Button>
  274. <Button
  275. variant="outline"
  276. size="sm"
  277. className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
  278. onClick={() => handleDeleteModel(index)}
  279. title={t('settings.deleteModel')}
  280. >
  281. <Trash2 className="h-3.5 w-3.5" />
  282. </Button>
  283. </div>
  284. </div>
  285. ))}
  286. </div>
  287. </div>
  288. {/* Add/Edit Model Dialog */}
  289. <Dialog open={showModelDialog} onOpenChange={setShowModelDialog}>
  290. <DialogContent className="sm:max-w-md">
  291. <DialogTitle>
  292. {editingModelIndex !== null ? t('settings.editModel') : t('settings.addNewModel')}
  293. </DialogTitle>
  294. <DialogDescription className="sr-only">
  295. {editingModelIndex !== null ? t('settings.editModel') : t('settings.addNewModel')}
  296. </DialogDescription>
  297. <div className="space-y-4 pt-2">
  298. <div className="space-y-2">
  299. <Label>{t('settings.modelId')}</Label>
  300. <Input
  301. value={modelForm.id}
  302. onChange={(e) => setModelForm((prev) => ({ ...prev, id: e.target.value }))}
  303. placeholder="e.g. my-custom-model-v1"
  304. className="h-8 font-mono text-sm"
  305. />
  306. </div>
  307. <div className="space-y-2">
  308. <Label>{t('settings.modelName')}</Label>
  309. <Input
  310. value={modelForm.name}
  311. onChange={(e) => setModelForm((prev) => ({ ...prev, name: e.target.value }))}
  312. placeholder="e.g. My Custom Model"
  313. className="h-8 text-sm"
  314. />
  315. </div>
  316. <div className="flex justify-end gap-2">
  317. <Button variant="outline" size="sm" onClick={() => setShowModelDialog(false)}>
  318. {t('settings.cancelEdit')}
  319. </Button>
  320. <Button size="sm" onClick={handleSaveModel} disabled={!modelForm.id.trim()}>
  321. {t('settings.saveModel')}
  322. </Button>
  323. </div>
  324. </div>
  325. </DialogContent>
  326. </Dialog>
  327. </div>
  328. );
  329. }