SettingsView.tsx 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683
  1. import React, { useState, useEffect } from 'react';
  2. import { ModelConfig, ModelType, AppSettings, KnowledgeGroup } 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, ToggleLeft, ToggleRight, Database } 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. interface SettingsViewProps {
  10. // Model Props
  11. models: ModelConfig[];
  12. authToken: string | null;
  13. onUpdateModels: (action: 'create' | 'update' | 'delete', model: ModelConfig) => Promise<void>;
  14. isAdmin?: boolean; // Added isAdmin prop
  15. currentUser?: any; // Added current user prop
  16. }
  17. type TabType = 'general' | 'user' | 'model';
  18. export const SettingsView: React.FC<SettingsViewProps> = ({
  19. models,
  20. authToken,
  21. onUpdateModels,
  22. isAdmin = false,
  23. currentUser,
  24. }) => {
  25. const { t } = useLanguage();
  26. const [activeTab, setActiveTab] = useState<TabType>('general');
  27. const [isLoading, setIsLoading] = useState(false);
  28. const [error, setError] = useState<string | null>(null);
  29. // --- Model Manager State ---
  30. const [editingId, setEditingId] = useState<string | null>(null);
  31. const [modelFormData, setModelFormData] = useState<Partial<ModelConfig>>({
  32. type: ModelType.LLM,
  33. supportsVision: false,
  34. baseUrl: '',
  35. apiKey: '',
  36. modelId: '',
  37. name: '',
  38. dimensions: 1536,
  39. maxInputTokens: 8191,
  40. maxBatchSize: 2048
  41. });
  42. const [skipValidation, setSkipValidation] = useState(false);
  43. // --- User Management State ---
  44. interface UserType {
  45. id: string;
  46. username: string;
  47. isAdmin: boolean;
  48. createdAt: string;
  49. }
  50. const [users, setUsers] = useState<UserType[]>([]);
  51. const [isUserLoading, setIsUserLoading] = useState(false);
  52. const [showAddUser, setShowAddUser] = useState(false);
  53. const [newUser, setNewUser] = useState({ username: '', password: '', isAdmin: false });
  54. const [userSuccess, setUserSuccess] = useState('');
  55. // --- Change Password State ---
  56. const [passwordForm, setPasswordForm] = useState({ current: '', new: '', confirm: '' });
  57. const [passwordSuccess, setPasswordSuccess] = useState('');
  58. // --- App Settings State ---
  59. const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
  60. const [knowledgeGroups, setKnowledgeGroups] = useState<KnowledgeGroup[]>([]);
  61. const [isSettingsLoading, setIsSettingsLoading] = useState(false);
  62. // ユーザー一覧の取得(ユーザータブがアクティブな場合)
  63. useEffect(() => {
  64. if (activeTab === 'user') {
  65. fetchUsers();
  66. } else if (activeTab === 'general') {
  67. fetchSettingsAndGroups();
  68. }
  69. }, [activeTab]); // 只依赖activeTab,不需要currentUser
  70. const fetchSettingsAndGroups = async () => {
  71. if (!authToken) return;
  72. setIsSettingsLoading(true);
  73. try {
  74. const [settings, groups] = await Promise.all([
  75. userSettingService.get(authToken),
  76. knowledgeGroupService.getGroups()
  77. ]);
  78. setAppSettings(settings);
  79. setKnowledgeGroups(groups);
  80. } catch (error) {
  81. console.error('Failed to fetch settings or groups:', error);
  82. } finally {
  83. setIsSettingsLoading(false);
  84. }
  85. };
  86. // --- 一般タブのハンドラー ---
  87. const handleChangePassword = async (e: React.FormEvent) => {
  88. e.preventDefault();
  89. setError(null);
  90. setPasswordSuccess('');
  91. if (passwordForm.new !== passwordForm.confirm) {
  92. setError(t('passwordMismatch'));
  93. return;
  94. }
  95. if (passwordForm.new.length < 6) {
  96. setError(t('newPasswordMinLength'));
  97. return;
  98. }
  99. setIsLoading(true);
  100. try {
  101. await userService.changePassword(passwordForm.current, passwordForm.new);
  102. setPasswordSuccess(t('passwordChangeSuccess'));
  103. setPasswordForm({ current: '', new: '', confirm: '' });
  104. } catch (err: any) {
  105. setError(err.message || t('passwordChangeFailed'));
  106. } finally {
  107. setIsLoading(false);
  108. }
  109. };
  110. // --- ユーザータブのハンドラー ---
  111. const fetchUsers = async () => {
  112. setIsUserLoading(true);
  113. try {
  114. const userList = await userService.getUsers();
  115. setUsers(userList);
  116. } catch (error: any) {
  117. setError(error.message || t('getUserListFailed'));
  118. } finally {
  119. setIsUserLoading(false);
  120. }
  121. };
  122. const handleCreateUser = async (e: React.FormEvent) => {
  123. e.preventDefault();
  124. setError('');
  125. setUserSuccess('');
  126. if (newUser.password.length < 6) {
  127. setError(t('passwordMinLength'));
  128. return;
  129. }
  130. try {
  131. await userService.createUser(newUser.username, newUser.password, newUser.isAdmin);
  132. setUserSuccess(t('userCreatedSuccess'));
  133. setNewUser({ username: '', password: '', isAdmin: false });
  134. setShowAddUser(false);
  135. fetchUsers();
  136. } catch (error: any) {
  137. setError(error.message || t('createUserFailed'));
  138. }
  139. };
  140. const [passwordChangeUserData, setPasswordChangeUserData] = useState<{ userId: string, newPassword: string } | null>(null);
  141. const handleToggleUserAdmin = async (userId: string, newAdminStatus: boolean) => {
  142. try {
  143. await userService.updateUser(userId, newAdminStatus);
  144. // 重新获取用户列表
  145. fetchUsers();
  146. setUserSuccess(newAdminStatus ? t('userPromotedToAdmin') : t('userDemotedFromAdmin'));
  147. } catch (error: any) {
  148. setError(error.message || t('updateUserFailed'));
  149. }
  150. };
  151. const handleUserPasswordChange = async () => {
  152. if (!passwordChangeUserData || !passwordChangeUserData.newPassword) return;
  153. try {
  154. // Update user password
  155. await userService.updateUserInfo(passwordChangeUserData.userId, { password: passwordChangeUserData.newPassword });
  156. setUserSuccess(t('passwordChangeSuccess'));
  157. setPasswordChangeUserData(null);
  158. fetchUsers(); // Refresh the user list
  159. } catch (error: any) {
  160. setError(error.message || t('passwordChangeFailed'));
  161. }
  162. };
  163. const handleDeleteUser = async (userId: string) => {
  164. if (window.confirm(t('confirmDeleteUser'))) {
  165. try {
  166. await userService.deleteUser(userId);
  167. // 重新获取用户列表
  168. fetchUsers();
  169. setUserSuccess(t('userDeletedSuccessfully'));
  170. } catch (error: any) {
  171. setError(error.message || t('deleteUserFailed'));
  172. }
  173. }
  174. };
  175. // --- モデルタブのハンドラー ---
  176. const validateApiKey = async (config: Partial<ModelConfig>) => {
  177. // 許可されたドメインを検証して、任意の外部API接続を防ぎます
  178. if (config.baseUrl) {
  179. try {
  180. // 注意:この検証はモデルAPI設定にのみ適用され、外部API呼び出しをセキュリティ制限します
  181. // 本番環境では、より柔軟な設定を使用することをお勧めします
  182. const url = new URL(config.baseUrl);
  183. // 環境変数から許可されたホストを取得(なければデフォルトを使用)
  184. const envAllowedHosts = process.env.VITE_ALLOWED_HOSTS || 'localhost,127.0.0.1,0.0.0.0';
  185. const allowedHosts = envAllowedHosts.split(',').map(host => host.trim());
  186. const isLocalhost = allowedHosts.includes(url.hostname) ||
  187. url.hostname.startsWith('192.168.') ||
  188. url.hostname.startsWith('10.') ||
  189. url.hostname.startsWith('172.');
  190. // 本番環境では、ホワイトリスト設定をハードコードされた許可リストの代わりに実装できます
  191. if (!isLocalhost) {
  192. // 設定ファイルまたは環境変数によって外部API接続を許可するかどうかを決定する必要があります
  193. // このデモでは、外部APIへの制限を維持します
  194. throw new Error(t('mmErrorExternalApiNotAllowed'));
  195. }
  196. } catch (urlError) {
  197. throw new Error(t('mmErrorInvalidBaseUrl'));
  198. }
  199. }
  200. try {
  201. let apiUrl = config.baseUrl || '';
  202. let body = {};
  203. if (config.type === ModelType.LLM) {
  204. apiUrl = apiUrl.endsWith('/chat/completions') ? apiUrl : `${apiUrl.replace(/\/+$/, '')}/chat/completions`;
  205. body = { model: config.modelId, messages: [{ role: 'user', content: 'Hi' }], max_tokens: 1 };
  206. } else if (config.type === ModelType.RERANK) {
  207. apiUrl = apiUrl.replace(/\/+$/, '');
  208. // SiliconFlow API テストボディ
  209. body = { model: config.modelId, query: 'test', documents: ['test'], top_n: 1 };
  210. } else {
  211. apiUrl = apiUrl.endsWith('/embeddings') ? apiUrl : `${apiUrl.replace(/\/+$/, '')}/embeddings`;
  212. body = { input: ['test'], model: config.modelId };
  213. }
  214. const response = await fetch(apiUrl, {
  215. method: 'POST',
  216. headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.apiKey}` },
  217. body: JSON.stringify(body),
  218. });
  219. if (!response.ok) {
  220. const errText = await response.text();
  221. throw new Error(`API Request Failed: ${response.status} ${response.statusText} - ${errText.substring(0, 100)}`);
  222. }
  223. } catch (error: any) {
  224. console.error('Validation Warning:', error);
  225. throw new Error(t('validationFailedMsg').replace('$1', error.message));
  226. }
  227. };
  228. const handleSaveModel = async () => {
  229. if (!authToken) {
  230. setError(t('mmErrorNotAuthenticated'));
  231. return;
  232. }
  233. setError(null);
  234. if (!modelFormData.name?.trim()) { setError(t('mmErrorNameRequired')); return; }
  235. if (!modelFormData.modelId?.trim()) { setError(t('mmErrorModelIdRequired')); return; }
  236. if (!modelFormData.baseUrl?.trim()) { setError(t('mmErrorBaseUrlRequired')); return; }
  237. if (editingId === 'new' && !modelFormData.apiKey?.trim()) { setError(t('mmErrorApiKeyRequired')); return; }
  238. setIsLoading(true);
  239. try {
  240. if (modelFormData.apiKey?.trim() && !skipValidation) {
  241. await validateApiKey(modelFormData);
  242. }
  243. const saveData = { ...modelFormData } as ModelConfig;
  244. if (saveData.type !== ModelType.EMBEDDING) {
  245. delete saveData.dimensions;
  246. delete saveData.maxInputTokens;
  247. delete saveData.maxBatchSize;
  248. }
  249. if (editingId !== 'new' && !modelFormData.apiKey?.trim()) {
  250. delete saveData.apiKey;
  251. }
  252. await onUpdateModels(editingId === 'new' ? 'create' : 'update', saveData);
  253. setEditingId(null);
  254. } catch (err: any) {
  255. setError(err.message || t('errorGeneric'));
  256. } finally {
  257. setIsLoading(false);
  258. }
  259. };
  260. const handleToggleModel = async (model: ModelConfig) => {
  261. try {
  262. await onUpdateModels('update', { ...model, isEnabled: !model.isEnabled });
  263. } catch (error) {
  264. console.error('Failed to toggle model:', error);
  265. }
  266. };
  267. const handleDeleteModel = async (id: string) => {
  268. if (confirm(t('confirmClear'))) {
  269. await onUpdateModels('delete', { id } as ModelConfig);
  270. }
  271. };
  272. const getTypeLabel = (type: ModelType) => {
  273. switch (type) {
  274. case ModelType.LLM: return t('typeLLM');
  275. case ModelType.EMBEDDING: return t('typeEmbedding');
  276. case ModelType.RERANK: return t('typeRerank');
  277. }
  278. };
  279. // --- レンダリング関数 ---
  280. const renderGeneralTab = () => (
  281. <div className="space-y-8 animate-in slide-in-from-right duration-300 max-w-2xl">
  282. {/* パスワード変更セクション */}
  283. <section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
  284. <h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
  285. <Key className="w-4 h-4 text-blue-500" />
  286. {t('changePassword')}
  287. </h3>
  288. <form onSubmit={handleChangePassword} className="space-y-4 max-w-sm">
  289. <div>
  290. <input
  291. type="password"
  292. placeholder={t('currentPassword')}
  293. value={passwordForm.current}
  294. onChange={e => setPasswordForm({ ...passwordForm, current: e.target.value })}
  295. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  296. required
  297. />
  298. </div>
  299. <div>
  300. <input
  301. type="password"
  302. placeholder={t('newPassword')}
  303. value={passwordForm.new}
  304. onChange={e => setPasswordForm({ ...passwordForm, new: e.target.value })}
  305. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  306. required
  307. />
  308. </div>
  309. <div>
  310. <input
  311. type="password"
  312. placeholder={t('confirmPassword')}
  313. value={passwordForm.confirm}
  314. onChange={e => setPasswordForm({ ...passwordForm, confirm: e.target.value })}
  315. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  316. required
  317. />
  318. </div>
  319. {passwordSuccess && <p className="text-xs text-green-600">{passwordSuccess}</p>}
  320. <button
  321. type="submit"
  322. disabled={isLoading}
  323. className="w-full py-2 bg-slate-800 text-white rounded-md text-sm hover:bg-slate-900 transition-colors disabled:opacity-50"
  324. >
  325. {isLoading ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> : t('confirmChange')}
  326. </button>
  327. </form>
  328. </section>
  329. </div >
  330. );
  331. const renderUserTab = () => (
  332. <div className="space-y-4 animate-in slide-in-from-right duration-300 max-w-3xl">
  333. <div className="flex justify-between items-center mb-4">
  334. <h3 className="font-medium text-slate-700">{t('userList')}</h3>
  335. <button
  336. onClick={() => setShowAddUser(!showAddUser)}
  337. 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"
  338. >
  339. <Plus className="w-4 h-4" />
  340. {t('addUser')}
  341. </button>
  342. </div>
  343. {showAddUser && (
  344. <form onSubmit={handleCreateUser} className="bg-slate-50 p-4 rounded-lg border border-slate-200 mb-4 space-y-3">
  345. <input
  346. type="text"
  347. placeholder={t('username')}
  348. value={newUser.username}
  349. onChange={e => setNewUser({ ...newUser, username: e.target.value })}
  350. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md"
  351. required
  352. />
  353. <input
  354. type="password"
  355. placeholder={t('password')}
  356. value={newUser.password}
  357. onChange={e => setNewUser({ ...newUser, password: e.target.value })}
  358. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md"
  359. required
  360. />
  361. <div className="flex items-center gap-2">
  362. <input
  363. type="checkbox"
  364. id="isAdmin"
  365. checked={newUser.isAdmin}
  366. onChange={e => setNewUser({ ...newUser, isAdmin: e.target.checked })}
  367. className="w-4 h-4 text-blue-600 rounded border border-slate-300"
  368. />
  369. <label htmlFor="isAdmin" className="text-sm text-slate-700">
  370. {t('adminUser')}
  371. </label>
  372. </div>
  373. <div className="flex justify-end gap-2">
  374. <button type="button" onClick={() => setShowAddUser(false)} className="text-xs text-slate-500 hover:text-slate-700 px-2 py-1">{t('cancel')}</button>
  375. <button type="submit" className="text-xs bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700">{t('create')}</button>
  376. </div>
  377. </form>
  378. )}
  379. {/* Password Change Modal */}
  380. {passwordChangeUserData && (
  381. <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
  382. <div className="bg-white rounded-lg p-6 w-full max-w-md">
  383. <div className="flex items-center justify-between mb-4">
  384. <h3 className="text-lg font-semibold">{t('changeUserPassword')}</h3>
  385. <button
  386. onClick={() => setPasswordChangeUserData(null)}
  387. className="text-gray-400 hover:text-gray-600"
  388. >
  389. <X size={20} />
  390. </button>
  391. </div>
  392. <form onSubmit={(e) => { e.preventDefault(); handleUserPasswordChange(); }} className="space-y-4">
  393. <div>
  394. <label className="block text-sm font-medium text-gray-700 mb-1">
  395. {t('newPassword')}
  396. </label>
  397. <input
  398. type="password"
  399. value={passwordChangeUserData.newPassword}
  400. onChange={(e) => setPasswordChangeUserData({ ...passwordChangeUserData, newPassword: e.target.value })}
  401. className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
  402. placeholder={t('enterNewPassword')}
  403. required
  404. />
  405. <p className="text-xs text-gray-500 mt-1">{t('passwordMinLength')}</p>
  406. </div>
  407. <div className="flex space-x-3 pt-4">
  408. <button
  409. type="button"
  410. onClick={() => setPasswordChangeUserData(null)}
  411. className="flex-1 px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
  412. >
  413. {t('cancel')}
  414. </button>
  415. <button
  416. type="submit"
  417. className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
  418. >
  419. {t('confirmChange')}
  420. </button>
  421. </div>
  422. </form>
  423. </div>
  424. </div>
  425. )}
  426. {userSuccess && <p className="text-xs text-green-600 mb-2">{userSuccess}</p>}
  427. <div className="space-y-2 max-h-[60vh] overflow-y-auto">
  428. {users.map(user => (
  429. <div key={user.id} className="flex items-center justify-between p-3 bg-white border border-slate-200 rounded-lg">
  430. <div className="flex items-center gap-3">
  431. <div className={`p-2 rounded-full ${user.isAdmin ? 'bg-orange-100 text-orange-600' : 'bg-slate-100 text-slate-600'}`}>
  432. {user.isAdmin ? <Shield className="w-4 h-4" /> : <User className="w-4 h-4" />}
  433. </div>
  434. <div>
  435. <p className="text-sm font-medium text-slate-800">{user.username}</p>
  436. <p className="text-xs text-slate-400">{new Date(user.createdAt).toLocaleDateString()}</p>
  437. </div>
  438. </div>
  439. <div className="flex items-center gap-2">
  440. <span className="text-xs text-slate-400 bg-slate-50 px-2 py-1 rounded">
  441. {user.isAdmin ? t('admin') : t('user')}
  442. </span>
  443. <button
  444. onClick={() => handleToggleUserAdmin(user.id, !user.isAdmin)}
  445. className="p-1.5 rounded-md hover:bg-slate-100 text-slate-500 hover:text-blue-600 transition-colors"
  446. title={user.isAdmin ? t('makeUserRegular') : t('makeUserAdmin')}
  447. >
  448. <Edit2 className="w-4 h-4" />
  449. </button>
  450. <button
  451. onClick={() => setPasswordChangeUserData({ userId: user.id, newPassword: '' })}
  452. className="p-1.5 rounded-md hover:bg-slate-100 text-slate-500 hover:text-blue-600 transition-colors"
  453. title={t('changeUserPassword')}
  454. >
  455. <Key className="w-4 h-4" />
  456. </button>
  457. {user.id !== currentUser?.id && ( // 不允许删除自己
  458. <button
  459. onClick={() => handleDeleteUser(user.id)}
  460. className="p-1.5 rounded-md hover:bg-slate-100 text-slate-500 hover:text-red-600 transition-colors"
  461. title={t('deleteUser')}
  462. >
  463. <Trash2 className="w-4 h-4" />
  464. </button>
  465. )}
  466. </div>
  467. </div>
  468. ))}
  469. </div>
  470. </div>
  471. );
  472. const renderModelTab = () => (
  473. <div className="animate-in slide-in-from-right duration-300 max-w-3xl">
  474. {editingId ? (
  475. <div className="bg-white p-6 rounded-lg border border-blue-100 shadow-sm space-y-4">
  476. <h3 className="font-semibold text-slate-700 mb-4">{editingId === 'new' ? t('mmAddBtn') : t('mmEdit')}</h3>
  477. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  478. <div>
  479. <label className="block text-xs font-medium text-slate-500 mb-1">{t('mmFormName')} *</label>
  480. <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} />
  481. </div>
  482. <div>
  483. <label className="block text-xs font-medium text-slate-500 mb-1">{t('mmFormModelId')} *</label>
  484. <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} />
  485. </div>
  486. </div>
  487. <div>
  488. <label className="block text-xs font-medium text-slate-500 mb-1">{t('mmFormType')} *</label>
  489. <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}>
  490. <option value={ModelType.LLM}>{t('typeLLM')}</option>
  491. <option value={ModelType.EMBEDDING}>{t('typeEmbedding')}</option>
  492. <option value={ModelType.RERANK}>{t('typeRerank')}</option>
  493. </select>
  494. {(modelFormData.type === ModelType.LLM || String(modelFormData.type).toLowerCase() === 'llm') && (
  495. <div className="mt-2 flex items-center gap-2 p-2 bg-blue-50/50 rounded-md border border-blue-100/50">
  496. <input
  497. type="checkbox"
  498. id="supportsVision"
  499. className="w-4 h-4 text-blue-600 rounded cursor-pointer"
  500. checked={modelFormData.supportsVision}
  501. onChange={e => setModelFormData({ ...modelFormData, supportsVision: e.target.checked })}
  502. />
  503. <label htmlFor="supportsVision" className="text-sm text-blue-700 font-medium cursor-pointer select-none">
  504. {t('mmFormVision')}
  505. </label>
  506. </div>
  507. )}
  508. </div>
  509. <div>
  510. <label className="block text-xs font-medium text-slate-500 mb-1">{t('mmFormBaseUrl')} *</label>
  511. <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" />
  512. </div>
  513. <div>
  514. <label className="block text-xs font-medium text-slate-500 mb-1">{t('mmFormApiKey')} {editingId === 'new' && '*'}</label>
  515. <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" />
  516. <div className="mt-2 flex items-center gap-2">
  517. <input type="checkbox" id="skipVal" checked={skipValidation} onChange={e => setSkipValidation(e.target.checked)} />
  518. <label htmlFor="skipVal" className="text-xs text-slate-500">{t('skipApiValidation')}</label>
  519. </div>
  520. </div>
  521. {modelFormData.type === ModelType.EMBEDDING && (
  522. <div className="grid grid-cols-2 gap-4">
  523. <div>
  524. <label className="block text-xs font-medium text-slate-500 mb-1">Max Input</label>
  525. <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) })} />
  526. </div>
  527. <div>
  528. <label className="block text-xs font-medium text-slate-500 mb-1">Dimensions</label>
  529. <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) })} />
  530. </div>
  531. </div>
  532. )}
  533. <div className="flex justify-end gap-2 pt-2">
  534. <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>
  535. <button onClick={handleSaveModel} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg flex items-center gap-2" disabled={isLoading}>
  536. {isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
  537. {t('mmSave')}
  538. </button>
  539. </div>
  540. </div>
  541. ) : (
  542. <div className="space-y-3">
  543. {models.map(model => (
  544. <div key={model.id} className="bg-white border border-slate-200 rounded-lg p-3 flex justify-between items-start">
  545. <div className="flex gap-3">
  546. <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>
  547. <div>
  548. <h4 className="font-semibold text-sm text-slate-800">{model.name}</h4>
  549. <div className="flex gap-2 text-xs text-slate-500">
  550. <span className="bg-slate-100 px-1 rounded">{getTypeLabel(model.type)}</span>
  551. <span className="font-mono">{model.modelId}</span>
  552. </div>
  553. </div>
  554. </div>
  555. <div className="flex gap-1">
  556. <button
  557. onClick={() => handleToggleModel(model)}
  558. className={`p-2 transition-colors ${model.isEnabled !== false ? 'text-emerald-500 hover:text-emerald-600' : 'text-slate-300 hover:text-slate-400'}`}
  559. title={model.isEnabled !== false ? t('modelEnabled') : t('modelDisabled')}
  560. >
  561. {model.isEnabled !== false ? <ToggleRight className="w-6 h-6" /> : <ToggleLeft className="w-6 h-6" />}
  562. </button>
  563. <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>
  564. <button onClick={() => handleDeleteModel(model.id)} className="p-2 text-slate-400 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
  565. </div>
  566. </div>
  567. ))}
  568. <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">
  569. <Plus className="w-5 h-5" /> {t('mmAddBtn')}
  570. </button>
  571. </div>
  572. )}
  573. </div>
  574. );
  575. return (
  576. <div className="flex h-full bg-white relative">
  577. {/* サイドバー */}
  578. <div className="w-64 bg-slate-50 border-r border-slate-200 flex flex-col shrink-0">
  579. <div className="p-6">
  580. <h2 className="text-xl font-bold text-slate-800 flex items-center gap-2">
  581. <SettingsIcon className="w-6 h-6 text-blue-600" />
  582. {t('settings')}
  583. </h2>
  584. </div>
  585. <nav className="flex-1 px-4 space-y-1">
  586. <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'}`}>
  587. <Globe className="w-5 h-5" /> {t('generalSettings')}
  588. </button>
  589. {isAdmin && (
  590. <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'}`}>
  591. <User className="w-5 h-5" /> {t('userManagement')}
  592. </button>
  593. )}
  594. {isAdmin && (
  595. <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'}`}>
  596. <Cpu className="w-5 h-5" /> {t('modelManagement')}
  597. </button>
  598. )}
  599. </nav>
  600. </div>
  601. {/* コンテンツエリア */}
  602. <div className="flex-1 flex flex-col min-w-0 h-full overflow-hidden">
  603. <div className="flex items-center justify-between px-8 py-6 border-b border-slate-100 shrink-0">
  604. <h3 className="text-xl font-bold text-slate-800">
  605. {activeTab === 'general' ? t('generalSettings') : activeTab === 'user' ? t('userManagement') : activeTab === 'model' ? t('modelManagement') : t('generalSettings')}
  606. </h3>
  607. </div>
  608. <div className="flex-1 overflow-y-auto p-8 bg-slate-50/50">
  609. {error && (
  610. <div className="mb-4 p-4 bg-red-50 text-red-700 rounded-lg flex gap-2 items-start text-sm">
  611. <span className="font-bold">Error:</span> {error}
  612. </div>
  613. )}
  614. {activeTab === 'general' && renderGeneralTab()}
  615. {activeTab === 'user' && renderUserTab()}
  616. {activeTab === 'model' && isAdmin && renderModelTab()} {/* Only render model tab if user is admin */}
  617. </div>
  618. </div>
  619. </div>
  620. );
  621. };