SettingsModal.tsx 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
  1. import React, { useState, useEffect } from 'react';
  2. import { ModelConfig, ModelType } from '../types';
  3. import { useLanguage } from '../contexts/LanguageContext';
  4. import { X, Plus, Trash2, Edit2, Save, Cpu, Box, Loader2, User, Shield, Key, LogOut, Globe, Settings as SettingsIcon, Star } from 'lucide-react';
  5. import { userService } from '../services/userService';
  6. import { settingsService } from '../services/settingsService';
  7. import { userSettingService } from '../services/userSettingService';
  8. import { knowledgeGroupService } from '../services/knowledgeGroupService';
  9. import { modelConfigService } from '../services/modelConfigService';
  10. import { AppSettings, KnowledgeGroup } from '../types';
  11. interface SettingsModalProps {
  12. isOpen: boolean;
  13. onClose: () => void;
  14. // Model Props
  15. models: ModelConfig[];
  16. authToken: string | null;
  17. onUpdateModels: (action: 'create' | 'update' | 'delete', model: ModelConfig) => Promise<void>;
  18. onLogout: () => void;
  19. }
  20. type TabType = 'general' | 'user' | 'model';
  21. export const SettingsModal: React.FC<SettingsModalProps> = ({
  22. isOpen,
  23. onClose,
  24. models,
  25. authToken,
  26. onUpdateModels,
  27. onLogout
  28. }) => {
  29. const { t, language, setLanguage } = useLanguage();
  30. const [activeTab, setActiveTab] = useState<TabType>('general');
  31. const [isLoading, setIsLoading] = useState(false);
  32. const [error, setError] = useState<string | null>(null);
  33. // --- Model Manager State ---
  34. const [editingId, setEditingId] = useState<string | null>(null);
  35. const [modelFormData, setModelFormData] = useState<Partial<ModelConfig>>({
  36. type: ModelType.LLM,
  37. supportsVision: false,
  38. baseUrl: '',
  39. apiKey: '',
  40. modelId: '',
  41. name: '',
  42. dimensions: 1536,
  43. maxInputTokens: 8191,
  44. maxBatchSize: 2048
  45. });
  46. const [skipValidation, setSkipValidation] = useState(false);
  47. // --- User Management State ---
  48. interface UserType {
  49. id: string;
  50. username: string;
  51. isAdmin: boolean;
  52. createdAt: string;
  53. }
  54. const [users, setUsers] = useState<UserType[]>([]);
  55. const [isUserLoading, setIsUserLoading] = useState(false);
  56. const [showAddUser, setShowAddUser] = useState(false);
  57. const [newUser, setNewUser] = useState({ username: '', password: '' });
  58. const [userSuccess, setUserSuccess] = useState('');
  59. // --- Change Password State ---
  60. const [passwordForm, setPasswordForm] = useState({ current: '', new: '', confirm: '' });
  61. const [passwordSuccess, setPasswordSuccess] = useState('');
  62. // --- App Settings State ---
  63. const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
  64. const [knowledgeGroups, setKnowledgeGroups] = useState<KnowledgeGroup[]>([]);
  65. const [isSettingsLoading, setIsSettingsLoading] = useState(false);
  66. // Reset state on open
  67. useEffect(() => {
  68. if (isOpen) {
  69. setActiveTab('general');
  70. setError(null);
  71. setEditingId(null);
  72. }
  73. }, [isOpen]);
  74. // Fetch Users when User tab is active
  75. useEffect(() => {
  76. if (isOpen) {
  77. if (activeTab === 'user') {
  78. fetchUsers();
  79. } else if (activeTab === 'general') {
  80. fetchSettingsAndGroups();
  81. }
  82. }
  83. }, [isOpen, activeTab]);
  84. const fetchSettingsAndGroups = async () => {
  85. if (!authToken) return;
  86. setIsSettingsLoading(true);
  87. try {
  88. const [settings, groups] = await Promise.all([
  89. userSettingService.get(authToken),
  90. knowledgeGroupService.getGroups()
  91. ]);
  92. setAppSettings(settings);
  93. setKnowledgeGroups(groups);
  94. } catch (error) {
  95. console.error('Failed to fetch settings or groups:', error);
  96. } finally {
  97. setIsSettingsLoading(false);
  98. }
  99. };
  100. if (!isOpen) return null;
  101. // --- General Tab Handlers ---
  102. const handleLanguageChange = async (newLanguage: string) => {
  103. setIsLoading(true);
  104. try {
  105. await settingsService.updateLanguage(newLanguage);
  106. setLanguage(newLanguage as any);
  107. } catch (error) {
  108. console.error('Failed to update language:', error);
  109. } finally {
  110. setIsLoading(false);
  111. }
  112. };
  113. const handleChangePassword = async (e: React.FormEvent) => {
  114. e.preventDefault();
  115. setError(null);
  116. setPasswordSuccess('');
  117. if (passwordForm.new !== passwordForm.confirm) {
  118. setError(t('passwordMismatch'));
  119. return;
  120. }
  121. if (passwordForm.new.length < 6) {
  122. setError(t('newPasswordMinLength'));
  123. return;
  124. }
  125. setIsLoading(true);
  126. try {
  127. await userService.changePassword(passwordForm.current, passwordForm.new);
  128. setPasswordSuccess(t('passwordChangeSuccess') || '密码修改成功');
  129. setPasswordForm({ current: '', new: '', confirm: '' });
  130. } catch (err: any) {
  131. setError(err.message || t('passwordChangeFailed'));
  132. } finally {
  133. setIsLoading(false);
  134. }
  135. };
  136. // --- User Tab Handlers ---
  137. const fetchUsers = async () => {
  138. setIsUserLoading(true);
  139. try {
  140. const userList = await userService.getUsers();
  141. setUsers(userList);
  142. } catch (error: any) {
  143. setError(error.message || t('getUserListFailed'));
  144. } finally {
  145. setIsUserLoading(false);
  146. }
  147. };
  148. const handleCreateUser = async (e: React.FormEvent) => {
  149. e.preventDefault();
  150. setError('');
  151. setUserSuccess('');
  152. if (newUser.password.length < 6) {
  153. setError(t('passwordMinLength'));
  154. return;
  155. }
  156. try {
  157. await userService.createUser(newUser.username, newUser.password);
  158. setUserSuccess(t('userCreatedSuccess'));
  159. setNewUser({ username: '', password: '' });
  160. setShowAddUser(false);
  161. fetchUsers();
  162. } catch (error: any) {
  163. setError(error.message || t('createUserFailed'));
  164. }
  165. };
  166. // --- Model Tab Handlers ---
  167. const validateApiKey = async (config: Partial<ModelConfig>) => {
  168. try {
  169. let apiUrl = config.baseUrl || '';
  170. let body = {};
  171. if (config.type === ModelType.LLM) {
  172. apiUrl = apiUrl.endsWith('/chat/completions') ? apiUrl : `${apiUrl.replace(/\/+$/, '')}/chat/completions`;
  173. body = { model: config.modelId, messages: [{ role: 'user', content: 'Hi' }], max_tokens: 1 };
  174. } else if (config.type === ModelType.RERANK) {
  175. apiUrl = apiUrl.replace(/\/+$/, '');
  176. // SiliconFlow API test body
  177. body = { model: config.modelId, query: 'test', documents: ['test'], top_n: 1 };
  178. } else {
  179. apiUrl = apiUrl.endsWith('/embeddings') ? apiUrl : `${apiUrl.replace(/\/+$/, '')}/embeddings`;
  180. body = { input: ['test'], model: config.modelId };
  181. }
  182. const response = await fetch(apiUrl, {
  183. method: 'POST',
  184. headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.apiKey}` },
  185. body: JSON.stringify(body),
  186. });
  187. if (!response.ok) {
  188. const errText = await response.text();
  189. throw new Error(`API Request Failed: ${response.status} ${response.statusText} - ${errText.substring(0, 100)}`);
  190. }
  191. } catch (error) {
  192. console.error('Validation Warning:', error);
  193. throw new Error(`验证请求失败: ${error.message}。可能是跨域(CORS)限制或地址错误。您可以勾选"跳过验证"强制保存。`);
  194. }
  195. };
  196. const handleSaveModel = async () => {
  197. if (!authToken) {
  198. setError(t('mmErrorNotAuthenticated'));
  199. return;
  200. }
  201. setError(null);
  202. if (!modelFormData.name?.trim()) { setError(t('mmErrorNameRequired')); return; }
  203. if (!modelFormData.modelId?.trim()) { setError(t('mmErrorModelIdRequired')); return; }
  204. if (!modelFormData.baseUrl?.trim()) { setError(t('mmErrorBaseUrlRequired')); return; }
  205. if (editingId === 'new' && !modelFormData.apiKey?.trim()) { setError(t('mmErrorApiKeyRequired')); return; }
  206. setIsLoading(true);
  207. try {
  208. if (modelFormData.apiKey?.trim() && !skipValidation) {
  209. await validateApiKey(modelFormData);
  210. }
  211. const saveData = { ...modelFormData } as ModelConfig;
  212. if (saveData.type !== ModelType.EMBEDDING) {
  213. delete saveData.dimensions;
  214. delete saveData.maxInputTokens;
  215. delete saveData.maxBatchSize;
  216. }
  217. if (editingId !== 'new' && !modelFormData.apiKey?.trim()) {
  218. delete saveData.apiKey;
  219. }
  220. await onUpdateModels(editingId === 'new' ? 'create' : 'update', saveData);
  221. setEditingId(null);
  222. } catch (err: any) {
  223. setError(err.message || t('errorGeneric'));
  224. } finally {
  225. setIsLoading(false);
  226. }
  227. };
  228. const handleDeleteModel = async (id: string) => {
  229. if (confirm(t('confirmClear'))) {
  230. await onUpdateModels('delete', { id } as ModelConfig);
  231. }
  232. };
  233. const handleSetDefault = async (id: string) => {
  234. if (!authToken) {
  235. setError(t('mmErrorNotAuthenticated'));
  236. return;
  237. }
  238. setIsLoading(true);
  239. try {
  240. await modelConfigService.setDefault(authToken, id);
  241. // モデル一覧を再取得するためにページをリロード
  242. window.location.reload();
  243. } catch (err: any) {
  244. setError(err.message || 'デフォルト設定に失敗しました');
  245. } finally {
  246. setIsLoading(false);
  247. }
  248. };
  249. const getTypeLabel = (type: ModelType) => {
  250. switch (type) {
  251. case ModelType.LLM: return t('typeLLM');
  252. case ModelType.EMBEDDING: return t('typeEmbedding');
  253. case ModelType.RERANK: return t('typeRerank');
  254. }
  255. };
  256. // --- Render Functions ---
  257. const renderGeneralTab = () => (
  258. <div className="space-y-8 animate-in slide-in-from-right duration-300">
  259. {/* 言語セクション */}
  260. <section>
  261. <h3 className="text-sm font-medium text-slate-500 mb-3 flex items-center gap-2">
  262. <Globe className="w-4 h-4" />
  263. {t('languageSettings')}
  264. </h3>
  265. <div className="flex gap-2">
  266. {(['zh', 'en', 'ja'] as const).map((lang) => (
  267. <button
  268. key={lang}
  269. onClick={() => handleLanguageChange(lang)}
  270. disabled={isLoading}
  271. className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors border ${language === lang
  272. ? 'bg-blue-50 border-blue-200 text-blue-700'
  273. : 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
  274. }`}
  275. >
  276. {lang === 'zh' ? '中文' : lang === 'en' ? 'English' : '日本語'}
  277. </button>
  278. ))}
  279. </div>
  280. </section>
  281. {/* Change Password Section */}
  282. <section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
  283. <h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
  284. <Key className="w-4 h-4 text-blue-500" />
  285. {t('changePassword') || '修改密码'}
  286. </h3>
  287. <form onSubmit={handleChangePassword} className="space-y-4 max-w-sm">
  288. <div>
  289. <input
  290. type="password"
  291. placeholder={t('currentPassword')}
  292. value={passwordForm.current}
  293. onChange={e => setPasswordForm({ ...passwordForm, current: e.target.value })}
  294. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  295. required
  296. />
  297. </div>
  298. <div>
  299. <input
  300. type="password"
  301. placeholder={t('newPassword')}
  302. value={passwordForm.new}
  303. onChange={e => setPasswordForm({ ...passwordForm, new: e.target.value })}
  304. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  305. required
  306. />
  307. </div>
  308. <div>
  309. <input
  310. type="password"
  311. placeholder={t('confirmPassword')}
  312. value={passwordForm.confirm}
  313. onChange={e => setPasswordForm({ ...passwordForm, confirm: e.target.value })}
  314. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  315. required
  316. />
  317. </div>
  318. {passwordSuccess && <p className="text-xs text-green-600">{passwordSuccess}</p>}
  319. <button
  320. type="submit"
  321. disabled={isLoading}
  322. className="w-full py-2 bg-slate-800 text-white rounded-md text-sm hover:bg-slate-900 transition-colors disabled:opacity-50"
  323. >
  324. {isLoading ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> : (t('confirmChange') || '确认修改')}
  325. </button>
  326. </form>
  327. </section>
  328. {/* Logout Section */}
  329. <section className="pt-4 border-t border-slate-200">
  330. <button
  331. onClick={onLogout}
  332. className="flex items-center gap-2 text-red-600 hover:bg-red-50 px-4 py-2 rounded-lg transition-colors text-sm font-medium"
  333. >
  334. <LogOut className="w-4 h-4" />
  335. {t('logout') || '退出登录'}
  336. </button>
  337. </section>
  338. </div>
  339. );
  340. const renderUserTab = () => (
  341. <div className="space-y-4 animate-in slide-in-from-right duration-300">
  342. <div className="flex justify-between items-center mb-4">
  343. <h3 className="font-medium text-slate-700">{t('userList')}</h3>
  344. <button
  345. onClick={() => setShowAddUser(!showAddUser)}
  346. className="flex items-center gap-1 text-sm bg-blue-600 text-white px-3 py-1.5 rounded-lg hover:bg-blue-700 transition-colors"
  347. >
  348. <Plus className="w-4 h-4" />
  349. {t('addUser')}
  350. </button>
  351. </div>
  352. {showAddUser && (
  353. <form onSubmit={handleCreateUser} className="bg-slate-50 p-4 rounded-lg border border-slate-200 mb-4 space-y-3">
  354. <input
  355. type="text"
  356. placeholder={t('username')}
  357. value={newUser.username}
  358. onChange={e => setNewUser({ ...newUser, username: e.target.value })}
  359. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md"
  360. required
  361. />
  362. <input
  363. type="password"
  364. placeholder={t('password')}
  365. value={newUser.password}
  366. onChange={e => setNewUser({ ...newUser, password: e.target.value })}
  367. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md"
  368. required
  369. />
  370. <div className="flex justify-end gap-2">
  371. <button type="button" onClick={() => setShowAddUser(false)} className="text-xs text-slate-500 hover:text-slate-700 px-2 py-1">{t('cancel')}</button>
  372. <button type="submit" className="text-xs bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700">{t('create')}</button>
  373. </div>
  374. </form>
  375. )}
  376. {userSuccess && <p className="text-xs text-green-600 mb-2">{userSuccess}</p>}
  377. <div className="space-y-2 max-h-[60vh] overflow-y-auto">
  378. {users.map(user => (
  379. <div key={user.id} className="flex items-center justify-between p-3 bg-white border border-slate-200 rounded-lg">
  380. <div className="flex items-center gap-3">
  381. <div className={`p-2 rounded-full ${user.isAdmin ? 'bg-orange-100 text-orange-600' : 'bg-slate-100 text-slate-600'}`}>
  382. {user.isAdmin ? <Shield className="w-4 h-4" /> : <User className="w-4 h-4" />}
  383. </div>
  384. <div>
  385. <p className="text-sm font-medium text-slate-800">{user.username}</p>
  386. <p className="text-xs text-slate-400">{new Date(user.createdAt).toLocaleDateString()}</p>
  387. </div>
  388. </div>
  389. <span className="text-xs text-slate-400 bg-slate-50 px-2 py-1 rounded">
  390. {user.isAdmin ? t('admin') : t('user')}
  391. </span>
  392. </div>
  393. ))}
  394. </div>
  395. </div>
  396. );
  397. const renderModelTab = () => (
  398. <div className="animate-in slide-in-from-right duration-300">
  399. {editingId ? (
  400. <div className="bg-white p-6 rounded-lg border border-blue-100 shadow-sm space-y-4">
  401. <h3 className="font-semibold text-slate-700 mb-4">{editingId === 'new' ? t('mmAddBtn') : t('mmEdit')}</h3>
  402. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  403. <div>
  404. <label className="block text-xs font-medium text-slate-500 mb-1">{t('mmFormName')} *</label>
  405. <input className="w-full text-sm border rounded-md px-3 py-2" value={modelFormData.name} onChange={e => setModelFormData({ ...modelFormData, name: e.target.value })} disabled={isLoading} />
  406. </div>
  407. <div>
  408. <label className="block text-xs font-medium text-slate-500 mb-1">{t('mmFormModelId')} *</label>
  409. <input className="w-full text-sm border rounded-md px-3 py-2 font-mono" value={modelFormData.modelId} onChange={e => setModelFormData({ ...modelFormData, modelId: e.target.value })} disabled={isLoading} />
  410. </div>
  411. </div>
  412. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  413. <div>
  414. <label className="block text-xs font-medium text-slate-500 mb-1">{t('mmFormType')} *</label>
  415. <select className="w-full text-sm border rounded-md px-3 py-2" value={modelFormData.type} onChange={e => setModelFormData({ ...modelFormData, type: e.target.value as ModelType })} disabled={isLoading}>
  416. <option value={ModelType.LLM}>{t('typeLLM')}</option>
  417. <option value={ModelType.EMBEDDING}>{t('typeEmbedding')}</option>
  418. <option value={ModelType.RERANK}>{t('typeRerank')}</option>
  419. </select>
  420. {(modelFormData.type === ModelType.LLM || String(modelFormData.type).toLowerCase() === 'llm') && (
  421. <div className="mt-2 flex items-center gap-2 p-2 bg-blue-50/50 rounded-md border border-blue-100/50">
  422. <input
  423. type="checkbox"
  424. id="supportsVision"
  425. className="w-4 h-4 text-blue-600 rounded cursor-pointer"
  426. checked={modelFormData.supportsVision}
  427. onChange={e => setModelFormData({ ...modelFormData, supportsVision: e.target.checked })}
  428. />
  429. <label htmlFor="supportsVision" className="text-sm text-blue-700 font-medium cursor-pointer select-none">
  430. {t('mmFormVision') || '支持视觉功能 (Vision Support)'}
  431. </label>
  432. </div>
  433. )}
  434. </div>
  435. </div>
  436. <div>
  437. <label className="block text-xs font-medium text-slate-500 mb-1">{t('mmFormBaseUrl')} *</label>
  438. <input className="w-full text-sm border rounded-md px-3 py-2 font-mono" value={modelFormData.baseUrl} onChange={e => setModelFormData({ ...modelFormData, baseUrl: e.target.value })} disabled={isLoading} autoComplete="off" />
  439. </div>
  440. <div>
  441. <label className="block text-xs font-medium text-slate-500 mb-1">{t('mmFormApiKey')} {editingId === 'new' && '*'}</label>
  442. <input type="password" className="w-full text-sm border rounded-md px-3 py-2 font-mono" value={modelFormData.apiKey} onChange={e => setModelFormData({ ...modelFormData, apiKey: e.target.value })} placeholder={editingId === 'new' ? '' : t('leaveEmptyNoChange')} disabled={isLoading} autoComplete="new-password" />
  443. <div className="mt-2">
  444. <div className="flex items-center gap-2">
  445. <input type="checkbox" id="skipVal" checked={skipValidation} onChange={e => setSkipValidation(e.target.checked)} />
  446. <label htmlFor="skipVal" className="text-xs text-slate-500">{t('skipApiValidation')}</label>
  447. </div>
  448. </div>
  449. </div>
  450. {modelFormData.type === ModelType.EMBEDDING && (
  451. <div className="grid grid-cols-2 gap-4">
  452. <div>
  453. <label className="block text-xs font-medium text-slate-500 mb-1">Max Input</label>
  454. <input type="number" className="w-full text-sm border rounded px-3 py-2" value={modelFormData.maxInputTokens || 8191} onChange={e => setModelFormData({ ...modelFormData, maxInputTokens: parseInt(e.target.value) })} />
  455. </div>
  456. <div>
  457. <label className="block text-xs font-medium text-slate-500 mb-1">Dimensions</label>
  458. <input type="number" className="w-full text-sm border rounded px-3 py-2" value={modelFormData.dimensions || 1536} onChange={e => setModelFormData({ ...modelFormData, dimensions: parseInt(e.target.value) })} />
  459. </div>
  460. </div>
  461. )}
  462. <div className="flex justify-end gap-2 pt-2">
  463. <button onClick={() => { setEditingId(null); setError(null); }} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-lg">{t('mmCancel')}</button>
  464. <button onClick={handleSaveModel} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg flex items-center gap-2" disabled={isLoading}>
  465. {isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
  466. {t('mmSave')}
  467. </button>
  468. </div>
  469. </div>
  470. ) : (
  471. <div className="space-y-3">
  472. {models.map(model => (
  473. <div key={model.id} className="bg-white border border-slate-200 rounded-lg p-3 flex justify-between items-start">
  474. <div className="flex gap-3 flex-1">
  475. <div className="w-10 h-10 rounded-lg bg-emerald-50 text-emerald-600 flex items-center justify-center"><Cpu className="w-5 h-5" /></div>
  476. <div className="flex-1">
  477. <div className="flex items-center gap-2">
  478. <h4 className="font-semibold text-sm text-slate-800">{model.name}</h4>
  479. {model.isDefault && (
  480. <span className="inline-flex items-center gap-1 px-2 py-0.5 bg-amber-50 text-amber-700 text-xs font-medium rounded-full border border-amber-200">
  481. <Star className="w-3 h-3 fill-amber-500 text-amber-500" />
  482. デフォルト
  483. </span>
  484. )}
  485. </div>
  486. <div className="flex gap-2 text-xs text-slate-500">
  487. <span className="bg-slate-100 px-1 rounded">{getTypeLabel(model.type)}</span>
  488. <span className="font-mono">{model.modelId}</span>
  489. </div>
  490. </div>
  491. </div>
  492. <div className="flex gap-1">
  493. {!model.isDefault && (
  494. <button
  495. onClick={() => handleSetDefault(model.id)}
  496. className="p-2 text-slate-400 hover:text-amber-600 transition-colors"
  497. title="デフォルトに設定"
  498. disabled={isLoading}
  499. >
  500. <Star className="w-4 h-4" />
  501. </button>
  502. )}
  503. <button onClick={() => { setEditingId(model.id); setModelFormData({ ...model, apiKey: '' }); }} className="p-2 text-slate-400 hover:text-blue-600"><Edit2 className="w-4 h-4" /></button>
  504. <button onClick={() => handleDeleteModel(model.id)} className="p-2 text-slate-400 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
  505. </div>
  506. </div>
  507. ))}
  508. <button onClick={() => { setEditingId('new'); setModelFormData({ type: ModelType.LLM, supportsVision: false, baseUrl: '', apiKey: '', modelId: '', name: '', dimensions: 1536 }); }} className="w-full py-3 border-2 border-dashed border-slate-200 rounded-lg text-slate-500 hover:border-blue-400 hover:text-blue-600 flex justify-center gap-2 items-center">
  509. <Plus className="w-5 h-5" /> {t('mmAddBtn')}
  510. </button>
  511. </div>
  512. )}
  513. </div>
  514. );
  515. return (
  516. <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200">
  517. <div className="bg-white rounded-xl shadow-2xl w-full max-w-4xl h-[80vh] flex overflow-hidden">
  518. {/* サイドバー */}
  519. <div className="w-64 bg-slate-50 border-r border-slate-200 flex flex-col">
  520. <div className="p-6">
  521. <h2 className="text-xl font-bold text-slate-800 flex items-center gap-2">
  522. <SettingsIcon className="w-6 h-6 text-blue-600" />
  523. {t('settings')}
  524. </h2>
  525. </div>
  526. <nav className="flex-1 px-4 space-y-1">
  527. <button onClick={() => setActiveTab('general')} className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${activeTab === 'general' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-600 hover:bg-slate-100'}`}>
  528. <Globe className="w-5 h-5" /> {t('generalSettings')}
  529. </button>
  530. <button onClick={() => setActiveTab('user')} className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${activeTab === 'user' ? 'bg-white text-purple-600 shadow-sm' : 'text-slate-600 hover:bg-slate-100'}`}>
  531. <User className="w-5 h-5" /> {t('userManagement')}
  532. </button>
  533. <button onClick={() => setActiveTab('model')} className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${activeTab === 'model' ? 'bg-white text-emerald-600 shadow-sm' : 'text-slate-600 hover:bg-slate-100'}`}>
  534. <Cpu className="w-5 h-5" /> {t('modelManagement')}
  535. </button>
  536. </nav>
  537. </div>
  538. {/* コンテンツエリア */}
  539. <div className="flex-1 flex flex-col min-w-0">
  540. <div className="flex items-center justify-between p-4 border-b border-slate-100">
  541. <h3 className="text-lg font-semibold text-slate-800">
  542. {activeTab === 'general' ? t('generalSettings') : activeTab === 'user' ? t('userManagement') : t('modelManagement')}
  543. </h3>
  544. <button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-full transition-colors"><X className="w-5 h-5 text-slate-500" /></button>
  545. </div>
  546. <div className="flex-1 overflow-y-auto p-6 bg-white">
  547. {error && (
  548. <div className="mb-4 p-4 bg-red-50 text-red-700 rounded-lg flex gap-2 items-start text-sm">
  549. <span className="font-bold">Error:</span> {error}
  550. </div>
  551. )}
  552. {activeTab === 'general' && renderGeneralTab()}
  553. {activeTab === 'user' && renderUserTab()}
  554. {activeTab === 'model' && renderModelTab()}
  555. </div>
  556. </div>
  557. </div>
  558. </div>
  559. );
  560. };