SettingsView.tsx 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610
  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. import { useConfirm } from '../../contexts/ConfirmContext';
  10. import { useToast } from '../../contexts/ToastContext';
  11. interface SettingsViewProps {
  12. // Model Props
  13. models: ModelConfig[];
  14. authToken: string | null;
  15. onUpdateModels: (action: 'create' | 'update' | 'delete', model: ModelConfig) => Promise<void>;
  16. isAdmin?: boolean; // Added isAdmin prop
  17. currentUser?: any; // Added current user prop
  18. }
  19. type TabType = 'general' | 'user' | 'model';
  20. export const SettingsView: React.FC<SettingsViewProps> = ({
  21. models,
  22. authToken,
  23. onUpdateModels,
  24. isAdmin = false,
  25. currentUser,
  26. }) => {
  27. const { t } = useLanguage();
  28. const { confirm } = useConfirm();
  29. const { showError, showSuccess } = useToast();
  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. baseUrl: 'http://localhost:11434/v1',
  38. modelId: 'llama3',
  39. name: '',
  40. dimensions: 1536,
  41. apiKey: '',
  42. maxInputTokens: 8191,
  43. maxBatchSize: 2048
  44. });
  45. // --- User Management State ---
  46. interface UserType {
  47. id: string;
  48. username: string;
  49. isAdmin: boolean;
  50. createdAt: string;
  51. }
  52. const [users, setUsers] = useState<UserType[]>([]);
  53. const [isUserLoading, setIsUserLoading] = useState(false);
  54. const [showAddUser, setShowAddUser] = useState(false);
  55. const [newUser, setNewUser] = useState({ username: '', password: '', isAdmin: false });
  56. const [userSuccess, setUserSuccess] = useState('');
  57. // --- Change Password State ---
  58. const [passwordForm, setPasswordForm] = useState({ current: '', new: '', confirm: '' });
  59. const [passwordSuccess, setPasswordSuccess] = useState('');
  60. // --- App Settings State ---
  61. const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
  62. const [knowledgeGroups, setKnowledgeGroups] = useState<KnowledgeGroup[]>([]);
  63. const [isSettingsLoading, setIsSettingsLoading] = useState(false);
  64. // ユーザー一覧の取得(ユーザータブがアクティブな場合)
  65. useEffect(() => {
  66. if (activeTab === 'user') {
  67. fetchUsers();
  68. } else if (activeTab === 'general') {
  69. fetchSettingsAndGroups();
  70. }
  71. }, [activeTab]); // activeTab のみに依存し、currentUser は不要
  72. const fetchSettingsAndGroups = async () => {
  73. if (!authToken) return;
  74. setIsSettingsLoading(true);
  75. try {
  76. const [settings, groups] = await Promise.all([
  77. userSettingService.get(authToken),
  78. knowledgeGroupService.getGroups()
  79. ]);
  80. setAppSettings(settings);
  81. setKnowledgeGroups(groups);
  82. } catch (error) {
  83. console.error('Failed to fetch settings or groups:', error);
  84. } finally {
  85. setIsSettingsLoading(false);
  86. }
  87. };
  88. // --- 一般タブのハンドラー ---
  89. const handleChangePassword = async (e: React.FormEvent) => {
  90. e.preventDefault();
  91. setError(null);
  92. setPasswordSuccess('');
  93. if (passwordForm.new !== passwordForm.confirm) {
  94. setError(t('passwordMismatch'));
  95. return;
  96. }
  97. if (passwordForm.new.length < 6) {
  98. setError(t('newPasswordMinLength'));
  99. return;
  100. }
  101. setIsLoading(true);
  102. try {
  103. await userService.changePassword(passwordForm.current, passwordForm.new);
  104. setPasswordSuccess(t('passwordChangeSuccess'));
  105. setPasswordForm({ current: '', new: '', confirm: '' });
  106. } catch (err: any) {
  107. setError(err.message || t('passwordChangeFailed'));
  108. } finally {
  109. setIsLoading(false);
  110. }
  111. };
  112. // --- ユーザータブのハンドラー ---
  113. const fetchUsers = async () => {
  114. setIsUserLoading(true);
  115. try {
  116. const userList = await userService.getUsers();
  117. setUsers(userList);
  118. } catch (error: any) {
  119. setError(error.message || t('getUserListFailed'));
  120. } finally {
  121. setIsUserLoading(false);
  122. }
  123. };
  124. const handleCreateUser = async (e: React.FormEvent) => {
  125. e.preventDefault();
  126. setError('');
  127. setUserSuccess('');
  128. if (newUser.password.length < 6) {
  129. setError(t('passwordMinLength'));
  130. return;
  131. }
  132. try {
  133. await userService.createUser(newUser.username, newUser.password, newUser.isAdmin);
  134. setUserSuccess(t('userCreatedSuccess'));
  135. setNewUser({ username: '', password: '', isAdmin: false });
  136. setShowAddUser(false);
  137. fetchUsers();
  138. } catch (error: any) {
  139. setError(error.message || t('createUserFailed'));
  140. }
  141. };
  142. const [passwordChangeUserData, setPasswordChangeUserData] = useState<{ userId: string, newPassword: string } | null>(null);
  143. const handleToggleUserAdmin = async (userId: string, newAdminStatus: boolean) => {
  144. try {
  145. await userService.updateUser(userId, newAdminStatus);
  146. // ユーザーリストを再取得
  147. fetchUsers();
  148. setUserSuccess(newAdminStatus ? t('userPromotedToAdmin') : t('userDemotedFromAdmin'));
  149. } catch (error: any) {
  150. setError(error.message || t('updateUserFailed'));
  151. }
  152. };
  153. const handleUserPasswordChange = async () => {
  154. if (!passwordChangeUserData || !passwordChangeUserData.newPassword) return;
  155. try {
  156. // Update user password
  157. await userService.updateUserInfo(passwordChangeUserData.userId, { password: passwordChangeUserData.newPassword });
  158. setUserSuccess(t('passwordChangeSuccess'));
  159. setPasswordChangeUserData(null);
  160. fetchUsers(); // Refresh the user list
  161. } catch (error: any) {
  162. setError(error.message || t('passwordChangeFailed'));
  163. }
  164. };
  165. const handleDeleteUser = async (userId: string) => {
  166. if (await confirm(t('confirmDeleteUser'))) {
  167. try {
  168. await userService.deleteUser(userId);
  169. // ユーザーリストを再取得
  170. fetchUsers();
  171. showSuccess(t('userDeletedSuccessfully'));
  172. } catch (error: any) {
  173. showError(error.message || t('deleteUserFailed'));
  174. }
  175. }
  176. };
  177. // --- モデルタブのハンドラー ---
  178. const handleSaveModel = async () => {
  179. if (!authToken) {
  180. setError(t('mmErrorNotAuthenticated'));
  181. return;
  182. }
  183. setError(null);
  184. if (!modelFormData.name?.trim()) { setError(t('mmErrorNameRequired')); return; }
  185. if (!modelFormData.modelId?.trim()) { setError(t('mmErrorModelIdRequired')); return; }
  186. if (!modelFormData.baseUrl?.trim()) { setError(t('mmErrorBaseUrlRequired')); return; }
  187. setIsLoading(true);
  188. try {
  189. const saveData = { ...modelFormData } as ModelConfig;
  190. await onUpdateModels(editingId === 'new' ? 'create' : 'update', saveData);
  191. setEditingId(null);
  192. } catch (err: any) {
  193. setError(err.message || t('errorGeneric'));
  194. } finally {
  195. setIsLoading(false);
  196. }
  197. };
  198. const handleToggleModel = async (model: ModelConfig) => {
  199. try {
  200. await onUpdateModels('update', { ...model, isEnabled: !model.isEnabled });
  201. } catch (error) {
  202. console.error('Failed to toggle model:', error);
  203. }
  204. };
  205. const handleDeleteModel = async (id: string) => {
  206. if (await confirm(t('confirmClear'))) {
  207. await onUpdateModels('delete', { id } as ModelConfig);
  208. }
  209. };
  210. const getTypeLabel = (type: ModelType) => {
  211. switch (type) {
  212. case ModelType.LLM: return t('typeLLM');
  213. case ModelType.EMBEDDING: return t('typeEmbedding');
  214. case ModelType.RERANK: return t('typeRerank');
  215. }
  216. };
  217. // --- レンダリング関数 ---
  218. const renderGeneralTab = () => (
  219. <div className="space-y-8 animate-in slide-in-from-right duration-300 max-w-2xl">
  220. {/* パスワード変更セクション */}
  221. <section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
  222. <h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
  223. <Key className="w-4 h-4 text-blue-500" />
  224. {t('changePassword')}
  225. </h3>
  226. <form onSubmit={handleChangePassword} className="space-y-4 max-w-sm">
  227. <div>
  228. <input
  229. type="password"
  230. placeholder={t('currentPassword')}
  231. value={passwordForm.current}
  232. onChange={e => setPasswordForm({ ...passwordForm, current: e.target.value })}
  233. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  234. required
  235. />
  236. </div>
  237. <div>
  238. <input
  239. type="password"
  240. placeholder={t('newPassword')}
  241. value={passwordForm.new}
  242. onChange={e => setPasswordForm({ ...passwordForm, new: e.target.value })}
  243. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  244. required
  245. />
  246. </div>
  247. <div>
  248. <input
  249. type="password"
  250. placeholder={t('confirmPassword')}
  251. value={passwordForm.confirm}
  252. onChange={e => setPasswordForm({ ...passwordForm, confirm: e.target.value })}
  253. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  254. required
  255. />
  256. </div>
  257. {passwordSuccess && <p className="text-xs text-green-600">{passwordSuccess}</p>}
  258. <button
  259. type="submit"
  260. disabled={isLoading}
  261. className="w-full py-2 bg-slate-800 text-white rounded-md text-sm hover:bg-slate-900 transition-colors disabled:opacity-50"
  262. >
  263. {isLoading ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> : t('confirmChange')}
  264. </button>
  265. </form>
  266. </section>
  267. </div >
  268. );
  269. const renderUserTab = () => (
  270. <div className="space-y-4 animate-in slide-in-from-right duration-300 max-w-3xl">
  271. <div className="flex justify-between items-center mb-4">
  272. <h3 className="font-medium text-slate-700">{t('userList')}</h3>
  273. <button
  274. onClick={() => setShowAddUser(!showAddUser)}
  275. 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"
  276. >
  277. <Plus className="w-4 h-4" />
  278. {t('addUser')}
  279. </button>
  280. </div>
  281. {showAddUser && (
  282. <form onSubmit={handleCreateUser} className="bg-slate-50 p-4 rounded-lg border border-slate-200 mb-4 space-y-3">
  283. <input
  284. type="text"
  285. placeholder={t('username')}
  286. value={newUser.username}
  287. onChange={e => setNewUser({ ...newUser, username: e.target.value })}
  288. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md"
  289. required
  290. />
  291. <input
  292. type="password"
  293. placeholder={t('password')}
  294. value={newUser.password}
  295. onChange={e => setNewUser({ ...newUser, password: e.target.value })}
  296. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md"
  297. required
  298. />
  299. <div className="flex items-center gap-2">
  300. <input
  301. type="checkbox"
  302. id="isAdmin"
  303. checked={newUser.isAdmin}
  304. onChange={e => setNewUser({ ...newUser, isAdmin: e.target.checked })}
  305. className="w-4 h-4 text-blue-600 rounded border border-slate-300"
  306. />
  307. <label htmlFor="isAdmin" className="text-sm text-slate-700">
  308. {t('adminUser')}
  309. </label>
  310. </div>
  311. <div className="flex justify-end gap-2">
  312. <button type="button" onClick={() => setShowAddUser(false)} className="text-xs text-slate-500 hover:text-slate-700 px-2 py-1">{t('cancel')}</button>
  313. <button type="submit" className="text-xs bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700">{t('create')}</button>
  314. </div>
  315. </form>
  316. )}
  317. {/* Password Change Modal */}
  318. {passwordChangeUserData && (
  319. <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
  320. <div className="bg-white rounded-lg p-6 w-full max-w-md">
  321. <div className="flex items-center justify-between mb-4">
  322. <h3 className="text-lg font-semibold">{t('changeUserPassword')}</h3>
  323. <button
  324. onClick={() => setPasswordChangeUserData(null)}
  325. className="text-gray-400 hover:text-gray-600"
  326. >
  327. <X size={20} />
  328. </button>
  329. </div>
  330. <form onSubmit={(e) => { e.preventDefault(); handleUserPasswordChange(); }} className="space-y-4">
  331. <div>
  332. <label className="block text-sm font-medium text-gray-700 mb-1">
  333. {t('newPassword')}
  334. </label>
  335. <input
  336. type="password"
  337. value={passwordChangeUserData.newPassword}
  338. onChange={(e) => setPasswordChangeUserData({ ...passwordChangeUserData, newPassword: e.target.value })}
  339. className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
  340. placeholder={t('enterNewPassword')}
  341. required
  342. />
  343. <p className="text-xs text-gray-500 mt-1">{t('passwordMinLength')}</p>
  344. </div>
  345. <div className="flex space-x-3 pt-4">
  346. <button
  347. type="button"
  348. onClick={() => setPasswordChangeUserData(null)}
  349. className="flex-1 px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
  350. >
  351. {t('cancel')}
  352. </button>
  353. <button
  354. type="submit"
  355. className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
  356. >
  357. {t('confirmChange')}
  358. </button>
  359. </div>
  360. </form>
  361. </div>
  362. </div>
  363. )}
  364. {userSuccess && <p className="text-xs text-green-600 mb-2">{userSuccess}</p>}
  365. <div className="space-y-2 max-h-[60vh] overflow-y-auto">
  366. {users.map(user => (
  367. <div key={user.id} className="flex items-center justify-between p-3 bg-white border border-slate-200 rounded-lg">
  368. <div className="flex items-center gap-3">
  369. <div className={`p-2 rounded-full ${user.isAdmin ? 'bg-orange-100 text-orange-600' : 'bg-slate-100 text-slate-600'}`}>
  370. {user.isAdmin ? <Shield className="w-4 h-4" /> : <User className="w-4 h-4" />}
  371. </div>
  372. <div>
  373. <p className="text-sm font-medium text-slate-800">{user.username}</p>
  374. <p className="text-xs text-slate-400">{new Date(user.createdAt).toLocaleDateString()}</p>
  375. </div>
  376. </div>
  377. <div className="flex items-center gap-2">
  378. <span className="text-xs text-slate-400 bg-slate-50 px-2 py-1 rounded">
  379. {user.isAdmin ? t('admin') : t('user')}
  380. </span>
  381. {user.username !== 'admin' && ( // ビルトイン管理者は読み取り専用、操作ボタンなし
  382. <>
  383. {currentUser?.username === 'admin' && ( // ビルトイン管理者のみがロールを変更可能
  384. <button
  385. onClick={() => handleToggleUserAdmin(user.id, !user.isAdmin)}
  386. className="p-1.5 rounded-md hover:bg-slate-100 text-slate-500 hover:text-blue-600 transition-colors"
  387. title={user.isAdmin ? t('makeUserRegular') : t('makeUserAdmin')}
  388. >
  389. <Edit2 className="w-4 h-4" />
  390. </button>
  391. )}
  392. <button
  393. onClick={() => setPasswordChangeUserData({ userId: user.id, newPassword: '' })}
  394. className="p-1.5 rounded-md hover:bg-slate-100 text-slate-500 hover:text-blue-600 transition-colors"
  395. title={t('changeUserPassword')}
  396. >
  397. <Key className="w-4 h-4" />
  398. </button>
  399. {user.id !== currentUser?.id && ( // 自身の削除は許可しない
  400. <button
  401. onClick={() => handleDeleteUser(user.id)}
  402. className="p-1.5 rounded-md hover:bg-slate-100 text-slate-500 hover:text-red-600 transition-colors"
  403. title={t('deleteUser')}
  404. >
  405. <Trash2 className="w-4 h-4" />
  406. </button>
  407. )}
  408. </>
  409. )}
  410. </div>
  411. </div>
  412. ))}
  413. </div>
  414. </div>
  415. );
  416. const renderModelTab = () => (
  417. <div className="animate-in slide-in-from-right duration-300 max-w-3xl">
  418. {editingId ? (
  419. <div className="bg-white p-6 rounded-lg border border-blue-100 shadow-sm space-y-4">
  420. <h3 className="font-semibold text-slate-700 mb-4">{editingId === 'new' ? t('mmAddBtn') : t('mmEdit')}</h3>
  421. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  422. <div>
  423. <label className="block text-xs font-medium text-slate-500 mb-1">{t('mmFormName')} *</label>
  424. <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} />
  425. </div>
  426. <div>
  427. <label className="block text-xs font-medium text-slate-500 mb-1">{t('mmFormModelId')} *</label>
  428. <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} />
  429. </div>
  430. </div>
  431. <div>
  432. <label className="block text-xs font-medium text-slate-500 mb-1">{t('mmFormType')} *</label>
  433. <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}>
  434. <option value={ModelType.LLM}>{t('typeLLM')}</option>
  435. <option value={ModelType.EMBEDDING}>{t('typeEmbedding')}</option>
  436. <option value={ModelType.RERANK}>{t('typeRerank')}</option>
  437. <option value={ModelType.VISION}>{t('typeVision')}</option>
  438. </select>
  439. </div>
  440. <div>
  441. <label className="block text-xs font-medium text-slate-500 mb-1">{t('mmFormBaseUrl')} *</label>
  442. <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" />
  443. </div>
  444. <div>
  445. <label className="block text-xs font-medium text-slate-500 mb-1">{t('mmFormApiKey')}</label>
  446. <input
  447. type="password"
  448. className="w-full text-sm border rounded-md px-3 py-2 font-mono"
  449. value={modelFormData.apiKey || ''}
  450. onChange={e => setModelFormData({ ...modelFormData, apiKey: e.target.value })}
  451. disabled={isLoading}
  452. placeholder={t('mmFormApiKeyPlaceholder')}
  453. autoComplete="off"
  454. />
  455. </div>
  456. {modelFormData.type === ModelType.EMBEDDING && (
  457. <div className="grid grid-cols-2 gap-4">
  458. <div>
  459. <label className="block text-xs font-medium text-slate-500 mb-1">Max Input</label>
  460. <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) })} />
  461. </div>
  462. <div>
  463. <label className="block text-xs font-medium text-slate-500 mb-1">Dimensions</label>
  464. <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) })} />
  465. </div>
  466. </div>
  467. )}
  468. <div className="flex justify-end gap-2 pt-2">
  469. <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>
  470. <button onClick={handleSaveModel} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg flex items-center gap-2" disabled={isLoading}>
  471. {isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
  472. {t('mmSave')}
  473. </button>
  474. </div>
  475. </div>
  476. ) : (
  477. <div className="space-y-3">
  478. {models.map(model => (
  479. <div key={model.id} className="bg-white border border-slate-200 rounded-lg p-3 flex justify-between items-start">
  480. <div className="flex gap-3">
  481. <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>
  482. <div>
  483. <h4 className="font-semibold text-sm text-slate-800">{model.name}</h4>
  484. <div className="flex gap-2 text-xs text-slate-500">
  485. <span className="bg-slate-100 px-1 rounded">{getTypeLabel(model.type)}</span>
  486. <span className="font-mono">{model.modelId}</span>
  487. </div>
  488. </div>
  489. </div>
  490. <div className="flex gap-1">
  491. <button
  492. onClick={() => handleToggleModel(model)}
  493. className={`p-2 transition-colors ${model.isEnabled !== false ? 'text-emerald-500 hover:text-emerald-600' : 'text-slate-300 hover:text-slate-400'}`}
  494. title={model.isEnabled !== false ? t('modelEnabled') : t('modelDisabled')}
  495. >
  496. {model.isEnabled !== false ? <ToggleRight className="w-6 h-6" /> : <ToggleLeft className="w-6 h-6" />}
  497. </button>
  498. <button onClick={() => { setEditingId(model.id); setModelFormData({ ...model }); }} className="p-2 text-slate-400 hover:text-blue-600"><Edit2 className="w-4 h-4" /></button>
  499. <button onClick={() => handleDeleteModel(model.id)} className="p-2 text-slate-400 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
  500. </div>
  501. </div>
  502. ))}
  503. <button onClick={() => { setEditingId('new'); setModelFormData({ type: ModelType.LLM, baseUrl: 'http://localhost:11434/v1', modelId: 'llama3', 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">
  504. <Plus className="w-5 h-5" /> {t('mmAddBtn')}
  505. </button>
  506. </div>
  507. )}
  508. </div>
  509. );
  510. return (
  511. <div className="flex h-full bg-white relative">
  512. {/* サイドバー */}
  513. <div className="w-64 bg-slate-50 border-r border-slate-200 flex flex-col shrink-0">
  514. <div className="p-6">
  515. <h2 className="text-xl font-bold text-slate-800 flex items-center gap-2">
  516. <SettingsIcon className="w-6 h-6 text-blue-600" />
  517. {t('settings')}
  518. </h2>
  519. </div>
  520. <nav className="flex-1 px-4 space-y-1">
  521. <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'}`}>
  522. <Globe className="w-5 h-5" /> {t('generalSettings')}
  523. </button>
  524. {isAdmin && (
  525. <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'}`}>
  526. <User className="w-5 h-5" /> {t('userManagement')}
  527. </button>
  528. )}
  529. {isAdmin && (
  530. <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'}`}>
  531. <Cpu className="w-5 h-5" /> {t('modelManagement')}
  532. </button>
  533. )}
  534. </nav>
  535. </div>
  536. {/* コンテンツエリア */}
  537. <div className="flex-1 flex flex-col min-w-0 h-full overflow-hidden">
  538. <div className="flex items-center justify-between px-8 py-6 border-b border-slate-100 shrink-0">
  539. <h3 className="text-xl font-bold text-slate-800">
  540. {activeTab === 'general' ? t('generalSettings') : activeTab === 'user' ? t('userManagement') : activeTab === 'model' ? t('modelManagement') : t('generalSettings')}
  541. </h3>
  542. </div>
  543. <div className="flex-1 overflow-y-auto p-8 bg-slate-50/50">
  544. {error && (
  545. <div className="mb-4 p-4 bg-red-50 text-red-700 rounded-lg flex gap-2 items-start text-sm">
  546. <span className="font-bold">Error:</span> {error}
  547. </div>
  548. )}
  549. {activeTab === 'general' && renderGeneralTab()}
  550. {activeTab === 'user' && renderUserTab()}
  551. {activeTab === 'model' && isAdmin && renderModelTab()} {/* Only render model tab if user is admin */}
  552. </div>
  553. </div>
  554. </div>
  555. );
  556. };