SettingsView.tsx 128 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137
  1. import React, { useState, useEffect } from 'react';
  2. import { ModelConfig, ModelType, AppSettings, KnowledgeGroup, Tenant, TenantMember } from '../../types';
  3. import { useLanguage } from '../../contexts/LanguageContext';
  4. import {
  5. ChevronLeft,
  6. ChevronRight,
  7. Plus,
  8. Search,
  9. KeyRound,
  10. Trash2,
  11. Edit,
  12. UserPlus,
  13. Globe,
  14. PlusCircle,
  15. Clock,
  16. ExternalLink,
  17. Download,
  18. Upload,
  19. Building,
  20. Settings as SettingsIcon,
  21. Shield,
  22. User,
  23. MoreVertical,
  24. Check,
  25. ChevronDown,
  26. ChevronUp,
  27. Filter,
  28. RefreshCcw,
  29. LayoutDashboard,
  30. Users,
  31. Database,
  32. UserCircle,
  33. HardDrive,
  34. LayoutGrid,
  35. X,
  36. Key,
  37. Loader2,
  38. Edit2,
  39. Save,
  40. Cpu,
  41. BookOpen,
  42. Sparkles,
  43. ToggleRight,
  44. ToggleLeft,
  45. } from "lucide-react";
  46. import { motion, AnimatePresence } from 'framer-motion';
  47. import { userService } from '../../services/userService';
  48. import { settingsService } from '../../services/settingsService';
  49. import { userSettingService } from '../../services/userSettingService';
  50. import { knowledgeGroupService } from '../../services/knowledgeGroupService';
  51. import { apiClient } from '../../services/apiClient';
  52. import { useConfirm } from '../../contexts/ConfirmContext';
  53. import { useToast } from '../../contexts/ToastContext';
  54. interface SettingsViewProps {
  55. // Model Props
  56. models: ModelConfig[];
  57. authToken: string | null;
  58. onUpdateModels: (action: 'create' | 'update' | 'delete', model: ModelConfig) => Promise<void>;
  59. isAdmin?: boolean; // Added isAdmin prop
  60. currentUser?: any; // Added current user prop
  61. initialTab?: TabType;
  62. }
  63. type TabType = 'general' | 'user' | 'model' | 'tenants' | 'knowledge_base' | 'import_tasks';
  64. const buildTenantTree = (tenants: Tenant[]): Tenant[] => {
  65. const map = new Map<string, Tenant>();
  66. const roots: Tenant[] = [];
  67. tenants.forEach(t => {
  68. map.set(t.id, { ...t, children: [] });
  69. });
  70. tenants.forEach(t => {
  71. const node = map.get(t.id)!;
  72. if (t.parentId && map.has(t.parentId)) {
  73. const parent = map.get(t.parentId)!;
  74. parent.children = parent.children || [];
  75. parent.children.push(node);
  76. } else {
  77. roots.push(node);
  78. }
  79. });
  80. return roots;
  81. };
  82. // Moved outside to prevent re-mounting
  83. const Pagination: React.FC<{
  84. current: number;
  85. total: number;
  86. pageSize: number;
  87. onChange: (page: number) => void;
  88. }> = ({ current, total, pageSize, onChange }) => {
  89. const totalPages = Math.ceil(total / pageSize);
  90. if (totalPages <= 1) return null;
  91. return (
  92. <div className="flex items-center justify-center gap-2 mt-6">
  93. <button
  94. disabled={current === 1}
  95. onClick={() => onChange(current - 1)}
  96. className="p-2 rounded-xl bg-white border border-slate-200 text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-slate-50 transition-colors"
  97. >
  98. <ChevronDown className="w-4 h-4 rotate-90" />
  99. </button>
  100. <div className="flex items-center gap-1">
  101. {[...Array(totalPages)].map((_, i) => {
  102. const p = i + 1;
  103. if (totalPages > 7) {
  104. if (p !== 1 && p !== totalPages && Math.abs(p - current) > 1) {
  105. if (p === 2 || p === totalPages - 1) return <span key={p} className="px-1 font-bold text-slate-300">...</span>;
  106. return null;
  107. }
  108. }
  109. return (
  110. <button
  111. key={p}
  112. onClick={() => onChange(p)}
  113. className={`w-9 h-9 flex items-center justify-center rounded-xl text-xs font-black transition-all ${current === p ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-100' : 'bg-white border border-slate-200 text-slate-500 hover:border-indigo-500 hover:text-indigo-600'}`}
  114. >
  115. {p}
  116. </button>
  117. );
  118. })}
  119. </div>
  120. <button
  121. disabled={current === totalPages}
  122. onClick={() => onChange(current + 1)}
  123. className="p-2 rounded-xl bg-white border border-slate-200 text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-slate-50 transition-colors"
  124. >
  125. <ChevronDown className="w-4 h-4 -rotate-90" />
  126. </button>
  127. </div>
  128. );
  129. };
  130. export const SettingsView: React.FC<SettingsViewProps> = ({
  131. models,
  132. authToken,
  133. onUpdateModels,
  134. isAdmin = false,
  135. currentUser,
  136. initialTab = 'general',
  137. }) => {
  138. const { t, language, setLanguage } = useLanguage();
  139. const { confirm } = useConfirm();
  140. const { showError, showSuccess } = useToast();
  141. const [activeTab, setActiveTab] = useState<TabType>('general');
  142. const [isLoading, setIsLoading] = useState(false);
  143. const [error, setError] = useState<string | null>(null);
  144. // --- Model Manager State ---
  145. const [editingId, setEditingId] = useState<string | null>(null);
  146. const [modelFormData, setModelFormData] = useState<Partial<ModelConfig>>({
  147. type: ModelType.LLM,
  148. baseUrl: 'http://localhost:11434/v1',
  149. modelId: 'llama3',
  150. name: '',
  151. dimensions: 1536,
  152. apiKey: '',
  153. maxInputTokens: 8191,
  154. maxBatchSize: 2048
  155. });
  156. const [users, setUsers] = useState<any[]>([]);
  157. const [isUserLoading, setIsUserLoading] = useState(false);
  158. const [userPage, setUserPage] = useState(1);
  159. const USER_PAGE_SIZE = 20;
  160. const [showAddUser, setShowAddUser] = useState(false);
  161. const [newUser, setNewUser] = useState({ username: '', password: '', displayName: '' });
  162. const [userSuccess, setUserSuccess] = useState('');
  163. // --- Change Password State ---
  164. const [passwordForm, setPasswordForm] = useState({ current: '', new: '', confirm: '' });
  165. const [passwordSuccess, setPasswordSuccess] = useState('');
  166. // --- App Settings State ---
  167. const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
  168. const [knowledgeGroups, setKnowledgeGroups] = useState<KnowledgeGroup[]>([]);
  169. const [isSettingsLoading, setIsSettingsLoading] = useState(false);
  170. const [enabledModelIds, setEnabledModelIds] = useState<string[]>([]);
  171. // --- Tenant Admin Binding Search State ---
  172. const [bindingTenantId, setBindingTenantId] = useState<string | null>(null);
  173. const [userSearchQuery, setUserSearchQuery] = useState('');
  174. // --- Manage Members Modal State ---
  175. const [managingMembersTenantId, setManagingMembersTenantId] = useState<string | null>(null);
  176. const [tenantMembers, setTenantMembers] = useState<any[]>([]);
  177. const [allMemberIds, setAllMemberIds] = useState<Set<string>>(new Set());
  178. const [memberUserSearch, setMemberUserSearch] = useState('');
  179. const [bindingRole, setBindingRole] = useState('USER');
  180. const [currentMemberSearch, setCurrentMemberSearch] = useState('');
  181. const [isMembersLoading, setIsMembersLoading] = useState(false);
  182. const [activeTenantManagementId, setActiveTenantManagementId] = useState<string | null>(null);
  183. const [memberPage, setMemberPage] = useState(1);
  184. const [memberTotal, setMemberTotal] = useState(0);
  185. const MEMBER_PAGE_SIZE = 20;
  186. const [userTotal, setUserTotal] = useState(0);
  187. // --- Tenant Tree & Global Management State ---
  188. const [tenants, setTenants] = useState<Tenant[]>([]);
  189. const [selectedTenantId, setSelectedTenantId] = useState<string | null>(null);
  190. const [stats, setStats] = useState({ users: 0, tenants: 0 });
  191. const [showCreateTenant, setShowCreateTenant] = useState(false);
  192. const [editingTenant, setEditingTenant] = useState<Tenant | null>(null);
  193. const [newTenant, setNewTenant] = useState<{ name: string; domain: string; parentId: string | null }>({
  194. name: '',
  195. domain: '',
  196. parentId: null
  197. });
  198. useEffect(() => {
  199. if (initialTab) {
  200. setActiveTab(initialTab);
  201. }
  202. }, [initialTab]);
  203. useEffect(() => {
  204. if (activeTab === 'user' || activeTab === 'tenants') {
  205. fetchUsers(userPage);
  206. }
  207. }, [userPage]);
  208. useEffect(() => {
  209. if (selectedTenantId) {
  210. fetchTenantMembers(selectedTenantId, memberPage);
  211. fetchAllMemberIds(selectedTenantId);
  212. } else {
  213. setAllMemberIds(new Set());
  214. }
  215. }, [selectedTenantId, memberPage]);
  216. // Data fetching on tab change
  217. useEffect(() => {
  218. // Reset pages when switching tabs to avoid bleed-over
  219. if (activeTab === 'user' || activeTab === 'tenants') {
  220. setUserPage(1);
  221. }
  222. if (activeTab === 'user') {
  223. fetchUsers(1);
  224. } else if (activeTab === 'general') {
  225. fetchSettingsAndGroups();
  226. } else if (activeTab === 'tenants' && currentUser?.role === 'SUPER_ADMIN') {
  227. fetchTenantsData();
  228. fetchUsers(1); // Ensure users are loaded for admin binding
  229. }
  230. // Independent check for KB/Model settings to avoid being blocked by the branches above
  231. if ((activeTab === 'knowledge_base' || activeTab === 'model') &&
  232. (currentUser?.role === 'TENANT_ADMIN' || currentUser?.role === 'SUPER_ADMIN' || isAdmin)) {
  233. fetchKnowledgeBaseSettings();
  234. }
  235. }, [activeTab, currentUser, authToken, isAdmin]);
  236. const [kbSettings, setKbSettings] = useState<any>(null);
  237. const [localKbSettings, setLocalKbSettings] = useState<any>(null);
  238. const [isSavingKbSettings, setIsSavingKbSettings] = useState(false);
  239. const fetchKnowledgeBaseSettings = async () => {
  240. if (!authToken) return;
  241. setIsLoading(true);
  242. try {
  243. const data = await userSettingService.get(authToken);
  244. if (data && Object.keys(data).length > 0) {
  245. setKbSettings(data);
  246. setLocalKbSettings(data);
  247. if (data.selectedVisionId) {
  248. // Vision model ID is part of settings now
  249. }
  250. }
  251. } catch (error) {
  252. console.error(error);
  253. } finally {
  254. setIsLoading(false);
  255. }
  256. };
  257. const handleUpdateKbSettings = (key: string, value: any) => {
  258. setLocalKbSettings((prev: any) => ({ ...prev, [key]: value }));
  259. };
  260. const handleSaveKbSettings = async () => {
  261. if (!authToken || !localKbSettings) return;
  262. setIsSavingKbSettings(true);
  263. try {
  264. await userSettingService.update(authToken, localKbSettings);
  265. setKbSettings(localKbSettings);
  266. showSuccess(t('kbSettingsSaved'));
  267. } catch (error) {
  268. console.error(error);
  269. showError(t('actionFailed'));
  270. } finally {
  271. setIsSavingKbSettings(false);
  272. }
  273. };
  274. const handleCancelKbSettings = () => {
  275. setLocalKbSettings(kbSettings);
  276. };
  277. const fetchSettingsAndGroups = async () => {
  278. if (!authToken) return;
  279. setIsSettingsLoading(true);
  280. try {
  281. const [settings, groups, personal] = await Promise.all([
  282. userSettingService.get(authToken),
  283. knowledgeGroupService.getGroups(),
  284. userSettingService.getPersonal(authToken)
  285. ]);
  286. setAppSettings(settings);
  287. setKnowledgeGroups(groups);
  288. // Sync local language with user settings if they differ
  289. if (personal?.language && personal.language !== language) {
  290. setLanguage(personal.language as any);
  291. }
  292. // Also update KB settings with the same data if not already set
  293. if (settings && Object.keys(settings).length > 0) {
  294. setKbSettings(settings);
  295. setLocalKbSettings(settings);
  296. }
  297. } catch (error) {
  298. console.error('Failed to fetch settings or groups:', error);
  299. } finally {
  300. setIsSettingsLoading(false);
  301. }
  302. };
  303. // --- 一般タブのハンドラー ---
  304. const handleChangePassword = async (e: React.FormEvent) => {
  305. e.preventDefault();
  306. setError(null);
  307. setPasswordSuccess('');
  308. if (passwordForm.new !== passwordForm.confirm) {
  309. setError(t('passwordMismatch'));
  310. return;
  311. }
  312. if (passwordForm.new.length < 6) {
  313. setError(t('newPasswordMinLength'));
  314. return;
  315. }
  316. setIsLoading(true);
  317. try {
  318. await userService.changePassword(passwordForm.current, passwordForm.new);
  319. setPasswordSuccess(t('passwordChangeSuccess'));
  320. setPasswordForm({ current: '', new: '', confirm: '' });
  321. } catch (err: any) {
  322. setError(err.message || t('passwordChangeFailed'));
  323. } finally {
  324. setIsLoading(false);
  325. }
  326. };
  327. // --- ユーザータブのハンドラー ---
  328. const fetchUsers = async (page?: number) => {
  329. setIsUserLoading(true);
  330. const p = page || userPage;
  331. try {
  332. const result = await userService.getUsers(p, USER_PAGE_SIZE);
  333. if (result && result.data) {
  334. setUsers(result.data);
  335. setUserTotal(result.total);
  336. } else if (Array.isArray(result)) {
  337. setUsers(result);
  338. setUserTotal(result.length);
  339. }
  340. } catch (error: any) {
  341. setError(error.message || t('getUserListFailed'));
  342. } finally {
  343. setIsUserLoading(false);
  344. }
  345. };
  346. const handleCreateUser = async (e: React.FormEvent) => {
  347. e.preventDefault();
  348. setError('');
  349. setUserSuccess('');
  350. if (newUser.username && newUser.password && newUser.displayName) {
  351. setIsUserLoading(true);
  352. try {
  353. await userService.createUser(
  354. newUser.username,
  355. newUser.password,
  356. false,
  357. undefined,
  358. newUser.displayName
  359. );
  360. showSuccess(t('userCreatedSuccess'));
  361. setNewUser({ username: '', password: '', displayName: '' });
  362. setShowAddUser(false);
  363. fetchUsers();
  364. } catch (error: any) {
  365. setError(error.message || t('createUserFailed'));
  366. } finally {
  367. setIsUserLoading(false);
  368. }
  369. }
  370. };
  371. const [passwordChangeUserData, setPasswordChangeUserData] = useState<{ userId: string, newPassword: string } | null>(null);
  372. // --- Edit User State ---
  373. const [editUserData, setEditUserData] = useState<{ userId: string, username: string, displayName: string } | null>(null);
  374. const handleToggleUserAdmin = async (userId: string, newAdminStatus: boolean) => {
  375. try {
  376. await userService.updateUser(userId, newAdminStatus);
  377. // ユーザーリストを再取得
  378. fetchUsers();
  379. setUserSuccess(newAdminStatus ? t('userPromotedToAdmin') : t('userDemotedFromAdmin'));
  380. } catch (error: any) {
  381. setError(error.message || t('updateUserFailed'));
  382. }
  383. };
  384. const handleUserPasswordChange = async () => {
  385. if (!passwordChangeUserData || !passwordChangeUserData.newPassword) return;
  386. try {
  387. // Update user password
  388. await userService.updateUserInfo(passwordChangeUserData.userId, { password: passwordChangeUserData.newPassword });
  389. setUserSuccess(t('passwordChangeSuccess'));
  390. setPasswordChangeUserData(null);
  391. fetchUsers(); // Refresh the user list
  392. } catch (error: any) {
  393. setError(error.message || t('passwordChangeFailed'));
  394. }
  395. };
  396. const fetchAllMemberIds = async (tenantId: string) => {
  397. try {
  398. const { data } = await apiClient.get<string[]>(`/v1/tenants/${tenantId}/members/ids`);
  399. if (Array.isArray(data)) {
  400. setAllMemberIds(new Set(data));
  401. }
  402. } catch (e) {
  403. console.error('Failed to fetch all member IDs:', e);
  404. }
  405. };
  406. const fetchTenantMembers = async (tenantId: string, page?: number) => {
  407. setIsMembersLoading(true);
  408. const p = page || memberPage;
  409. try {
  410. const { data } = await apiClient.get(`/v1/tenants/${tenantId}/members?page=${p}&limit=${MEMBER_PAGE_SIZE}`);
  411. if (data && data.data) {
  412. setTenantMembers(data.data);
  413. setMemberTotal(data.total);
  414. } else if (Array.isArray(data)) {
  415. setTenantMembers(data);
  416. setMemberTotal(data.length);
  417. }
  418. } catch (e) {
  419. console.error(e);
  420. } finally {
  421. setIsMembersLoading(false);
  422. }
  423. };
  424. const handleAddMember = async (tenantId: string, userId: string, role: string = 'USER') => {
  425. try {
  426. await apiClient.post(`/v1/tenants/${tenantId}/members`, { userId, role });
  427. setAllMemberIds(prev => {
  428. const next = new Set(prev);
  429. next.add(userId);
  430. return next;
  431. });
  432. showSuccess(t('confirm'));
  433. fetchTenantMembers(tenantId);
  434. fetchTenantsData();
  435. } catch (e: any) {
  436. showError(e.message || 'Error adding member');
  437. }
  438. };
  439. const handleRemoveMember = async (tenantId: string, userId: string) => {
  440. try {
  441. await apiClient.delete(`/v1/tenants/${tenantId}/members/${userId}`);
  442. setAllMemberIds(prev => {
  443. const next = new Set(prev);
  444. next.delete(userId);
  445. return next;
  446. });
  447. showSuccess('User removed from organization');
  448. fetchTenantMembers(tenantId);
  449. fetchTenantsData();
  450. } catch (e: any) {
  451. showError(e.message || 'Error removing member');
  452. }
  453. };
  454. const handleUpdateMemberRole = async (tenantId: string, userId: string, role: string) => {
  455. try {
  456. await apiClient.patch(`/v1/tenants/${tenantId}/members/${userId}`, { role });
  457. showSuccess(t('featureUpdated'));
  458. fetchTenantMembers(tenantId);
  459. } catch (e: any) {
  460. showError(e.message || 'Error updating role');
  461. }
  462. };
  463. const fetchTenantsData = async () => {
  464. if (!authToken) return;
  465. setIsLoading(true);
  466. try {
  467. const [tenRes, admRes] = await Promise.all([
  468. apiClient.get('/v1/tenants'),
  469. apiClient.get('/users?page=1&limit=1')
  470. ]);
  471. const data: Tenant[] = tenRes.data;
  472. const filteredData = data.filter(t => t.name !== 'Default');
  473. setTenants(filteredData);
  474. setStats(s => ({ ...s, tenants: filteredData.length }));
  475. const result = admRes.data;
  476. setStats(s => ({ ...s, users: result.total ?? result.length ?? 0 }));
  477. } catch (e) {
  478. console.error(e);
  479. } finally {
  480. setIsLoading(false);
  481. }
  482. };
  483. const handleCreateTenant = async (e: React.FormEvent) => {
  484. e.preventDefault();
  485. try {
  486. const path = editingTenant ? `/v1/tenants/${editingTenant.id}` : '/v1/tenants';
  487. const body = {
  488. name: newTenant.name,
  489. domain: newTenant.domain,
  490. parentId: newTenant.parentId
  491. };
  492. if (editingTenant) {
  493. await apiClient.put(path, body);
  494. } else {
  495. await apiClient.post(path, body);
  496. }
  497. setShowCreateTenant(false);
  498. setEditingTenant(null);
  499. setNewTenant({ name: '', domain: '', parentId: null });
  500. fetchTenantsData();
  501. showSuccess(editingTenant ? 'Tenant updated' : 'Tenant created');
  502. } catch (e: any) {
  503. showError(e.message || 'Action failed');
  504. }
  505. };
  506. const handleRemoveTenant = async (tenantId: string) => {
  507. if (!(await confirm(t('confirmDeleteTenant')))) return;
  508. try {
  509. await apiClient.delete(`/v1/tenants/${tenantId}`);
  510. setSelectedTenantId(null);
  511. fetchTenantsData();
  512. showSuccess('Tenant deleted');
  513. } catch (e: any) {
  514. showError(e.message || 'Delete failed');
  515. }
  516. };
  517. const handleUpdateUser = async () => {
  518. if (!editUserData) return;
  519. try {
  520. await userService.updateUserInfo(editUserData.userId, {
  521. username: editUserData.username,
  522. displayName: editUserData.displayName
  523. });
  524. showSuccess(t('featureUpdated'));
  525. setEditUserData(null);
  526. fetchUsers();
  527. } catch (error: any) {
  528. showError('Failed to update user');
  529. }
  530. };
  531. const handleDeleteUser = async (userId: string) => {
  532. if (!confirm(t('confirmDeleteUser') || "Are you sure?")) return;
  533. try {
  534. await userService.deleteUser(userId);
  535. showSuccess(t('userDeletedSuccessfully'));
  536. fetchUsers();
  537. } catch (error: any) {
  538. showError(error.message || t('deleteUserFailed'));
  539. }
  540. };
  541. const handleExportUsers = async () => {
  542. try {
  543. const blob = await userService.exportUsers();
  544. const url = window.URL.createObjectURL(blob);
  545. const a = document.createElement('a');
  546. a.href = url;
  547. a.download = `users_${new Date().toISOString().split('T')[0]}.xlsx`;
  548. document.body.appendChild(a);
  549. a.click();
  550. window.URL.revokeObjectURL(url);
  551. document.body.removeChild(a);
  552. } catch (error) {
  553. console.error('Export users failed', error);
  554. showError(t('exportFailed'));
  555. }
  556. };
  557. const handleImportUsers = async (e: React.ChangeEvent<HTMLInputElement>) => {
  558. const file = e.target.files?.[0];
  559. if (!file) return;
  560. try {
  561. const result = await userService.importUsers(file);
  562. showSuccess(t('importSuccess').replace('$1', (result.success || 0).toString()).replace('$2', (result.failed || 0).toString()));
  563. fetchUsers();
  564. if (result.errors.length > 0) {
  565. console.warn('Import had errors:', result.errors);
  566. }
  567. } catch (error: any) {
  568. console.error('Import users failed', error);
  569. showError(t('importFailed') + (error.response?.data?.message ? `: ${error.response.data.message}` : ''));
  570. } finally {
  571. // Reset input
  572. e.target.value = '';
  573. }
  574. };
  575. const handleSaveModel = async () => {
  576. if (!authToken) return;
  577. setIsLoading(true);
  578. try {
  579. await onUpdateModels(editingId === 'new' ? 'create' : 'update', modelFormData as ModelConfig);
  580. setEditingId(null);
  581. } catch (err) {
  582. setError('Update failed');
  583. } finally {
  584. setIsLoading(false);
  585. }
  586. };
  587. const handleToggleModel = async (model: ModelConfig) => {
  588. if (currentUser?.role === 'TENANT_ADMIN') {
  589. const newEnabledIds = enabledModelIds.includes(model.id)
  590. ? enabledModelIds.filter(id => id !== model.id)
  591. : [...enabledModelIds, model.id];
  592. try {
  593. await apiClient.put('/v1/admin/settings', { enabledModelIds: newEnabledIds });
  594. setEnabledModelIds(newEnabledIds);
  595. setLocalKbSettings((prev: any) => ({ ...prev, enabledModelIds: newEnabledIds }));
  596. showSuccess('Updated');
  597. } catch (e: any) {
  598. showError(e.message || 'Update failed');
  599. }
  600. return;
  601. }
  602. await onUpdateModels('update', { ...model, isEnabled: !model.isEnabled });
  603. };
  604. const handleDeleteModel = async (id: string) => {
  605. if (await confirm(t('confirmDeleteModel'))) {
  606. await onUpdateModels('delete', { id } as ModelConfig);
  607. }
  608. };
  609. const TenantTreeNode: React.FC<{
  610. tenant: Tenant;
  611. selectedTenantId: string | null;
  612. onSelect: (id: string) => void;
  613. onCreateSubtenant: (parentId: string) => void;
  614. depth?: number;
  615. }> = ({ tenant, selectedTenantId, onSelect, onCreateSubtenant, depth = 0 }) => {
  616. const [collapsed, setCollapsed] = useState(false);
  617. const hasChildren = tenant.children && tenant.children.length > 0;
  618. const isSelected = selectedTenantId === tenant.id;
  619. return (
  620. <div className="select-none">
  621. <div
  622. className={`group flex items-center gap-2 px-3 py-2 rounded-xl cursor-pointer transition-all ${isSelected ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-100' : 'hover:bg-slate-50 text-slate-600 hover:text-slate-900'}`}
  623. style={{ paddingLeft: `${depth * 1.5 + 0.75}rem` }}
  624. onClick={() => onSelect(tenant.id)}
  625. >
  626. <div className="flex items-center gap-2 flex-1 min-w-0">
  627. {hasChildren ? (
  628. <button
  629. onClick={(e) => { e.stopPropagation(); setCollapsed(!collapsed); }}
  630. className={`p-0.5 rounded-md hover:bg-black/10 transition-colors ${isSelected ? 'text-white/70' : 'text-slate-400'}`}
  631. >
  632. {collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
  633. </button>
  634. ) : (
  635. <div className="w-5" />
  636. )}
  637. <Building size={16} className={isSelected ? 'text-white' : 'text-slate-400'} />
  638. <span className="text-sm font-bold truncate">{tenant.name}</span>
  639. </div>
  640. <button
  641. onClick={(e) => {
  642. e.stopPropagation();
  643. onCreateSubtenant(tenant.id);
  644. }}
  645. className={`p-1.5 rounded-lg opacity-0 group-hover:opacity-100 transition-all ${isSelected ? 'hover:bg-white/20 text-white' : 'hover:bg-slate-200 text-slate-400 hover:text-indigo-600'}`}
  646. title={t('createSubOrg')}
  647. >
  648. <Plus size={14} />
  649. </button>
  650. </div>
  651. {hasChildren && !collapsed && (
  652. <div className="mt-1">
  653. {tenant.children?.map(child => (
  654. <TenantTreeNode
  655. key={child.id}
  656. tenant={child}
  657. selectedTenantId={selectedTenantId}
  658. onSelect={onSelect}
  659. onCreateSubtenant={onCreateSubtenant}
  660. depth={depth + 1}
  661. />
  662. ))}
  663. </div>
  664. )}
  665. </div>
  666. );
  667. };
  668. const getTypeLabel = (type: ModelType) => {
  669. switch (type) {
  670. case ModelType.LLM: return t('typeLLM');
  671. case ModelType.EMBEDDING: return t('typeEmbedding');
  672. case ModelType.RERANK: return t('typeRerank');
  673. }
  674. };
  675. // --- レンダリング関数 ---
  676. const renderGeneralTab = () => (
  677. <div className="space-y-8 animate-in slide-in-from-right duration-300 max-w-2xl">
  678. {/* パスワード変更セクション */}
  679. <section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
  680. <h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
  681. <Key className="w-4 h-4 text-blue-500" />
  682. {t('changePassword')}
  683. </h3>
  684. <form onSubmit={handleChangePassword} className="space-y-4 max-w-sm">
  685. <input type="hidden" name="username" value="current-user" autoComplete="username" />
  686. <div>
  687. <input
  688. type="password"
  689. name="currentPassword"
  690. placeholder={t('currentPassword')}
  691. value={passwordForm.current}
  692. onChange={e => setPasswordForm({ ...passwordForm, current: e.target.value })}
  693. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  694. required
  695. autoComplete="current-password"
  696. />
  697. </div>
  698. <div>
  699. <input
  700. type="password"
  701. name="newPassword"
  702. placeholder={t('newPassword')}
  703. value={passwordForm.new}
  704. onChange={e => setPasswordForm({ ...passwordForm, new: e.target.value })}
  705. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  706. required
  707. autoComplete="new-password"
  708. />
  709. </div>
  710. <div>
  711. <input
  712. type="password"
  713. name="confirmPassword"
  714. placeholder={t('confirmPassword')}
  715. value={passwordForm.confirm}
  716. onChange={e => setPasswordForm({ ...passwordForm, confirm: e.target.value })}
  717. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  718. required
  719. autoComplete="new-password"
  720. />
  721. </div>
  722. {passwordSuccess && <p className="text-xs text-green-600">{passwordSuccess}</p>}
  723. <button
  724. type="submit"
  725. disabled={isLoading}
  726. className="w-full py-2 bg-slate-800 text-white rounded-md text-sm hover:bg-slate-900 transition-colors disabled:opacity-50"
  727. >
  728. {isLoading ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> : t('confirmChange')}
  729. </button>
  730. </form>
  731. </section>
  732. {/* 语言设置セクション */}
  733. <section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
  734. <h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
  735. <Globe className="w-4 h-4 text-blue-500" />
  736. {t('languageSettings')}
  737. </h3>
  738. <div className="space-y-4 max-w-sm">
  739. <div className="space-y-2">
  740. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">
  741. {t('switchLanguage')}
  742. </label>
  743. <select
  744. value={language}
  745. onChange={async (e) => {
  746. const newLang = e.target.value as any;
  747. setLanguage(newLang);
  748. try {
  749. await settingsService.updateLanguage(newLang);
  750. showSuccess(t('confirm'));
  751. } catch (err) {
  752. console.error('Failed to update backend language preference:', err);
  753. }
  754. }}
  755. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none bg-white"
  756. >
  757. <option value="en">English</option>
  758. <option value="zh">中文 (Chinese)</option>
  759. <option value="ja">日本語 (Japanese)</option>
  760. </select>
  761. </div>
  762. </div>
  763. </section>
  764. </div >
  765. );
  766. const renderUserTab = () => (
  767. <div className="space-y-6 w-full">
  768. <div className="flex justify-between items-center mb-6">
  769. <div>
  770. <p className="text-xs text-slate-400 font-medium">{''}</p>
  771. </div>
  772. {currentUser?.role === 'SUPER_ADMIN' && (
  773. <div className="flex gap-2">
  774. <button
  775. onClick={handleExportUsers}
  776. className="flex items-center gap-2 px-4 py-2.5 bg-white border border-slate-200 text-slate-600 text-sm font-bold rounded-2xl hover:bg-slate-50 shadow-sm transition-all active:scale-95"
  777. title={t('exportUsers')}
  778. >
  779. <Download className="w-4 h-4" />
  780. <span className="hidden sm:inline">{t('exportUsers')}</span>
  781. </button>
  782. <div className="relative">
  783. <input
  784. type="file"
  785. accept=".xlsx,.xls,.csv"
  786. onChange={handleImportUsers}
  787. className="absolute inset-0 opacity-0 cursor-pointer"
  788. title={t('importUsers')}
  789. />
  790. <button
  791. className="flex items-center gap-2 px-4 py-2.5 bg-white border border-slate-200 text-slate-600 text-sm font-bold rounded-2xl hover:bg-slate-50 shadow-sm transition-all active:scale-95"
  792. >
  793. <Upload className="w-4 h-4" />
  794. <span className="hidden sm:inline">{t('importUsers')}</span>
  795. </button>
  796. </div>
  797. <button
  798. onClick={() => setShowAddUser(!showAddUser)}
  799. className="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white text-sm font-bold rounded-2xl hover:bg-indigo-700 shadow-lg shadow-indigo-100 transition-all active:scale-95"
  800. >
  801. <Plus className="w-4 h-4" />
  802. {t('addUser')}
  803. </button>
  804. </div>
  805. )}
  806. </div>
  807. {showAddUser && (
  808. <motion.form
  809. initial={{ opacity: 0, y: -10 }}
  810. animate={{ opacity: 1, y: 0 }}
  811. onSubmit={handleCreateUser}
  812. className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-xl mb-8 space-y-5"
  813. >
  814. <div className="grid grid-cols-1 md:grid-cols-3 gap-5">
  815. <input
  816. type="text"
  817. placeholder={t('usernamePlaceholder')}
  818. value={newUser.username}
  819. onChange={e => setNewUser({ ...newUser, username: e.target.value })}
  820. className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
  821. required
  822. autoComplete="username"
  823. />
  824. <input
  825. type="text"
  826. placeholder={t('displayNamePlaceholder') || t('name')}
  827. value={newUser.displayName}
  828. onChange={e => setNewUser({ ...newUser, displayName: e.target.value })}
  829. className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
  830. required
  831. autoComplete="name"
  832. />
  833. <input
  834. type="password"
  835. placeholder={t('passwordPlaceholder')}
  836. value={newUser.password}
  837. onChange={e => setNewUser({ ...newUser, password: e.target.value })}
  838. className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
  839. required
  840. autoComplete="new-password"
  841. />
  842. </div>
  843. <div className="flex items-center justify-between">
  844. <div></div>
  845. <div className="flex gap-3">
  846. <button type="button" onClick={() => setShowAddUser(false)} className="px-5 py-2 text-xs font-bold text-slate-500 hover:text-slate-700">{t('cancel')}</button>
  847. <button type="submit" className="px-8 py-2 bg-slate-900 text-white rounded-xl text-xs font-black uppercase tracking-widest hover:bg-indigo-600 transition-all shadow-lg shadow-slate-100">{t('create')}</button>
  848. </div>
  849. </div>
  850. </motion.form>
  851. )}
  852. {passwordChangeUserData && (
  853. <div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm flex items-center justify-center z-[100] p-6">
  854. <motion.div
  855. initial={{ scale: 0.95, opacity: 0 }}
  856. animate={{ scale: 1, opacity: 1 }}
  857. className="bg-white rounded-3xl p-10 w-full max-w-md shadow-2xl border border-white/20"
  858. >
  859. <div className="flex items-center justify-between mb-8">
  860. <h3 className="text-xl font-black text-slate-900 tracking-tight">{t('changeUserPassword')}</h3>
  861. <button onClick={() => setPasswordChangeUserData(null)} className="p-2 hover:bg-slate-100 rounded-xl transition-all">
  862. <X size={20} className="text-slate-400" />
  863. </button>
  864. </div>
  865. <form onSubmit={(e) => { e.preventDefault(); handleUserPasswordChange(); }} className="space-y-6">
  866. <div>
  867. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
  868. {t('newPassword')}
  869. </label>
  870. <input
  871. type="password"
  872. value={passwordChangeUserData.newPassword}
  873. onChange={(e) => setPasswordChangeUserData({ ...passwordChangeUserData, newPassword: e.target.value })}
  874. className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-[14px] font-medium transition-all focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50"
  875. placeholder={t('enterNewPassword')}
  876. required
  877. />
  878. </div>
  879. <div className="flex gap-4 pt-4">
  880. <button type="button" onClick={() => setPasswordChangeUserData(null)} className="flex-1 py-3.5 text-slate-500 font-bold text-sm">{t('cancel')}</button>
  881. <button type="submit" className="flex-1 py-3.5 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-indigo-600 shadow-xl shadow-slate-100 transition-all">{t('confirmChange')}</button>
  882. </div>
  883. </form>
  884. </motion.div>
  885. </div>
  886. )}
  887. {/* Edit User Modal */}
  888. {editUserData && (
  889. <div className="fixed inset-0 z-[110] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4">
  890. <motion.div
  891. initial={{ scale: 0.95, opacity: 0 }}
  892. animate={{ scale: 1, opacity: 1 }}
  893. className="bg-white rounded-3xl p-10 w-full max-w-md shadow-2xl border border-white/20"
  894. >
  895. <div className="flex items-center justify-between mb-8">
  896. <h3 className="text-xl font-black text-slate-900 tracking-tight">{t('editUser')}</h3>
  897. <button onClick={() => setEditUserData(null)} className="p-2 hover:bg-slate-100 rounded-xl transition-all">
  898. <X size={20} className="text-slate-400" />
  899. </button>
  900. </div>
  901. <form onSubmit={(e) => { e.preventDefault(); handleUpdateUser(); }} className="space-y-6">
  902. <div>
  903. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
  904. {t('username')}
  905. </label>
  906. <input
  907. type="text"
  908. value={editUserData.username}
  909. onChange={(e) => setEditUserData({ ...editUserData, username: e.target.value })}
  910. className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-[14px] font-medium transition-all focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50"
  911. placeholder={t('usernamePlaceholder')}
  912. required
  913. />
  914. </div>
  915. <div>
  916. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
  917. {t('displayName') || t('name')}
  918. </label>
  919. <input
  920. type="text"
  921. value={editUserData.displayName}
  922. onChange={(e) => setEditUserData({ ...editUserData, displayName: e.target.value })}
  923. className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-[14px] font-medium transition-all focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50"
  924. placeholder={t('displayNamePlaceholder') || t('namePlaceholder')}
  925. required
  926. />
  927. </div>
  928. <div className="py-2.5 px-4 bg-indigo-50 rounded-2xl border border-indigo-100/50">
  929. <p className="text-[10px] font-black text-indigo-500 uppercase tracking-widest mb-1">{t('globalUserNote') || "Note"}</p>
  930. <p className="text-[11px] text-indigo-700/70 leading-relaxed font-medium">
  931. {t('roleManagedInOrg') || "Roles are managed within organizations."}
  932. </p>
  933. </div>
  934. <div className="flex gap-4 pt-4">
  935. <button type="button" onClick={() => setEditUserData(null)} className="flex-1 py-3.5 text-slate-500 font-bold text-sm">{t('cancel')}</button>
  936. <button type="submit" className="flex-1 py-3.5 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-indigo-600 shadow-xl shadow-slate-100 transition-all">{t('saveChanges')}</button>
  937. </div>
  938. </form>
  939. </motion.div>
  940. </div>
  941. )}
  942. <div className="w-full bg-white/70 backdrop-blur-md border border-slate-200/50 rounded-2xl overflow-hidden shadow-sm">
  943. <table className="w-full border-collapse text-left">
  944. <thead>
  945. <tr className="bg-slate-50/50 border-b border-slate-200/50">
  946. <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('username')}</th>
  947. <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('displayName') || t('name')}</th>
  948. <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('organizations')}</th>
  949. <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('createdAt')}</th>
  950. <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest text-right">{t('actions')}</th>
  951. </tr>
  952. </thead>
  953. <tbody className="divide-y divide-slate-100">
  954. <AnimatePresence>
  955. {users.filter((u: any) => u.username !== 'admin').map((user: any, index: number) => {
  956. let IconComponent = User;
  957. let iconColors = 'bg-slate-50 text-slate-400';
  958. if (user.isAdmin) {
  959. IconComponent = Shield;
  960. iconColors = 'bg-red-50 text-red-600';
  961. }
  962. return (
  963. <motion.tr
  964. key={user.id}
  965. initial={{ opacity: 0, y: 10 }}
  966. animate={{ opacity: 1, y: 0 }}
  967. transition={{ delay: index * 0.03 }}
  968. className="group hover:bg-slate-50/50 transition-all"
  969. >
  970. <td className="px-6 py-4">
  971. <div className="flex items-center gap-3">
  972. <div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${iconColors}`}>
  973. <IconComponent size={18} />
  974. </div>
  975. <div className="min-w-0">
  976. <p className="font-bold text-slate-900 truncate">{user.username}</p>
  977. </div>
  978. </div>
  979. </td>
  980. <td className="px-6 py-4">
  981. <p className="text-sm text-slate-600 truncate">{user.displayName || '-'}</p>
  982. </td>
  983. <td className="px-6 py-4">
  984. {user.tenantMembers && user.tenantMembers.length > 0 ? (
  985. <div className="flex flex-wrap gap-1">
  986. {user.tenantMembers
  987. .filter((m: any) => m.tenant?.name !== 'Default')
  988. .map((m: any) => (
  989. <span
  990. key={m.tenantId}
  991. className="inline-flex items-center gap-1 px-2 py-0.5 bg-emerald-50 text-emerald-700 text-[9px] font-black rounded-md uppercase tracking-wider border border-emerald-100"
  992. >
  993. <Building size={8} />
  994. {m.tenant?.name || m.tenantId}
  995. </span>
  996. ))}
  997. </div>
  998. ) : (
  999. <span className="text-[10px] text-slate-400 italic">{t('noOrganization')}</span>
  1000. )}
  1001. </td>
  1002. <td className="px-6 py-4">
  1003. <p className="text-[11px] font-medium text-slate-600">
  1004. {new Date(user.createdAt).toLocaleDateString()}
  1005. </p>
  1006. </td>
  1007. <td className="px-6 py-4 text-right">
  1008. <div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-all">
  1009. {user.username !== 'admin' && (
  1010. <>
  1011. <button
  1012. onClick={(e) => {
  1013. e.preventDefault();
  1014. e.stopPropagation();
  1015. setEditUserData({
  1016. userId: user.id,
  1017. username: user.username,
  1018. displayName: user.displayName || ''
  1019. });
  1020. }}
  1021. className="p-2 rounded-lg text-slate-400 hover:text-indigo-600 hover:bg-white shadow-sm transition-all"
  1022. title={t('edit')}
  1023. >
  1024. <Edit2 className="w-4 h-4" />
  1025. </button>
  1026. <button
  1027. onClick={() => setPasswordChangeUserData({ userId: user.id, newPassword: '' })}
  1028. className="p-2 rounded-lg text-slate-400 hover:text-indigo-600 hover:bg-white shadow-sm transition-all"
  1029. title={t('changeUserPassword')}
  1030. >
  1031. <Key className="w-4 h-4" />
  1032. </button>
  1033. {user.id !== currentUser?.id && (
  1034. <button
  1035. onClick={() => handleDeleteUser(user.id)}
  1036. className="p-2 rounded-lg text-slate-400 hover:text-red-500 hover:bg-white shadow-sm transition-all"
  1037. title={t('deleteUser')}
  1038. >
  1039. <Trash2 className="w-4 h-4" />
  1040. </button>
  1041. )}
  1042. </>
  1043. )}
  1044. </div>
  1045. </td>
  1046. </motion.tr>
  1047. );
  1048. })}
  1049. </AnimatePresence>
  1050. </tbody>
  1051. </table>
  1052. </div>
  1053. <Pagination
  1054. current={userPage}
  1055. total={userTotal}
  1056. pageSize={USER_PAGE_SIZE}
  1057. onChange={setUserPage}
  1058. />
  1059. </div>
  1060. );
  1061. const renderTenantsTab = () => {
  1062. const tenantTree = buildTenantTree(tenants);
  1063. const activeTenant = selectedTenantId ? tenants.find(t => t.id === selectedTenantId) : null;
  1064. return (
  1065. <div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-12rem)] animate-in fade-in duration-500">
  1066. {/* Left: Organization Tree */}
  1067. <div className="w-full lg:w-80 flex flex-col bg-white border border-slate-200 rounded-3xl shadow-sm overflow-hidden min-h-[400px] lg:min-h-0">
  1068. <div className="p-6 border-b border-slate-100 flex items-center justify-between shrink-0">
  1069. <div>
  1070. <h3 className="font-black text-slate-900 text-lg tracking-tight">{t('orgManagement')}</h3>
  1071. <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{t('globalTenantControl')}</p>
  1072. </div>
  1073. <button
  1074. onClick={() => {
  1075. setNewTenant({ name: '', domain: '', parentId: null });
  1076. setEditingTenant(null);
  1077. setShowCreateTenant(true);
  1078. }}
  1079. className="p-2 bg-slate-900 text-white rounded-xl hover:bg-slate-700 transition-all"
  1080. >
  1081. <Plus size={18} />
  1082. </button>
  1083. </div>
  1084. <div className="flex-1 overflow-y-auto p-4 space-y-1 custom-scrollbar">
  1085. {tenantTree.length > 0 ? (
  1086. tenantTree.map(t => (
  1087. <TenantTreeNode
  1088. key={t.id}
  1089. tenant={t}
  1090. selectedTenantId={selectedTenantId}
  1091. onSelect={(id) => {
  1092. if (id !== selectedTenantId) {
  1093. setSelectedTenantId(id);
  1094. setMemberPage(1);
  1095. setUserPage(1);
  1096. }
  1097. }}
  1098. onCreateSubtenant={(parentId) => {
  1099. setNewTenant({ name: '', domain: '', parentId });
  1100. setEditingTenant(null);
  1101. setShowCreateTenant(true);
  1102. }}
  1103. />
  1104. ))
  1105. ) : (
  1106. <div className="py-20 text-center">
  1107. <Building size={32} className="mx-auto text-slate-200 mb-3" />
  1108. <p className="text-xs font-black text-slate-300 uppercase tracking-widest">{t('noOrganizations')}</p>
  1109. </div>
  1110. )}
  1111. </div>
  1112. <div className="p-4 bg-slate-50 border-t border-slate-100 shrink-0">
  1113. <div className="flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-2xl shadow-sm">
  1114. <div className="w-10 h-10 rounded-xl bg-blue-50 flex items-center justify-center text-blue-600">
  1115. <Building size={20} />
  1116. </div>
  1117. <div>
  1118. <p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('totalTenants')}</p>
  1119. <p className="text-xl font-black text-slate-900">{stats.tenants}</p>
  1120. </div>
  1121. </div>
  1122. </div>
  1123. </div>
  1124. {/* Right: User List & Management */}
  1125. <div className="flex-1 flex flex-col bg-white border border-slate-200 rounded-3xl shadow-xl overflow-hidden min-h-[600px] lg:min-h-0">
  1126. {activeTenant ? (
  1127. <div className="flex flex-col h-full">
  1128. {/* Organization Header */}
  1129. <div className="p-8 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between shrink-0">
  1130. <div className="flex items-center gap-4">
  1131. <div className="w-14 h-14 rounded-2xl bg-white border border-slate-200 shadow-sm flex items-center justify-center text-indigo-600">
  1132. <Building size={28} />
  1133. </div>
  1134. <div>
  1135. <div className="flex items-center gap-2">
  1136. <h2 className="text-2xl font-black text-slate-900 tracking-tight">{activeTenant.name}</h2>
  1137. <span className="px-2 py-0.5 bg-indigo-50 text-indigo-600 rounded text-[9px] font-black uppercase tracking-widest border border-indigo-100">{t('activeOrg')}</span>
  1138. </div>
  1139. <p className="text-xs font-medium text-slate-400">{activeTenant.domain || t('noCustomDomain')}</p>
  1140. </div>
  1141. </div>
  1142. <div className="flex items-center gap-2">
  1143. <button
  1144. onClick={() => {
  1145. setEditingTenant(activeTenant);
  1146. setNewTenant({
  1147. name: activeTenant.name,
  1148. domain: activeTenant.domain || '',
  1149. parentId: activeTenant.parentId || null
  1150. });
  1151. setShowCreateTenant(true);
  1152. }}
  1153. className="p-2.5 bg-white border border-slate-200 text-slate-600 rounded-xl hover:border-indigo-500 hover:text-indigo-600 transition-all shadow-sm"
  1154. title={t('orgSettings')}
  1155. >
  1156. <SettingsIcon size={18} />
  1157. </button>
  1158. <button
  1159. onClick={() => handleRemoveTenant(activeTenant.id)}
  1160. className="p-2.5 bg-white border border-slate-200 text-slate-400 hover:text-red-500 hover:border-red-500 transition-all shadow-sm"
  1161. title={t('deleteOrg')}
  1162. >
  1163. <Trash2 size={18} />
  1164. </button>
  1165. </div>
  1166. </div>
  1167. {/* Main Content Split: Members vs All Users */}
  1168. <div className="flex-1 flex overflow-hidden">
  1169. {/* Current Members */}
  1170. <div className="flex-1 flex flex-col border-r border-slate-100 overflow-hidden">
  1171. <div className="p-6 border-b border-slate-50 flex items-center justify-between shrink-0">
  1172. <h4 className="text-xs font-black text-slate-400 uppercase tracking-widest">{t('orgMembers')}</h4>
  1173. <span className="text-[10px] font-black px-2 py-0.5 bg-slate-100 text-slate-500 rounded-full">
  1174. {t('membersCount').replace('$1', (memberTotal || 0).toString())}
  1175. </span>
  1176. </div>
  1177. <div className="flex-1 overflow-y-auto p-6 scrollbar-hide">
  1178. <div className="grid grid-cols-1 gap-3">
  1179. {tenantMembers?.map((m: any) => (
  1180. <div key={m.id} className="p-4 bg-slate-50/50 border border-slate-100 rounded-2xl flex items-center justify-between group hover:bg-white hover:shadow-sm transition-all hover:border-slate-200">
  1181. <div className="flex items-center gap-3 min-w-0">
  1182. <div className="w-10 h-10 rounded-xl bg-white border border-slate-200 flex items-center justify-center text-slate-400 shadow-sm">
  1183. <User size={18} />
  1184. </div>
  1185. <div className="min-w-0">
  1186. <p className="text-sm font-black text-slate-900 truncate">{m.user?.username || m.userId}</p>
  1187. <select
  1188. value={m.role}
  1189. onChange={(e) => handleUpdateMemberRole(activeTenant.id, m.userId, e.target.value)}
  1190. className={`text-[9px] font-black uppercase tracking-widest bg-transparent border-none outline-none cursor-pointer hover:bg-slate-100 rounded px-1 transition-colors ${m.role === 'TENANT_ADMIN' ? 'text-indigo-500' : 'text-slate-400'}`}
  1191. >
  1192. <option value="USER">{t('roleRegularUser')}</option>
  1193. <option value="TENANT_ADMIN">{t('roleTenantAdmin')}</option>
  1194. </select>
  1195. </div>
  1196. </div>
  1197. <button
  1198. onClick={() => handleRemoveMember(activeTenant.id, m.userId)}
  1199. className="p-2 text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
  1200. >
  1201. <Trash2 size={14} />
  1202. </button>
  1203. </div>
  1204. ))}
  1205. {(!tenantMembers || tenantMembers.length === 0) && (
  1206. <div className="py-20 text-center">
  1207. <Users size={24} className="mx-auto text-slate-200 mb-2" />
  1208. <p className="text-[10px] font-bold text-slate-300 uppercase tracking-wider">{t('noMembersAssigned')}</p>
  1209. </div>
  1210. )}
  1211. </div>
  1212. <Pagination
  1213. current={memberPage}
  1214. total={memberTotal}
  1215. pageSize={MEMBER_PAGE_SIZE}
  1216. onChange={setMemberPage}
  1217. />
  1218. </div>
  1219. </div>
  1220. {/* Add New Users (Right side of specific tenant view) */}
  1221. <div className="w-72 flex flex-col bg-slate-50/30 overflow-hidden shrink-0">
  1222. <div className="p-6 border-b border-slate-100 shrink-0">
  1223. <h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3">{t('addMembers')}</h4>
  1224. <div className="relative">
  1225. <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
  1226. <input
  1227. className="w-full pl-9 pr-3 py-2 bg-white border border-slate-200 rounded-xl text-xs outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
  1228. placeholder={t('searchSystemUsers')}
  1229. value={userSearchQuery}
  1230. onChange={e => setUserSearchQuery(e.target.value)}
  1231. />
  1232. </div>
  1233. <div className="mt-3 flex gap-1 p-1 bg-white border border-slate-200 rounded-xl">
  1234. <button
  1235. onClick={() => setBindingRole('USER')}
  1236. className={`flex-1 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${bindingRole === 'USER' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
  1237. >
  1238. {t('roleRegularUser')}
  1239. </button>
  1240. <button
  1241. onClick={() => setBindingRole('TENANT_ADMIN')}
  1242. className={`flex-1 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${bindingRole === 'TENANT_ADMIN' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
  1243. >
  1244. {t('roleTenantAdmin')}
  1245. </button>
  1246. </div>
  1247. </div>
  1248. <div className="flex-1 overflow-y-auto p-4 space-y-2">
  1249. {users
  1250. .filter(u =>
  1251. u.username !== 'admin' &&
  1252. u.username.toLowerCase().includes(userSearchQuery.toLowerCase())
  1253. )
  1254. .map(u => {
  1255. const isAlreadyMember = allMemberIds.has(u.id);
  1256. return (
  1257. <button
  1258. key={u.id}
  1259. onClick={() => !isAlreadyMember && handleAddMember(activeTenant.id, u.id, bindingRole)}
  1260. disabled={isAlreadyMember}
  1261. className={`w-full p-3 border rounded-xl flex items-center justify-between group transition-all ${isAlreadyMember
  1262. ? 'bg-slate-50 border-slate-100 cursor-not-allowed opacity-60'
  1263. : 'bg-white border-slate-100 hover:border-indigo-500 hover:shadow-sm'
  1264. }`}
  1265. >
  1266. <div className="flex items-center gap-2 min-w-0">
  1267. <div className={`w-8 h-8 rounded-lg flex items-center justify-center transition-colors ${isAlreadyMember
  1268. ? 'bg-slate-100 text-slate-300'
  1269. : 'bg-slate-50 text-slate-400 group-hover:bg-indigo-50 group-hover:text-indigo-600'
  1270. }`}>
  1271. <User size={14} />
  1272. </div>
  1273. <span className={`text-[13px] font-bold truncate ${isAlreadyMember ? 'text-slate-400' : 'text-slate-700'}`}>
  1274. {u.username}
  1275. </span>
  1276. </div>
  1277. {isAlreadyMember ? (
  1278. <Check size={14} className="text-emerald-500" />
  1279. ) : (
  1280. <Plus size={14} className="text-slate-300 group-hover:text-indigo-600" />
  1281. )}
  1282. </button>
  1283. );
  1284. })
  1285. }
  1286. <Pagination
  1287. current={userPage}
  1288. total={userTotal}
  1289. pageSize={USER_PAGE_SIZE}
  1290. onChange={setUserPage}
  1291. />
  1292. </div>
  1293. </div>
  1294. </div>
  1295. {/* Create Tenant Modal (Nested in Tab Content for scope) */}
  1296. {showCreateTenant && (
  1297. <div className="fixed inset-0 z-[120] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4 text-left">
  1298. <motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="bg-white rounded-3xl p-8 w-full max-w-md shadow-2xl">
  1299. <h3 className="text-xl font-black text-slate-900 mb-6">{editingTenant ? t('editOrg') : t('newTenant')}</h3>
  1300. <form onSubmit={handleCreateTenant} className="space-y-5">
  1301. <div>
  1302. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('tenantName')}</label>
  1303. <input className="w-full mt-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm" placeholder={t('tenantName')} value={newTenant.name} onChange={e => setNewTenant({ ...newTenant, name: e.target.value })} required />
  1304. </div>
  1305. <div>
  1306. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('domainOptional')}</label>
  1307. <input className="w-full mt-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm" placeholder={t('domainOptional')} value={newTenant.domain} onChange={e => setNewTenant({ ...newTenant, domain: e.target.value })} />
  1308. </div>
  1309. <div>
  1310. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('parentOrg')}</label>
  1311. {!editingTenant ? (
  1312. <div className="w-full mt-1 px-4 py-3 bg-slate-100 border border-slate-200 rounded-2xl text-sm text-slate-500 font-bold">
  1313. {newTenant.parentId ? tenants.find(t => t.id === newTenant.parentId)?.name : t('noneRoot')}
  1314. </div>
  1315. ) : (
  1316. <select
  1317. className="w-full mt-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-indigo-500/20"
  1318. value={newTenant.parentId || ''}
  1319. onChange={e => setNewTenant({ ...newTenant, parentId: e.target.value || null })}
  1320. >
  1321. <option value="">{t('noneRoot')}</option>
  1322. {tenants.filter(t => t.id !== editingTenant?.id).map(t => (
  1323. <option key={t.id} value={t.id}>{t.name}</option>
  1324. ))}
  1325. </select>
  1326. )}
  1327. </div>
  1328. <div className="flex gap-4 pt-2">
  1329. <button type="button" onClick={() => { setShowCreateTenant(false); setEditingTenant(null); }} className="flex-1 py-3 text-slate-500 font-bold text-sm">{t('cancel')}</button>
  1330. <button type="submit" className="flex-1 py-3 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-indigo-600">{editingTenant ? t('update') : t('create')}</button>
  1331. </div>
  1332. </form>
  1333. </motion.div>
  1334. </div>
  1335. )}
  1336. </div>
  1337. ) : (
  1338. <div className="flex-1 flex flex-col items-center justify-center p-12 text-center bg-slate-50/30">
  1339. <div className="w-24 h-24 rounded-[2rem] bg-indigo-50 flex items-center justify-center text-indigo-400 mb-6 shadow-xl shadow-indigo-100/50">
  1340. <Building size={48} />
  1341. </div>
  1342. <h2 className="text-2xl font-black text-slate-900 tracking-tight mb-2">{t('selectOrg')}</h2>
  1343. <p className="text-sm text-slate-400 max-w-xs font-medium leading-relaxed">{t('selectOrgDesc')}</p>
  1344. <div className="mt-12 grid grid-cols-2 gap-4 w-full max-w-lg">
  1345. <div className="p-6 bg-white border border-slate-200 rounded-3xl text-left shadow-sm">
  1346. <div className="w-10 h-10 rounded-xl bg-orange-50 flex items-center justify-center text-orange-600 mb-4">
  1347. <Users size={20} />
  1348. </div>
  1349. <p className="text-xl font-black text-slate-900">{stats.users}</p>
  1350. <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">{t('totalSystemUsers')}</p>
  1351. </div>
  1352. <div className="p-6 bg-white border border-slate-200 rounded-3xl text-left shadow-sm">
  1353. <div className="w-10 h-10 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-600 mb-4">
  1354. <Shield size={20} />
  1355. </div>
  1356. <p className="text-xl font-black text-slate-900">{tenants.filter(t => t.parentId === null).length}</p>
  1357. <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">{t('rootOrgs')}</p>
  1358. </div>
  1359. </div>
  1360. {/* Scope Modal for Create even when no selection */}
  1361. {showCreateTenant && (
  1362. <div className="fixed inset-0 z-[120] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4 text-left">
  1363. <motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="bg-white rounded-3xl p-8 w-full max-w-md shadow-2xl">
  1364. <h3 className="text-xl font-black text-slate-900 mb-6">{t('newTenant')}</h3>
  1365. <form onSubmit={handleCreateTenant} className="space-y-5">
  1366. <div>
  1367. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('tenantName')}</label>
  1368. <input className="w-full mt-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm" placeholder={t('tenantName')} value={newTenant.name} onChange={e => setNewTenant({ ...newTenant, name: e.target.value })} required />
  1369. </div>
  1370. <div>
  1371. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('domainOptional')}</label>
  1372. <input className="w-full mt-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm" placeholder={t('domainOptional')} value={newTenant.domain} onChange={e => setNewTenant({ ...newTenant, domain: e.target.value })} />
  1373. </div>
  1374. <div>
  1375. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('parentOrg')}</label>
  1376. <select
  1377. className="w-full mt-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-indigo-500/20"
  1378. value={newTenant.parentId || ''}
  1379. onChange={e => setNewTenant({ ...newTenant, parentId: e.target.value || null })}
  1380. >
  1381. <option value="">{t('noneRoot')}</option>
  1382. {tenants.map(t => (
  1383. <option key={t.id} value={t.id}>{t.name}</option>
  1384. ))}
  1385. </select>
  1386. </div>
  1387. <div className="flex gap-4 pt-2">
  1388. <button
  1389. type="button"
  1390. onClick={() => {
  1391. setShowCreateTenant(false);
  1392. setNewTenant({ name: '', domain: '', parentId: null });
  1393. }}
  1394. className="flex-1 py-3 text-slate-500 font-bold text-sm"
  1395. >
  1396. {t('cancel')}
  1397. </button>
  1398. <button type="submit" className="flex-1 py-3 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-indigo-600">{t('create')}</button>
  1399. </div>
  1400. </form>
  1401. </motion.div>
  1402. </div>
  1403. )}
  1404. </div>
  1405. )}
  1406. </div>
  1407. </div>
  1408. );
  1409. };
  1410. const renderKnowledgeBaseTab = () => (
  1411. <div className="space-y-8 animate-in slide-in-from-right duration-300 w-full max-w-5xl pb-10">
  1412. {localKbSettings && (
  1413. <>
  1414. {/* Save/Cancel Bar */}
  1415. <div className="flex justify-end gap-3 sticky top-0 z-20 py-4 bg-white/50 backdrop-blur-sm border-b border-slate-100 mb-6">
  1416. <button
  1417. onClick={handleCancelKbSettings}
  1418. disabled={isSavingKbSettings || JSON.stringify(localKbSettings) === JSON.stringify(kbSettings)}
  1419. className="px-6 py-2 text-sm font-bold text-slate-500 hover:text-slate-700 disabled:opacity-30"
  1420. >
  1421. {t('cancel')}
  1422. </button>
  1423. <button
  1424. onClick={handleSaveKbSettings}
  1425. disabled={isSavingKbSettings || JSON.stringify(localKbSettings) === JSON.stringify(kbSettings)}
  1426. className="flex items-center gap-2 px-8 py-2 bg-indigo-600 text-white text-sm font-black uppercase tracking-widest rounded-2xl hover:bg-indigo-700 shadow-lg shadow-indigo-100 transition-all active:scale-95 disabled:opacity-50"
  1427. >
  1428. {isSavingKbSettings ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
  1429. {t('saveChanges')}
  1430. </button>
  1431. </div>
  1432. {/* Model Configuration */}
  1433. <section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
  1434. <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
  1435. <div className="w-8 h-8 rounded-xl bg-indigo-50 flex items-center justify-center text-indigo-600">
  1436. <Cpu size={16} />
  1437. </div>
  1438. {t('modelConfiguration')}
  1439. </div>
  1440. <div className="grid grid-cols-1 gap-6">
  1441. <div>
  1442. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('defaultLLMModel')}</label>
  1443. <select
  1444. value={localKbSettings.selectedLLMId || ''}
  1445. onChange={(e) => handleUpdateKbSettings('selectedLLMId', e.target.value)}
  1446. className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all cursor-pointer appearance-none"
  1447. >
  1448. <option value="">{t('selectLLM')}</option>
  1449. {models.filter(m => m.type === ModelType.LLM).map(m => (
  1450. <option key={m.id} value={m.id}>{m.name} ({m.modelId})</option>
  1451. ))}
  1452. </select>
  1453. </div>
  1454. <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
  1455. <div>
  1456. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('embeddingModel')}</label>
  1457. <select
  1458. value={localKbSettings.selectedEmbeddingId || ''}
  1459. onChange={(e) => handleUpdateKbSettings('selectedEmbeddingId', e.target.value)}
  1460. className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
  1461. >
  1462. <option value="">{t('selectEmbedding')}</option>
  1463. {models.filter(m => m.type === ModelType.EMBEDDING).map(m => (
  1464. <option key={m.id} value={m.id}>{m.name}</option>
  1465. ))}
  1466. </select>
  1467. </div>
  1468. <div>
  1469. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('rerankModel')}</label>
  1470. <select
  1471. value={localKbSettings.selectedRerankId || ''}
  1472. onChange={(e) => handleUpdateKbSettings('selectedRerankId', e.target.value)}
  1473. className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
  1474. >
  1475. <option value="">{t('none')}</option>
  1476. {models.filter(m => m.type === ModelType.RERANK).map(m => (
  1477. <option key={m.id} value={m.id}>{m.name}</option>
  1478. ))}
  1479. </select>
  1480. </div>
  1481. </div>
  1482. </div>
  1483. </section>
  1484. {/* Indexing & Chunking Configuration */}
  1485. <section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
  1486. <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
  1487. <div className="w-8 h-8 rounded-xl bg-orange-50 flex items-center justify-center text-orange-600">
  1488. <BookOpen size={16} />
  1489. </div>
  1490. {t('indexingChunkingConfig')}
  1491. </div>
  1492. <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
  1493. <div>
  1494. <div className="flex justify-between mb-3 px-1">
  1495. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('chunkSize')}</label>
  1496. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.chunkSize || 1000}</span>
  1497. </div>
  1498. <input
  1499. type="range"
  1500. min="100"
  1501. max="8192"
  1502. step="100"
  1503. value={localKbSettings.chunkSize || 1000}
  1504. onChange={(e) => handleUpdateKbSettings('chunkSize', parseInt(e.target.value))}
  1505. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1506. />
  1507. </div>
  1508. <div>
  1509. <div className="flex justify-between mb-3 px-1">
  1510. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('chunkOverlap')}</label>
  1511. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.chunkOverlap || 100}</span>
  1512. </div>
  1513. <input
  1514. type="range"
  1515. min="0"
  1516. max="2048"
  1517. step="10"
  1518. value={localKbSettings.chunkOverlap || 100}
  1519. onChange={(e) => handleUpdateKbSettings('chunkOverlap', parseInt(e.target.value))}
  1520. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1521. />
  1522. </div>
  1523. </div>
  1524. </section >
  1525. {/* Chat Hyperparameters */}
  1526. < section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6" >
  1527. <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
  1528. <div className="w-8 h-8 rounded-xl bg-pink-50 flex items-center justify-center text-pink-600">
  1529. <Sparkles size={16} />
  1530. </div>
  1531. {t('chatHyperparameters')}
  1532. </div>
  1533. <div className="space-y-8">
  1534. <div>
  1535. <div className="flex justify-between mb-3 px-1">
  1536. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('temperature')}</label>
  1537. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.temperature}</span>
  1538. </div>
  1539. <input
  1540. type="range"
  1541. min="0"
  1542. max="1"
  1543. step="0.1"
  1544. value={localKbSettings.temperature || 0.7}
  1545. onChange={(e) => handleUpdateKbSettings('temperature', parseFloat(e.target.value))}
  1546. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1547. />
  1548. <div className="flex justify-between mt-2 px-1 text-[9px] font-bold text-slate-300 uppercase tracking-tighter">
  1549. <span>{t('precise')}</span>
  1550. <span>{t('creative')}</span>
  1551. </div>
  1552. </div>
  1553. <div>
  1554. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('maxResponseTokens')}</label>
  1555. <input
  1556. type="number"
  1557. value={localKbSettings.maxTokens || 2000}
  1558. onChange={(e) => handleUpdateKbSettings('maxTokens', parseInt(e.target.value))}
  1559. className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
  1560. />
  1561. </div>
  1562. </div>
  1563. </section >
  1564. {/* Retrieval & Search Settings */}
  1565. < section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6" >
  1566. <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
  1567. <div className="w-8 h-8 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-600">
  1568. <Database size={16} />
  1569. </div>
  1570. {t('retrievalSearchSettings')}
  1571. </div>
  1572. <div className="space-y-8">
  1573. <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
  1574. <div>
  1575. <div className="flex justify-between mb-3 px-1">
  1576. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('topK')}</label>
  1577. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.topK}</span>
  1578. </div>
  1579. <input
  1580. type="range"
  1581. min="1"
  1582. max="50"
  1583. step="1"
  1584. value={localKbSettings.topK || 10}
  1585. onChange={(e) => handleUpdateKbSettings('topK', parseInt(e.target.value))}
  1586. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1587. />
  1588. </div>
  1589. <div>
  1590. <div className="flex justify-between mb-3 px-1">
  1591. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('similarityThreshold')}</label>
  1592. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.similarityThreshold}</span>
  1593. </div>
  1594. <input
  1595. type="range"
  1596. min="0"
  1597. max="1"
  1598. step="0.05"
  1599. value={localKbSettings.similarityThreshold || 0.5}
  1600. onChange={(e) => handleUpdateKbSettings('similarityThreshold', parseFloat(e.target.value))}
  1601. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1602. />
  1603. </div>
  1604. </div>
  1605. <div className="space-y-4 pt-4 border-t border-slate-100">
  1606. <div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
  1607. <div>
  1608. <div className="text-sm font-bold text-slate-800">{t('enableHybridSearch')}</div>
  1609. <div className="text-[10px] text-slate-400 font-medium">{t('hybridSearchDesc')}</div>
  1610. </div>
  1611. <button
  1612. onClick={() => handleUpdateKbSettings('enableFullTextSearch', !localKbSettings.enableFullTextSearch)}
  1613. className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${localKbSettings.enableFullTextSearch ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
  1614. >
  1615. <span className={`${localKbSettings.enableFullTextSearch ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
  1616. </button>
  1617. </div>
  1618. {localKbSettings.enableFullTextSearch && (
  1619. <motion.div
  1620. initial={{ opacity: 0, y: -10 }}
  1621. animate={{ opacity: 1, y: 0 }}
  1622. className="p-5 bg-indigo-50/30 rounded-2xl border border-indigo-100/50 space-y-4"
  1623. >
  1624. <div className="flex justify-between mb-2 px-1">
  1625. <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">{t('hybridWeight')}</label>
  1626. <span className="text-sm font-black text-indigo-600">{localKbSettings.hybridVectorWeight || 0.5}</span>
  1627. </div>
  1628. <input
  1629. type="range"
  1630. min="0"
  1631. max="1"
  1632. step="0.05"
  1633. value={localKbSettings.hybridVectorWeight || 0.5}
  1634. onChange={(e) => handleUpdateKbSettings('hybridVectorWeight', parseFloat(e.target.value))}
  1635. className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1636. />
  1637. <div className="flex justify-between mt-1 px-1 text-[9px] font-bold text-indigo-300 uppercase">
  1638. <span>{t('pureText')}</span>
  1639. <span>{t('pureVector')}</span>
  1640. </div>
  1641. </motion.div>
  1642. )}
  1643. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  1644. <div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
  1645. <div>
  1646. <div className="text-sm font-bold text-slate-800">{t('enableQueryExpansion')}</div>
  1647. <div className="text-[10px] text-slate-400 font-medium">{t('queryExpansionDesc')}</div>
  1648. </div>
  1649. <button
  1650. onClick={() => handleUpdateKbSettings('enableQueryExpansion', !localKbSettings.enableQueryExpansion)}
  1651. className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${localKbSettings.enableQueryExpansion ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
  1652. >
  1653. <span className={`${localKbSettings.enableQueryExpansion ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
  1654. </button>
  1655. </div>
  1656. <div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
  1657. <div>
  1658. <div className="text-sm font-bold text-slate-800">{t('enableHyDE')}</div>
  1659. <div className="text-[10px] text-slate-400 font-medium">{t('hydeDesc')}</div>
  1660. </div>
  1661. <button
  1662. onClick={() => handleUpdateKbSettings('enableHyDE', !localKbSettings.enableHyDE)}
  1663. className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${localKbSettings.enableHyDE ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
  1664. >
  1665. <span className={`${localKbSettings.enableHyDE ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
  1666. </button>
  1667. </div>
  1668. </div>
  1669. <div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
  1670. <div>
  1671. <div className="text-sm font-bold text-slate-800">{t('enableReranking')}</div>
  1672. <div className="text-[10px] text-slate-400 font-medium">{t('rerankingDesc')}</div>
  1673. </div>
  1674. <button
  1675. onClick={() => handleUpdateKbSettings('enableRerank', !localKbSettings.enableRerank)}
  1676. className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${localKbSettings.enableRerank ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
  1677. >
  1678. <span className={`${localKbSettings.enableRerank ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
  1679. </button>
  1680. </div>
  1681. {localKbSettings.enableRerank && (
  1682. <motion.div
  1683. initial={{ opacity: 0, y: -10 }}
  1684. animate={{ opacity: 1, y: 0 }}
  1685. className="p-5 bg-indigo-50/30 rounded-2xl border border-indigo-100/50 space-y-4"
  1686. >
  1687. <div className="flex justify-between mb-2 px-1">
  1688. <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">{t('rerankSimilarityThreshold')}</label>
  1689. <span className="text-sm font-black text-indigo-600">{localKbSettings.rerankSimilarityThreshold || 0.5}</span>
  1690. </div>
  1691. <input
  1692. type="range"
  1693. min="0"
  1694. max="1"
  1695. step="0.05"
  1696. value={localKbSettings.rerankSimilarityThreshold || 0.5}
  1697. onChange={(e) => handleUpdateKbSettings('rerankSimilarityThreshold', parseFloat(e.target.value))}
  1698. className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1699. />
  1700. <div className="flex justify-between mt-1 px-1 text-[9px] font-bold text-indigo-300 uppercase">
  1701. <span>{t('broad')}</span>
  1702. <span>{t('strict')}</span>
  1703. </div>
  1704. </motion.div>
  1705. )}
  1706. </div>
  1707. </div>
  1708. </section>
  1709. </>
  1710. )}
  1711. </div>
  1712. );
  1713. const renderModelTab = () => (
  1714. <div className="w-full space-y-6">
  1715. <div className="flex justify-between items-center mb-6">
  1716. <div>
  1717. </div>
  1718. {!editingId && currentUser?.role === 'SUPER_ADMIN' && (
  1719. <button
  1720. onClick={() => { setEditingId('new'); setModelFormData({ type: ModelType.LLM, baseUrl: 'http://localhost:11434/v1', modelId: 'llama3', name: '', dimensions: 1536 }); }}
  1721. className="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white text-sm font-bold rounded-2xl hover:bg-indigo-700 shadow-lg shadow-indigo-100 transition-all active:scale-95"
  1722. >
  1723. <Plus className="w-4 h-4" />
  1724. {t('mmAddBtn')}
  1725. </button>
  1726. )}
  1727. </div>
  1728. {editingId ? (
  1729. <motion.div
  1730. initial={{ opacity: 0, y: 10 }}
  1731. animate={{ opacity: 1, y: 0 }}
  1732. className="bg-white/90 backdrop-blur-md p-10 rounded-3xl border border-slate-200/50 shadow-xl space-y-8"
  1733. >
  1734. <div className="flex items-center gap-4 mb-2">
  1735. <div className="w-12 h-12 rounded-2xl bg-indigo-50 flex items-center justify-center text-indigo-600">
  1736. <Cpu className="w-6 h-6" />
  1737. </div>
  1738. <h3 className="text-xl font-black text-slate-900 tracking-tight">{editingId === 'new' ? t('mmAddBtn') : t('mmEdit')}</h3>
  1739. </div>
  1740. <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
  1741. <div className="space-y-2">
  1742. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormName')} *</label>
  1743. <input className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all" value={modelFormData.name || ''} onChange={e => setModelFormData({ ...modelFormData, name: e.target.value })} disabled={isLoading} />
  1744. </div>
  1745. <div className="space-y-2">
  1746. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormModelId')} *</label>
  1747. <input className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-mono focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all" value={modelFormData.modelId || ''} onChange={e => setModelFormData({ ...modelFormData, modelId: e.target.value })} disabled={isLoading} />
  1748. </div>
  1749. </div>
  1750. <div className="space-y-2">
  1751. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormType')} *</label>
  1752. <select className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all appearance-none" value={modelFormData.type} onChange={e => setModelFormData({ ...modelFormData, type: e.target.value as ModelType })} disabled={isLoading}>
  1753. <option value={ModelType.LLM}>{t('typeLLM')}</option>
  1754. <option value={ModelType.EMBEDDING}>{t('typeEmbedding')}</option>
  1755. <option value={ModelType.RERANK}>{t('typeRerank')}</option>
  1756. <option value={ModelType.VISION}>{t('typeVision')}</option>
  1757. </select>
  1758. </div>
  1759. <div className="space-y-2">
  1760. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormBaseUrl')} *</label>
  1761. <input className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-mono focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all" value={modelFormData.baseUrl || ''} onChange={e => setModelFormData({ ...modelFormData, baseUrl: e.target.value })} disabled={isLoading} />
  1762. </div>
  1763. <div className="space-y-2">
  1764. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormApiKey')}</label>
  1765. <input
  1766. type="password"
  1767. className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-mono focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
  1768. value={modelFormData.apiKey || ''}
  1769. onChange={e => setModelFormData({ ...modelFormData, apiKey: e.target.value })}
  1770. disabled={isLoading}
  1771. placeholder={t('mmFormApiKeyPlaceholder')}
  1772. />
  1773. </div>
  1774. {modelFormData.type === ModelType.EMBEDDING && (
  1775. <div className="grid grid-cols-2 gap-6 p-6 bg-slate-50 rounded-3xl border border-slate-200/50">
  1776. <div className="space-y-2">
  1777. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('maxInput')}</label>
  1778. <input type="number" className="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm font-bold" value={modelFormData.maxInputTokens || 8191} onChange={e => setModelFormData({ ...modelFormData, maxInputTokens: parseInt(e.target.value) })} />
  1779. </div>
  1780. <div className="space-y-2">
  1781. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('dimensions')}</label>
  1782. <input type="number" className="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm font-bold" value={modelFormData.dimensions || 1536} onChange={e => setModelFormData({ ...modelFormData, dimensions: parseInt(e.target.value) })} />
  1783. </div>
  1784. </div>
  1785. )}
  1786. <div className="flex justify-end gap-3 pt-4">
  1787. <button onClick={() => { setEditingId(null); setError(null); }} className="px-6 py-3 text-sm font-bold text-slate-500 hover:text-slate-700">{t('mmCancel')}</button>
  1788. <button onClick={handleSaveModel} className="px-10 py-3 bg-indigo-600 text-white rounded-2xl font-black uppercase tracking-widest text-xs shadow-xl shadow-indigo-100 transition-all active:scale-95 flex items-center gap-2" disabled={isLoading}>
  1789. {isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
  1790. {t('mmSave')}
  1791. </button>
  1792. </div>
  1793. </motion.div>
  1794. ) : (
  1795. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-5 overflow-y-auto pr-2 pb-6 scrollbar-hide">
  1796. <AnimatePresence>
  1797. {models.map((model, index) => (
  1798. <motion.div
  1799. key={model.id}
  1800. initial={{ opacity: 0, scale: 0.95 }}
  1801. animate={{ opacity: 1, scale: 1 }}
  1802. transition={{ delay: index * 0.05 }}
  1803. className="bg-white/70 backdrop-blur-md border border-slate-200/50 rounded-[2rem] p-6 flex flex-col justify-between group hover:shadow-xl hover:shadow-indigo-500/5 hover:border-indigo-500/30 transition-all duration-300 relative overflow-hidden"
  1804. >
  1805. {/* Subtle background pattern/glow */}
  1806. <div className="absolute -top-12 -right-12 w-32 h-32 bg-indigo-500/5 rounded-full blur-3xl group-hover:bg-indigo-500/10 transition-all duration-500" />
  1807. <div className="relative z-10">
  1808. <div className="flex items-start justify-between mb-5">
  1809. <div className={`w-14 h-14 rounded-2xl flex items-center justify-center transition-all duration-500 ${model.isEnabled !== false ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-100 rotate-0 group-hover:rotate-6' : 'bg-slate-100 text-slate-400 opacity-60'}`}>
  1810. <Cpu size={26} strokeWidth={2.5} />
  1811. </div>
  1812. <div className="flex gap-1 items-center bg-slate-50 p-1 rounded-xl border border-slate-100/50">
  1813. <button
  1814. onClick={() => handleToggleModel(model)}
  1815. className={`p-1.5 rounded-lg transition-all ${((currentUser?.role === 'SUPER_ADMIN' ? !!model.isEnabled : enabledModelIds.includes(model.id))) ? 'text-indigo-600 bg-white shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
  1816. title={((currentUser?.role === 'SUPER_ADMIN' ? !!model.isEnabled : enabledModelIds.includes(model.id))) ? t('modelEnabled') : t('modelDisabled')}
  1817. >
  1818. {((currentUser?.role === 'SUPER_ADMIN' ? !!model.isEnabled : enabledModelIds.includes(model.id))) ? <ToggleRight size={24} /> : <ToggleLeft size={24} />}
  1819. </button>
  1820. </div>
  1821. </div>
  1822. <div className="space-y-1 mb-6">
  1823. <div className="flex items-center gap-2.5">
  1824. <h4 className="font-black text-slate-900 text-lg tracking-tight truncate">{model.name}</h4>
  1825. </div>
  1826. <div className="flex items-center gap-2">
  1827. <span className="text-[9px] font-black bg-indigo-50 text-indigo-600 px-2 py-0.5 rounded-lg uppercase tracking-wider border border-indigo-100/50">
  1828. {getTypeLabel(model.type)}
  1829. </span>
  1830. {model.isDefault && (
  1831. <span className="text-[9px] font-black bg-amber-50 text-amber-600 px-2 py-0.5 rounded-lg uppercase tracking-wider border border-amber-100/50 flex items-center gap-1">
  1832. <Sparkles size={8} /> {t('defaultBadge')}
  1833. </span>
  1834. )}
  1835. </div>
  1836. <p className="text-[11px] font-mono text-slate-400 mt-2 truncate bg-slate-50 px-2 py-1 rounded-lg border border-slate-100/50 inline-block max-w-full">
  1837. {model.modelId}
  1838. </p>
  1839. </div>
  1840. {/* Additional info grid */}
  1841. <div className="grid grid-cols-2 gap-3 mb-6">
  1842. {model.type === ModelType.EMBEDDING && (
  1843. <>
  1844. <div className="bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50">
  1845. <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('dims')}</span>
  1846. <span className="text-xs font-bold text-slate-700">{model.dimensions || '-'}</span>
  1847. </div>
  1848. <div className="bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50">
  1849. <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('ctx')}</span>
  1850. <span className="text-xs font-bold text-slate-700">{model.maxInputTokens || '-'}</span>
  1851. </div>
  1852. </>
  1853. )}
  1854. {model.type === ModelType.LLM && (
  1855. <div className="col-span-2 bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50 flex items-center justify-between">
  1856. <span className="text-[8px] font-black text-slate-400 uppercase tracking-widest">{t('baseApi')}</span>
  1857. <span className="text-[9px] font-mono font-medium text-slate-600 max-w-[140px] truncate">{model.baseUrl}</span>
  1858. </div>
  1859. )}
  1860. </div>
  1861. </div>
  1862. <div className="flex items-center justify-between pt-4 border-t border-slate-100/50 relative z-10">
  1863. <div className="flex items-center gap-1 text-[10px] font-bold text-slate-400">
  1864. <SettingsIcon size={12} />
  1865. {t('configured')}
  1866. </div>
  1867. <div className="flex gap-2">
  1868. {currentUser?.role === 'SUPER_ADMIN' && (
  1869. <>
  1870. <button
  1871. onClick={() => { setEditingId(model.id); setModelFormData({ ...model }); }}
  1872. className="w-9 h-9 flex items-center justify-center rounded-xl bg-slate-50 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 hover:shadow-sm transition-all"
  1873. >
  1874. <Edit2 size={15} />
  1875. </button>
  1876. <button
  1877. onClick={() => handleDeleteModel(model.id)}
  1878. className="w-9 h-9 flex items-center justify-center rounded-xl bg-slate-50 text-slate-400 hover:text-red-500 hover:bg-red-50 hover:shadow-sm transition-all"
  1879. >
  1880. <Trash2 size={15} />
  1881. </button>
  1882. </>
  1883. )}
  1884. </div>
  1885. </div>
  1886. </motion.div>
  1887. ))}
  1888. </AnimatePresence>
  1889. {models.length === 0 && (
  1890. <div className="bg-white/50 border-2 border-dashed border-slate-200 rounded-3xl p-16 text-center">
  1891. <Cpu className="w-12 h-12 text-slate-200 mx-auto mb-4" />
  1892. <p className="text-slate-400 font-bold uppercase tracking-widest text-xs">{t('mmEmpty')}</p>
  1893. </div>
  1894. )}
  1895. </div>
  1896. )}
  1897. </div>
  1898. );
  1899. return (
  1900. <div className="flex h-full bg-[#FCFDFF] overflow-hidden relative">
  1901. {/* Settings Sidebar */}
  1902. <div className="w-64 bg-slate-50/50 border-r border-slate-200/60 flex flex-col shrink-0 z-20">
  1903. <div className="p-6 pb-2">
  1904. <h2 className="text-lg font-bold text-slate-900 tracking-tight">{t('tabSettings')}</h2>
  1905. </div>
  1906. <div className="flex-1 overflow-y-auto p-3 space-y-1">
  1907. <button
  1908. onClick={() => setActiveTab('general')}
  1909. className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'general' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
  1910. }`}
  1911. >
  1912. <SettingsIcon size={18} />
  1913. {t('generalSettings')}
  1914. </button>
  1915. {currentUser?.role === 'SUPER_ADMIN' && (
  1916. <button
  1917. onClick={() => setActiveTab('user')}
  1918. className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'user' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
  1919. }`}
  1920. >
  1921. <UserCircle size={18} />
  1922. {t('userManagement')}
  1923. </button>
  1924. )}
  1925. {isAdmin && (
  1926. <>
  1927. <button
  1928. onClick={() => setActiveTab('model')}
  1929. className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'model' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
  1930. }`}
  1931. >
  1932. <HardDrive size={18} />
  1933. {t('modelManagement')}
  1934. </button>
  1935. <button
  1936. onClick={() => setActiveTab('knowledge_base')}
  1937. className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'knowledge_base' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
  1938. }`}
  1939. >
  1940. <Database size={18} />
  1941. {t('sidebarTitle')}
  1942. </button>
  1943. </>
  1944. )}
  1945. {currentUser?.role === 'SUPER_ADMIN' && (
  1946. <button
  1947. onClick={() => setActiveTab('tenants')}
  1948. className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'tenants' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
  1949. }`}
  1950. >
  1951. <LayoutGrid size={18} />
  1952. {t('navTenants')}
  1953. </button>
  1954. )}
  1955. </div>
  1956. </div>
  1957. {/* Content Area */}
  1958. <div className="flex-1 flex flex-col min-w-0 h-full overflow-hidden relative bg-white z-10">
  1959. <div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
  1960. <div>
  1961. <h1 className="text-2xl font-bold text-slate-900 leading-tight">
  1962. {activeTab === 'general' ? t('generalSettings') : activeTab === 'user' ? t('userManagement') : activeTab === 'model' ? t('modelManagement') : activeTab === 'knowledge_base' ? t('sidebarTitle') : t('navTenants')}
  1963. </h1>
  1964. <p className="text-[15px] text-slate-500 mt-1">
  1965. {activeTab === 'general' ? t('generalSettingsSubtitle') : activeTab === 'user' ? t('userManagementSubtitle') : activeTab === 'model' ? t('modelManagementSubtitle') : activeTab === 'knowledge_base' ? t('kbSettingsSubtitle') : t('tenantsSubtitle')}
  1966. </p>
  1967. </div>
  1968. </div>
  1969. <div className="flex-1 overflow-y-auto px-10 pb-10 scrollbar-hide">
  1970. <div className="w-full">
  1971. {error && (
  1972. <motion.div
  1973. initial={{ opacity: 0, y: -10 }}
  1974. animate={{ opacity: 1, y: 0 }}
  1975. className="mb-8 p-4 bg-red-50/80 backdrop-blur-md border border-red-200/50 text-red-700 rounded-2xl flex gap-3 items-center text-sm shadow-sm"
  1976. >
  1977. <div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center shrink-0">
  1978. <X className="w-4 h-4 text-red-600" />
  1979. </div>
  1980. <div>
  1981. <span className="font-black uppercase tracking-widest text-[10px] block mb-0.5">{t('errorLabel')}</span>
  1982. {error}
  1983. </div>
  1984. </motion.div>
  1985. )}
  1986. <AnimatePresence mode="wait">
  1987. <motion.div
  1988. key={activeTab}
  1989. initial={{ opacity: 0, x: 20 }}
  1990. animate={{ opacity: 1, x: 0 }}
  1991. exit={{ opacity: 0, x: -20 }}
  1992. transition={{ duration: 0.3 }}
  1993. >
  1994. {activeTab === 'general' && renderGeneralTab()}
  1995. {activeTab === 'user' && currentUser?.role === 'SUPER_ADMIN' && renderUserTab()}
  1996. {activeTab === 'model' && isAdmin && renderModelTab()}
  1997. {activeTab === 'knowledge_base' && isAdmin && renderKnowledgeBaseTab()}
  1998. {activeTab === 'tenants' && currentUser?.role === 'SUPER_ADMIN' && renderTenantsTab()}
  1999. </motion.div>
  2000. </AnimatePresence>
  2001. </div>
  2002. </div>
  2003. </div>
  2004. </div>
  2005. );
  2006. };