model-edit-dialog.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. 'use client';
  2. import { useState, useCallback, useEffect } from 'react';
  3. import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
  4. import { Button } from '@/components/ui/button';
  5. import { Input } from '@/components/ui/input';
  6. import { Label } from '@/components/ui/label';
  7. import { Checkbox } from '@/components/ui/checkbox';
  8. import { Sparkles, Wrench, Zap, Loader2, CheckCircle, XCircle } from 'lucide-react';
  9. import { useI18n } from '@/lib/hooks/use-i18n';
  10. import type { EditingModel } from '@/lib/types/settings';
  11. import type { ProviderId } from '@/lib/ai/providers';
  12. import { cn } from '@/lib/utils';
  13. interface ModelEditDialogProps {
  14. open: boolean;
  15. onOpenChange: (open: boolean) => void;
  16. editingModel: EditingModel | null;
  17. setEditingModel: (model: EditingModel | null) => void;
  18. onSave: () => void;
  19. onAutoSave?: () => void; // Auto-save on blur
  20. providerId: ProviderId;
  21. apiKey: string;
  22. baseUrl?: string;
  23. providerType?: string;
  24. requiresApiKey?: boolean;
  25. isServerConfigured?: boolean;
  26. }
  27. export function ModelEditDialog({
  28. open,
  29. onOpenChange,
  30. editingModel,
  31. setEditingModel,
  32. onSave,
  33. onAutoSave,
  34. providerId,
  35. apiKey,
  36. baseUrl,
  37. providerType,
  38. requiresApiKey,
  39. isServerConfigured,
  40. }: ModelEditDialogProps) {
  41. const { t } = useI18n();
  42. const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
  43. const [testMessage, setTestMessage] = useState('');
  44. // Reset test status when dialog closes
  45. useEffect(() => {
  46. if (!open) {
  47. // eslint-disable-next-line react-hooks/set-state-in-effect -- Reset state when dialog closes
  48. setTestStatus('idle');
  49. setTestMessage('');
  50. }
  51. }, [open]);
  52. const handleClose = () => {
  53. onOpenChange(false);
  54. setEditingModel(null);
  55. };
  56. const handleTestModel = useCallback(async () => {
  57. if (!editingModel) {
  58. return;
  59. }
  60. setTestStatus('testing');
  61. setTestMessage('');
  62. try {
  63. const response = await fetch('/api/verify-model', {
  64. method: 'POST',
  65. headers: { 'Content-Type': 'application/json' },
  66. body: JSON.stringify({
  67. apiKey,
  68. baseUrl,
  69. model: `${providerId}:${editingModel.model.id}`,
  70. providerType,
  71. requiresApiKey,
  72. }),
  73. });
  74. const data = await response.json();
  75. if (data.success) {
  76. setTestStatus('success');
  77. setTestMessage(t('settings.connectionSuccess'));
  78. } else {
  79. setTestStatus('error');
  80. setTestMessage(data.error || t('settings.connectionFailed'));
  81. }
  82. } catch (_error) {
  83. setTestStatus('error');
  84. setTestMessage(t('settings.connectionFailed'));
  85. }
  86. }, [editingModel, apiKey, baseUrl, providerId, providerType, requiresApiKey, t]);
  87. if (!editingModel) return null;
  88. return (
  89. <Dialog open={open} onOpenChange={onOpenChange}>
  90. <DialogContent className="sm:max-w-[500px]">
  91. <DialogTitle className="sr-only">
  92. {editingModel.modelIndex === null ? t('settings.addNewModel') : t('settings.editModel')}
  93. </DialogTitle>
  94. <DialogDescription className="sr-only">
  95. {editingModel.modelIndex === null
  96. ? t('settings.addNewModelDescription')
  97. : t('settings.editModelDescription')}
  98. </DialogDescription>
  99. <div className="space-y-4">
  100. <div className="pb-3 border-b">
  101. <h2 className="text-lg font-semibold">
  102. {editingModel.modelIndex === null
  103. ? t('settings.addNewModel')
  104. : t('settings.editModel')}
  105. </h2>
  106. </div>
  107. {/* Model ID */}
  108. <div className="space-y-2">
  109. <Label>{t('settings.modelId')}</Label>
  110. <Input
  111. placeholder={t('settings.modelIdPlaceholder')}
  112. value={editingModel.model.id}
  113. onChange={(e) => {
  114. const newId = e.target.value;
  115. const currentName = editingModel.model.name;
  116. const currentId = editingModel.model.id;
  117. // Auto-sync name if it's empty or matches the old ID
  118. const shouldSyncName = !currentName || currentName === currentId;
  119. setEditingModel({
  120. ...editingModel,
  121. model: {
  122. ...editingModel.model,
  123. id: newId,
  124. name: shouldSyncName ? newId : currentName,
  125. },
  126. });
  127. // Reset test status when model ID changes
  128. setTestStatus('idle');
  129. setTestMessage('');
  130. }}
  131. onBlur={() => onAutoSave?.()}
  132. />
  133. </div>
  134. {/* Display Name */}
  135. <div className="space-y-2">
  136. <Label>{t('settings.modelName')}</Label>
  137. <Input
  138. placeholder={t('settings.modelNamePlaceholder')}
  139. value={editingModel.model.name}
  140. onChange={(e) =>
  141. setEditingModel({
  142. ...editingModel,
  143. model: { ...editingModel.model, name: e.target.value },
  144. })
  145. }
  146. onBlur={() => onAutoSave?.()}
  147. />
  148. </div>
  149. {/* Capabilities */}
  150. <div className="space-y-2">
  151. <Label>{t('settings.modelCapabilities')}</Label>
  152. <div className="flex gap-4">
  153. <div className="flex items-center space-x-2">
  154. <Checkbox
  155. id="cap-vision"
  156. checked={editingModel.model.capabilities?.vision || false}
  157. onCheckedChange={(checked) => {
  158. setEditingModel({
  159. ...editingModel,
  160. model: {
  161. ...editingModel.model,
  162. capabilities: {
  163. ...editingModel.model.capabilities,
  164. vision: checked as boolean,
  165. },
  166. },
  167. });
  168. onAutoSave?.();
  169. }}
  170. />
  171. <label
  172. htmlFor="cap-vision"
  173. className="text-sm flex items-center gap-1.5 cursor-pointer"
  174. >
  175. <Sparkles className="h-3.5 w-3.5" />
  176. {t('settings.capabilities.vision')}
  177. </label>
  178. </div>
  179. <div className="flex items-center space-x-2">
  180. <Checkbox
  181. id="cap-tools"
  182. checked={editingModel.model.capabilities?.tools || false}
  183. onCheckedChange={(checked) => {
  184. setEditingModel({
  185. ...editingModel,
  186. model: {
  187. ...editingModel.model,
  188. capabilities: {
  189. ...editingModel.model.capabilities,
  190. tools: checked as boolean,
  191. },
  192. },
  193. });
  194. onAutoSave?.();
  195. }}
  196. />
  197. <label
  198. htmlFor="cap-tools"
  199. className="text-sm flex items-center gap-1.5 cursor-pointer"
  200. >
  201. <Wrench className="h-3.5 w-3.5" />
  202. {t('settings.capabilities.tools')}
  203. </label>
  204. </div>
  205. <div className="flex items-center space-x-2">
  206. <Checkbox
  207. id="cap-streaming"
  208. checked={editingModel.model.capabilities?.streaming || false}
  209. onCheckedChange={(checked) => {
  210. setEditingModel({
  211. ...editingModel,
  212. model: {
  213. ...editingModel.model,
  214. capabilities: {
  215. ...editingModel.model.capabilities,
  216. streaming: checked as boolean,
  217. },
  218. },
  219. });
  220. onAutoSave?.();
  221. }}
  222. />
  223. <label
  224. htmlFor="cap-streaming"
  225. className="text-sm flex items-center gap-1.5 cursor-pointer"
  226. >
  227. <Zap className="h-3.5 w-3.5" />
  228. {t('settings.capabilities.streaming')}
  229. </label>
  230. </div>
  231. </div>
  232. </div>
  233. {/* Advanced Settings */}
  234. <div className="space-y-3 pt-3 border-t">
  235. <Label className="text-base">{t('settings.advancedSettings')}</Label>
  236. <div className="grid grid-cols-2 gap-3">
  237. <div className="space-y-2">
  238. <Label className="text-sm">{t('settings.contextWindowLabel')}</Label>
  239. <Input
  240. type="number"
  241. placeholder={t('settings.contextWindowPlaceholder')}
  242. value={editingModel.model.contextWindow || ''}
  243. onChange={(e) =>
  244. setEditingModel({
  245. ...editingModel,
  246. model: {
  247. ...editingModel.model,
  248. contextWindow: e.target.value ? parseInt(e.target.value) : undefined,
  249. },
  250. })
  251. }
  252. onBlur={() => onAutoSave?.()}
  253. />
  254. </div>
  255. <div className="space-y-2">
  256. <Label className="text-sm">{t('settings.outputWindowLabel')}</Label>
  257. <Input
  258. type="number"
  259. placeholder={t('settings.outputWindowPlaceholder')}
  260. value={editingModel.model.outputWindow || ''}
  261. onChange={(e) =>
  262. setEditingModel({
  263. ...editingModel,
  264. model: {
  265. ...editingModel.model,
  266. outputWindow: e.target.value ? parseInt(e.target.value) : undefined,
  267. },
  268. })
  269. }
  270. onBlur={() => onAutoSave?.()}
  271. />
  272. </div>
  273. </div>
  274. </div>
  275. {/* Test Model */}
  276. <div className="space-y-3 pt-3 border-t">
  277. <div className="flex items-center justify-between">
  278. <Label className="text-base">{t('settings.testModel')}</Label>
  279. <Button
  280. variant="outline"
  281. size="sm"
  282. onClick={handleTestModel}
  283. disabled={
  284. !editingModel.model.id ||
  285. testStatus === 'testing' ||
  286. (requiresApiKey && !apiKey && !isServerConfigured)
  287. }
  288. className={cn(
  289. testStatus === 'success' && 'border-green-600 text-green-600 hover:bg-green-50',
  290. testStatus === 'error' && 'border-red-600 text-red-600 hover:bg-red-50',
  291. )}
  292. >
  293. {testStatus === 'testing' && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
  294. {testStatus === 'success' && <CheckCircle className="mr-2 h-4 w-4" />}
  295. {testStatus === 'error' && <XCircle className="mr-2 h-4 w-4" />}
  296. {testStatus === 'testing' ? t('settings.testing') : t('settings.testConnection')}
  297. </Button>
  298. </div>
  299. {testMessage && (
  300. <div
  301. className={cn(
  302. 'rounded-lg p-3 text-sm',
  303. testStatus === 'success' && 'bg-green-50 text-green-700 border border-green-200',
  304. testStatus === 'error' && 'bg-red-50 text-red-700 border border-red-200',
  305. )}
  306. >
  307. <div className="flex items-start gap-2 flex-wrap">
  308. {testStatus === 'success' && <CheckCircle className="h-4 w-4 mt-0.5 shrink-0" />}
  309. {testStatus === 'error' && <XCircle className="h-4 w-4 mt-0.5 shrink-0" />}
  310. <p className="flex-1 break-words">{testMessage}</p>
  311. </div>
  312. </div>
  313. )}
  314. </div>
  315. {/* Footer */}
  316. <div className="flex items-center justify-end gap-2 pt-3 border-t">
  317. <Button variant="outline" size="sm" onClick={handleClose}>
  318. {t('settings.cancelEdit')}
  319. </Button>
  320. <Button size="sm" onClick={onSave}>
  321. {t('settings.saveModel')}
  322. </Button>
  323. </div>
  324. </div>
  325. </DialogContent>
  326. </Dialog>
  327. );
  328. }