image-settings.tsx 13 KB

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