SettingsView.tsx 127 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127
  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('Delete this organization?'))) 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.created + result.updated).toString()).replace('$2', result.errors.length.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('confirmClear'))) {
  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. <div>
  686. <input
  687. type="password"
  688. placeholder={t('currentPassword')}
  689. value={passwordForm.current}
  690. onChange={e => setPasswordForm({ ...passwordForm, current: e.target.value })}
  691. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  692. required
  693. />
  694. </div>
  695. <div>
  696. <input
  697. type="password"
  698. placeholder={t('newPassword')}
  699. value={passwordForm.new}
  700. onChange={e => setPasswordForm({ ...passwordForm, new: e.target.value })}
  701. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  702. required
  703. />
  704. </div>
  705. <div>
  706. <input
  707. type="password"
  708. placeholder={t('confirmPassword')}
  709. value={passwordForm.confirm}
  710. onChange={e => setPasswordForm({ ...passwordForm, confirm: e.target.value })}
  711. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  712. required
  713. />
  714. </div>
  715. {passwordSuccess && <p className="text-xs text-green-600">{passwordSuccess}</p>}
  716. <button
  717. type="submit"
  718. disabled={isLoading}
  719. className="w-full py-2 bg-slate-800 text-white rounded-md text-sm hover:bg-slate-900 transition-colors disabled:opacity-50"
  720. >
  721. {isLoading ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> : t('confirmChange')}
  722. </button>
  723. </form>
  724. </section>
  725. {/* 语言设置セクション */}
  726. <section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
  727. <h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
  728. <Globe className="w-4 h-4 text-blue-500" />
  729. {t('languageSettings')}
  730. </h3>
  731. <div className="space-y-4 max-w-sm">
  732. <div className="space-y-2">
  733. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">
  734. {t('switchLanguage')}
  735. </label>
  736. <select
  737. value={language}
  738. onChange={async (e) => {
  739. const newLang = e.target.value as any;
  740. setLanguage(newLang);
  741. try {
  742. await settingsService.updateLanguage(newLang);
  743. showSuccess(t('confirm'));
  744. } catch (err) {
  745. console.error('Failed to update backend language preference:', err);
  746. }
  747. }}
  748. 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"
  749. >
  750. <option value="en">English</option>
  751. <option value="zh">中文 (Chinese)</option>
  752. <option value="ja">日本語 (Japanese)</option>
  753. </select>
  754. </div>
  755. </div>
  756. </section>
  757. </div >
  758. );
  759. const renderUserTab = () => (
  760. <div className="space-y-6 w-full">
  761. <div className="flex justify-between items-center mb-6">
  762. <div>
  763. <p className="text-xs text-slate-400 font-medium">{''}</p>
  764. </div>
  765. {currentUser?.role === 'SUPER_ADMIN' && (
  766. <div className="flex gap-2">
  767. <button
  768. onClick={handleExportUsers}
  769. 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"
  770. title={t('exportUsers')}
  771. >
  772. <Download className="w-4 h-4" />
  773. <span className="hidden sm:inline">{t('exportUsers')}</span>
  774. </button>
  775. <div className="relative">
  776. <input
  777. type="file"
  778. accept=".xlsx,.xls,.csv"
  779. onChange={handleImportUsers}
  780. className="absolute inset-0 opacity-0 cursor-pointer"
  781. title={t('importUsers')}
  782. />
  783. <button
  784. 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"
  785. >
  786. <Upload className="w-4 h-4" />
  787. <span className="hidden sm:inline">{t('importUsers')}</span>
  788. </button>
  789. </div>
  790. <button
  791. onClick={() => setShowAddUser(!showAddUser)}
  792. 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"
  793. >
  794. <Plus className="w-4 h-4" />
  795. {t('addUser')}
  796. </button>
  797. </div>
  798. )}
  799. </div>
  800. {showAddUser && (
  801. <motion.form
  802. initial={{ opacity: 0, y: -10 }}
  803. animate={{ opacity: 1, y: 0 }}
  804. onSubmit={handleCreateUser}
  805. className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-xl mb-8 space-y-5"
  806. >
  807. <div className="grid grid-cols-1 md:grid-cols-3 gap-5">
  808. <input
  809. type="text"
  810. placeholder={t('usernamePlaceholder')}
  811. value={newUser.username}
  812. onChange={e => setNewUser({ ...newUser, username: e.target.value })}
  813. 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"
  814. required
  815. />
  816. <input
  817. type="text"
  818. placeholder={t('displayNamePlaceholder') || t('name')}
  819. value={newUser.displayName}
  820. onChange={e => setNewUser({ ...newUser, displayName: e.target.value })}
  821. 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"
  822. required
  823. />
  824. <input
  825. type="password"
  826. placeholder={t('passwordPlaceholder')}
  827. value={newUser.password}
  828. onChange={e => setNewUser({ ...newUser, password: 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. />
  832. </div>
  833. <div className="flex items-center justify-between">
  834. <div></div>
  835. <div className="flex gap-3">
  836. <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>
  837. <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>
  838. </div>
  839. </div>
  840. </motion.form>
  841. )}
  842. {passwordChangeUserData && (
  843. <div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm flex items-center justify-center z-[100] p-6">
  844. <motion.div
  845. initial={{ scale: 0.95, opacity: 0 }}
  846. animate={{ scale: 1, opacity: 1 }}
  847. className="bg-white rounded-3xl p-10 w-full max-w-md shadow-2xl border border-white/20"
  848. >
  849. <div className="flex items-center justify-between mb-8">
  850. <h3 className="text-xl font-black text-slate-900 tracking-tight">{t('changeUserPassword')}</h3>
  851. <button onClick={() => setPasswordChangeUserData(null)} className="p-2 hover:bg-slate-100 rounded-xl transition-all">
  852. <X size={20} className="text-slate-400" />
  853. </button>
  854. </div>
  855. <form onSubmit={(e) => { e.preventDefault(); handleUserPasswordChange(); }} className="space-y-6">
  856. <div>
  857. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
  858. {t('newPassword')}
  859. </label>
  860. <input
  861. type="password"
  862. value={passwordChangeUserData.newPassword}
  863. onChange={(e) => setPasswordChangeUserData({ ...passwordChangeUserData, newPassword: e.target.value })}
  864. 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"
  865. placeholder={t('enterNewPassword')}
  866. required
  867. />
  868. </div>
  869. <div className="flex gap-4 pt-4">
  870. <button type="button" onClick={() => setPasswordChangeUserData(null)} className="flex-1 py-3.5 text-slate-500 font-bold text-sm">{t('cancel')}</button>
  871. <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>
  872. </div>
  873. </form>
  874. </motion.div>
  875. </div>
  876. )}
  877. {/* Edit User Modal */}
  878. {editUserData && (
  879. <div className="fixed inset-0 z-[110] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4">
  880. <motion.div
  881. initial={{ scale: 0.95, opacity: 0 }}
  882. animate={{ scale: 1, opacity: 1 }}
  883. className="bg-white rounded-3xl p-10 w-full max-w-md shadow-2xl border border-white/20"
  884. >
  885. <div className="flex items-center justify-between mb-8">
  886. <h3 className="text-xl font-black text-slate-900 tracking-tight">{t('editUser')}</h3>
  887. <button onClick={() => setEditUserData(null)} className="p-2 hover:bg-slate-100 rounded-xl transition-all">
  888. <X size={20} className="text-slate-400" />
  889. </button>
  890. </div>
  891. <form onSubmit={(e) => { e.preventDefault(); handleUpdateUser(); }} className="space-y-6">
  892. <div>
  893. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
  894. {t('username')}
  895. </label>
  896. <input
  897. type="text"
  898. value={editUserData.username}
  899. onChange={(e) => setEditUserData({ ...editUserData, username: e.target.value })}
  900. 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"
  901. placeholder={t('usernamePlaceholder')}
  902. required
  903. />
  904. </div>
  905. <div>
  906. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
  907. {t('displayName') || t('name')}
  908. </label>
  909. <input
  910. type="text"
  911. value={editUserData.displayName}
  912. onChange={(e) => setEditUserData({ ...editUserData, displayName: e.target.value })}
  913. 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"
  914. placeholder={t('displayNamePlaceholder') || t('namePlaceholder')}
  915. required
  916. />
  917. </div>
  918. <div className="py-2.5 px-4 bg-indigo-50 rounded-2xl border border-indigo-100/50">
  919. <p className="text-[10px] font-black text-indigo-500 uppercase tracking-widest mb-1">{t('globalUserNote') || "Note"}</p>
  920. <p className="text-[11px] text-indigo-700/70 leading-relaxed font-medium">
  921. {t('roleManagedInOrg') || "Roles are managed within organizations."}
  922. </p>
  923. </div>
  924. <div className="flex gap-4 pt-4">
  925. <button type="button" onClick={() => setEditUserData(null)} className="flex-1 py-3.5 text-slate-500 font-bold text-sm">{t('cancel')}</button>
  926. <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>
  927. </div>
  928. </form>
  929. </motion.div>
  930. </div>
  931. )}
  932. <div className="w-full bg-white/70 backdrop-blur-md border border-slate-200/50 rounded-2xl overflow-hidden shadow-sm">
  933. <table className="w-full border-collapse text-left">
  934. <thead>
  935. <tr className="bg-slate-50/50 border-b border-slate-200/50">
  936. <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('username')}</th>
  937. <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('displayName') || t('name')}</th>
  938. <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('organizations')}</th>
  939. <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('createdAt')}</th>
  940. <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest text-right">{t('actions')}</th>
  941. </tr>
  942. </thead>
  943. <tbody className="divide-y divide-slate-100">
  944. <AnimatePresence>
  945. {users.filter((u: any) => u.username !== 'admin').map((user: any, index: number) => {
  946. let IconComponent = User;
  947. let iconColors = 'bg-slate-50 text-slate-400';
  948. if (user.isAdmin) {
  949. IconComponent = Shield;
  950. iconColors = 'bg-red-50 text-red-600';
  951. }
  952. return (
  953. <motion.tr
  954. key={user.id}
  955. initial={{ opacity: 0, y: 10 }}
  956. animate={{ opacity: 1, y: 0 }}
  957. transition={{ delay: index * 0.03 }}
  958. className="group hover:bg-slate-50/50 transition-all"
  959. >
  960. <td className="px-6 py-4">
  961. <div className="flex items-center gap-3">
  962. <div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${iconColors}`}>
  963. <IconComponent size={18} />
  964. </div>
  965. <div className="min-w-0">
  966. <p className="font-bold text-slate-900 truncate">{user.username}</p>
  967. </div>
  968. </div>
  969. </td>
  970. <td className="px-6 py-4">
  971. <p className="text-sm text-slate-600 truncate">{user.displayName || '-'}</p>
  972. </td>
  973. <td className="px-6 py-4">
  974. {user.tenantMembers && user.tenantMembers.length > 0 ? (
  975. <div className="flex flex-wrap gap-1">
  976. {user.tenantMembers
  977. .filter((m: any) => m.tenant?.name !== 'Default')
  978. .map((m: any) => (
  979. <span
  980. key={m.tenantId}
  981. 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"
  982. >
  983. <Building size={8} />
  984. {m.tenant?.name || m.tenantId}
  985. </span>
  986. ))}
  987. </div>
  988. ) : (
  989. <span className="text-[10px] text-slate-400 italic">{t('noOrganization')}</span>
  990. )}
  991. </td>
  992. <td className="px-6 py-4">
  993. <p className="text-[11px] font-medium text-slate-600">
  994. {new Date(user.createdAt).toLocaleDateString()}
  995. </p>
  996. </td>
  997. <td className="px-6 py-4 text-right">
  998. <div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-all">
  999. {user.username !== 'admin' && (
  1000. <>
  1001. <button
  1002. onClick={(e) => {
  1003. e.preventDefault();
  1004. e.stopPropagation();
  1005. setEditUserData({
  1006. userId: user.id,
  1007. username: user.username,
  1008. displayName: user.displayName || ''
  1009. });
  1010. }}
  1011. className="p-2 rounded-lg text-slate-400 hover:text-indigo-600 hover:bg-white shadow-sm transition-all"
  1012. title={t('edit')}
  1013. >
  1014. <Edit2 className="w-4 h-4" />
  1015. </button>
  1016. <button
  1017. onClick={() => setPasswordChangeUserData({ userId: user.id, newPassword: '' })}
  1018. className="p-2 rounded-lg text-slate-400 hover:text-indigo-600 hover:bg-white shadow-sm transition-all"
  1019. title={t('changeUserPassword')}
  1020. >
  1021. <Key className="w-4 h-4" />
  1022. </button>
  1023. {user.id !== currentUser?.id && (
  1024. <button
  1025. onClick={() => handleDeleteUser(user.id)}
  1026. className="p-2 rounded-lg text-slate-400 hover:text-red-500 hover:bg-white shadow-sm transition-all"
  1027. title={t('deleteUser')}
  1028. >
  1029. <Trash2 className="w-4 h-4" />
  1030. </button>
  1031. )}
  1032. </>
  1033. )}
  1034. </div>
  1035. </td>
  1036. </motion.tr>
  1037. );
  1038. })}
  1039. </AnimatePresence>
  1040. </tbody>
  1041. </table>
  1042. </div>
  1043. <Pagination
  1044. current={userPage}
  1045. total={userTotal}
  1046. pageSize={USER_PAGE_SIZE}
  1047. onChange={setUserPage}
  1048. />
  1049. </div>
  1050. );
  1051. const renderTenantsTab = () => {
  1052. const tenantTree = buildTenantTree(tenants);
  1053. const activeTenant = selectedTenantId ? tenants.find(t => t.id === selectedTenantId) : null;
  1054. return (
  1055. <div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-12rem)] animate-in fade-in duration-500">
  1056. {/* Left: Organization Tree */}
  1057. <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">
  1058. <div className="p-6 border-b border-slate-100 flex items-center justify-between shrink-0">
  1059. <div>
  1060. <h3 className="font-black text-slate-900 text-lg tracking-tight">{t('orgManagement')}</h3>
  1061. <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{t('globalTenantControl')}</p>
  1062. </div>
  1063. <button
  1064. onClick={() => {
  1065. setNewTenant({ name: '', domain: '', parentId: null });
  1066. setEditingTenant(null);
  1067. setShowCreateTenant(true);
  1068. }}
  1069. className="p-2 bg-slate-900 text-white rounded-xl hover:bg-slate-700 transition-all"
  1070. >
  1071. <Plus size={18} />
  1072. </button>
  1073. </div>
  1074. <div className="flex-1 overflow-y-auto p-4 space-y-1 custom-scrollbar">
  1075. {tenantTree.length > 0 ? (
  1076. tenantTree.map(t => (
  1077. <TenantTreeNode
  1078. key={t.id}
  1079. tenant={t}
  1080. selectedTenantId={selectedTenantId}
  1081. onSelect={(id) => {
  1082. if (id !== selectedTenantId) {
  1083. setSelectedTenantId(id);
  1084. setMemberPage(1);
  1085. setUserPage(1);
  1086. }
  1087. }}
  1088. onCreateSubtenant={(parentId) => {
  1089. setNewTenant({ name: '', domain: '', parentId });
  1090. setEditingTenant(null);
  1091. setShowCreateTenant(true);
  1092. }}
  1093. />
  1094. ))
  1095. ) : (
  1096. <div className="py-20 text-center">
  1097. <Building size={32} className="mx-auto text-slate-200 mb-3" />
  1098. <p className="text-xs font-black text-slate-300 uppercase tracking-widest">{t('noOrganizations')}</p>
  1099. </div>
  1100. )}
  1101. </div>
  1102. <div className="p-4 bg-slate-50 border-t border-slate-100 shrink-0">
  1103. <div className="flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-2xl shadow-sm">
  1104. <div className="w-10 h-10 rounded-xl bg-blue-50 flex items-center justify-center text-blue-600">
  1105. <Building size={20} />
  1106. </div>
  1107. <div>
  1108. <p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('totalTenants')}</p>
  1109. <p className="text-xl font-black text-slate-900">{stats.tenants}</p>
  1110. </div>
  1111. </div>
  1112. </div>
  1113. </div>
  1114. {/* Right: User List & Management */}
  1115. <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">
  1116. {activeTenant ? (
  1117. <div className="flex flex-col h-full">
  1118. {/* Organization Header */}
  1119. <div className="p-8 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between shrink-0">
  1120. <div className="flex items-center gap-4">
  1121. <div className="w-14 h-14 rounded-2xl bg-white border border-slate-200 shadow-sm flex items-center justify-center text-indigo-600">
  1122. <Building size={28} />
  1123. </div>
  1124. <div>
  1125. <div className="flex items-center gap-2">
  1126. <h2 className="text-2xl font-black text-slate-900 tracking-tight">{activeTenant.name}</h2>
  1127. <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>
  1128. </div>
  1129. <p className="text-xs font-medium text-slate-400">{activeTenant.domain || t('noCustomDomain')}</p>
  1130. </div>
  1131. </div>
  1132. <div className="flex items-center gap-2">
  1133. <button
  1134. onClick={() => {
  1135. setEditingTenant(activeTenant);
  1136. setNewTenant({
  1137. name: activeTenant.name,
  1138. domain: activeTenant.domain || '',
  1139. parentId: activeTenant.parentId || null
  1140. });
  1141. setShowCreateTenant(true);
  1142. }}
  1143. 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"
  1144. title={t('orgSettings')}
  1145. >
  1146. <SettingsIcon size={18} />
  1147. </button>
  1148. <button
  1149. onClick={() => handleRemoveTenant(activeTenant.id)}
  1150. 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"
  1151. title={t('deleteOrg')}
  1152. >
  1153. <Trash2 size={18} />
  1154. </button>
  1155. </div>
  1156. </div>
  1157. {/* Main Content Split: Members vs All Users */}
  1158. <div className="flex-1 flex overflow-hidden">
  1159. {/* Current Members */}
  1160. <div className="flex-1 flex flex-col border-r border-slate-100 overflow-hidden">
  1161. <div className="p-6 border-b border-slate-50 flex items-center justify-between shrink-0">
  1162. <h4 className="text-xs font-black text-slate-400 uppercase tracking-widest">{t('orgMembers')}</h4>
  1163. <span className="text-[10px] font-black px-2 py-0.5 bg-slate-100 text-slate-500 rounded-full">
  1164. {t('membersCount').replace('$1', (memberTotal || 0).toString())}
  1165. </span>
  1166. </div>
  1167. <div className="flex-1 overflow-y-auto p-6 scrollbar-hide">
  1168. <div className="grid grid-cols-1 gap-3">
  1169. {tenantMembers?.map((m: any) => (
  1170. <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">
  1171. <div className="flex items-center gap-3 min-w-0">
  1172. <div className="w-10 h-10 rounded-xl bg-white border border-slate-200 flex items-center justify-center text-slate-400 shadow-sm">
  1173. <User size={18} />
  1174. </div>
  1175. <div className="min-w-0">
  1176. <p className="text-sm font-black text-slate-900 truncate">{m.user?.username || m.userId}</p>
  1177. <select
  1178. value={m.role}
  1179. onChange={(e) => handleUpdateMemberRole(activeTenant.id, m.userId, e.target.value)}
  1180. 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'}`}
  1181. >
  1182. <option value="USER">{t('roleRegularUser')}</option>
  1183. <option value="TENANT_ADMIN">{t('roleTenantAdmin')}</option>
  1184. </select>
  1185. </div>
  1186. </div>
  1187. <button
  1188. onClick={() => handleRemoveMember(activeTenant.id, m.userId)}
  1189. className="p-2 text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
  1190. >
  1191. <Trash2 size={14} />
  1192. </button>
  1193. </div>
  1194. ))}
  1195. {(!tenantMembers || tenantMembers.length === 0) && (
  1196. <div className="py-20 text-center">
  1197. <Users size={24} className="mx-auto text-slate-200 mb-2" />
  1198. <p className="text-[10px] font-bold text-slate-300 uppercase tracking-wider">{t('noMembersAssigned')}</p>
  1199. </div>
  1200. )}
  1201. </div>
  1202. <Pagination
  1203. current={memberPage}
  1204. total={memberTotal}
  1205. pageSize={MEMBER_PAGE_SIZE}
  1206. onChange={setMemberPage}
  1207. />
  1208. </div>
  1209. </div>
  1210. {/* Add New Users (Right side of specific tenant view) */}
  1211. <div className="w-72 flex flex-col bg-slate-50/30 overflow-hidden shrink-0">
  1212. <div className="p-6 border-b border-slate-100 shrink-0">
  1213. <h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3">{t('addMembers')}</h4>
  1214. <div className="relative">
  1215. <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
  1216. <input
  1217. 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"
  1218. placeholder={t('searchSystemUsers')}
  1219. value={userSearchQuery}
  1220. onChange={e => setUserSearchQuery(e.target.value)}
  1221. />
  1222. </div>
  1223. <div className="mt-3 flex gap-1 p-1 bg-white border border-slate-200 rounded-xl">
  1224. <button
  1225. onClick={() => setBindingRole('USER')}
  1226. 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'}`}
  1227. >
  1228. {t('roleRegularUser')}
  1229. </button>
  1230. <button
  1231. onClick={() => setBindingRole('TENANT_ADMIN')}
  1232. 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'}`}
  1233. >
  1234. {t('roleTenantAdmin')}
  1235. </button>
  1236. </div>
  1237. </div>
  1238. <div className="flex-1 overflow-y-auto p-4 space-y-2">
  1239. {users
  1240. .filter(u =>
  1241. u.username !== 'admin' &&
  1242. u.username.toLowerCase().includes(userSearchQuery.toLowerCase())
  1243. )
  1244. .map(u => {
  1245. const isAlreadyMember = allMemberIds.has(u.id);
  1246. return (
  1247. <button
  1248. key={u.id}
  1249. onClick={() => !isAlreadyMember && handleAddMember(activeTenant.id, u.id, bindingRole)}
  1250. disabled={isAlreadyMember}
  1251. className={`w-full p-3 border rounded-xl flex items-center justify-between group transition-all ${isAlreadyMember
  1252. ? 'bg-slate-50 border-slate-100 cursor-not-allowed opacity-60'
  1253. : 'bg-white border-slate-100 hover:border-indigo-500 hover:shadow-sm'
  1254. }`}
  1255. >
  1256. <div className="flex items-center gap-2 min-w-0">
  1257. <div className={`w-8 h-8 rounded-lg flex items-center justify-center transition-colors ${isAlreadyMember
  1258. ? 'bg-slate-100 text-slate-300'
  1259. : 'bg-slate-50 text-slate-400 group-hover:bg-indigo-50 group-hover:text-indigo-600'
  1260. }`}>
  1261. <User size={14} />
  1262. </div>
  1263. <span className={`text-[13px] font-bold truncate ${isAlreadyMember ? 'text-slate-400' : 'text-slate-700'}`}>
  1264. {u.username}
  1265. </span>
  1266. </div>
  1267. {isAlreadyMember ? (
  1268. <Check size={14} className="text-emerald-500" />
  1269. ) : (
  1270. <Plus size={14} className="text-slate-300 group-hover:text-indigo-600" />
  1271. )}
  1272. </button>
  1273. );
  1274. })
  1275. }
  1276. <Pagination
  1277. current={userPage}
  1278. total={userTotal}
  1279. pageSize={USER_PAGE_SIZE}
  1280. onChange={setUserPage}
  1281. />
  1282. </div>
  1283. </div>
  1284. </div>
  1285. {/* Create Tenant Modal (Nested in Tab Content for scope) */}
  1286. {showCreateTenant && (
  1287. <div className="fixed inset-0 z-[120] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4 text-left">
  1288. <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">
  1289. <h3 className="text-xl font-black text-slate-900 mb-6">{editingTenant ? t('editOrg') : t('newTenant')}</h3>
  1290. <form onSubmit={handleCreateTenant} className="space-y-5">
  1291. <div>
  1292. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('tenantName')}</label>
  1293. <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 />
  1294. </div>
  1295. <div>
  1296. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('domainOptional')}</label>
  1297. <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 })} />
  1298. </div>
  1299. <div>
  1300. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('parentOrg')}</label>
  1301. {!editingTenant ? (
  1302. <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">
  1303. {newTenant.parentId ? tenants.find(t => t.id === newTenant.parentId)?.name : t('noneRoot')}
  1304. </div>
  1305. ) : (
  1306. <select
  1307. 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"
  1308. value={newTenant.parentId || ''}
  1309. onChange={e => setNewTenant({ ...newTenant, parentId: e.target.value || null })}
  1310. >
  1311. <option value="">{t('noneRoot')}</option>
  1312. {tenants.filter(t => t.id !== editingTenant?.id).map(t => (
  1313. <option key={t.id} value={t.id}>{t.name}</option>
  1314. ))}
  1315. </select>
  1316. )}
  1317. </div>
  1318. <div className="flex gap-4 pt-2">
  1319. <button type="button" onClick={() => { setShowCreateTenant(false); setEditingTenant(null); }} className="flex-1 py-3 text-slate-500 font-bold text-sm">{t('cancel')}</button>
  1320. <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>
  1321. </div>
  1322. </form>
  1323. </motion.div>
  1324. </div>
  1325. )}
  1326. </div>
  1327. ) : (
  1328. <div className="flex-1 flex flex-col items-center justify-center p-12 text-center bg-slate-50/30">
  1329. <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">
  1330. <Building size={48} />
  1331. </div>
  1332. <h2 className="text-2xl font-black text-slate-900 tracking-tight mb-2">{t('selectOrg')}</h2>
  1333. <p className="text-sm text-slate-400 max-w-xs font-medium leading-relaxed">{t('selectOrgDesc')}</p>
  1334. <div className="mt-12 grid grid-cols-2 gap-4 w-full max-w-lg">
  1335. <div className="p-6 bg-white border border-slate-200 rounded-3xl text-left shadow-sm">
  1336. <div className="w-10 h-10 rounded-xl bg-orange-50 flex items-center justify-center text-orange-600 mb-4">
  1337. <Users size={20} />
  1338. </div>
  1339. <p className="text-xl font-black text-slate-900">{stats.users}</p>
  1340. <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">{t('totalSystemUsers')}</p>
  1341. </div>
  1342. <div className="p-6 bg-white border border-slate-200 rounded-3xl text-left shadow-sm">
  1343. <div className="w-10 h-10 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-600 mb-4">
  1344. <Shield size={20} />
  1345. </div>
  1346. <p className="text-xl font-black text-slate-900">{tenants.filter(t => t.parentId === null).length}</p>
  1347. <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">{t('rootOrgs')}</p>
  1348. </div>
  1349. </div>
  1350. {/* Scope Modal for Create even when no selection */}
  1351. {showCreateTenant && (
  1352. <div className="fixed inset-0 z-[120] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4 text-left">
  1353. <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">
  1354. <h3 className="text-xl font-black text-slate-900 mb-6">{t('newTenant')}</h3>
  1355. <form onSubmit={handleCreateTenant} className="space-y-5">
  1356. <div>
  1357. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('tenantName')}</label>
  1358. <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 />
  1359. </div>
  1360. <div>
  1361. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('domainOptional')}</label>
  1362. <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 })} />
  1363. </div>
  1364. <div>
  1365. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('parentOrg')}</label>
  1366. <select
  1367. 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"
  1368. value={newTenant.parentId || ''}
  1369. onChange={e => setNewTenant({ ...newTenant, parentId: e.target.value || null })}
  1370. >
  1371. <option value="">{t('noneRoot')}</option>
  1372. {tenants.map(t => (
  1373. <option key={t.id} value={t.id}>{t.name}</option>
  1374. ))}
  1375. </select>
  1376. </div>
  1377. <div className="flex gap-4 pt-2">
  1378. <button
  1379. type="button"
  1380. onClick={() => {
  1381. setShowCreateTenant(false);
  1382. setNewTenant({ name: '', domain: '', parentId: null });
  1383. }}
  1384. className="flex-1 py-3 text-slate-500 font-bold text-sm"
  1385. >
  1386. {t('cancel')}
  1387. </button>
  1388. <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>
  1389. </div>
  1390. </form>
  1391. </motion.div>
  1392. </div>
  1393. )}
  1394. </div>
  1395. )}
  1396. </div>
  1397. </div>
  1398. );
  1399. };
  1400. const renderKnowledgeBaseTab = () => (
  1401. <div className="space-y-8 animate-in slide-in-from-right duration-300 w-full max-w-5xl pb-10">
  1402. {localKbSettings && (
  1403. <>
  1404. {/* Save/Cancel Bar */}
  1405. <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">
  1406. <button
  1407. onClick={handleCancelKbSettings}
  1408. disabled={isSavingKbSettings || JSON.stringify(localKbSettings) === JSON.stringify(kbSettings)}
  1409. className="px-6 py-2 text-sm font-bold text-slate-500 hover:text-slate-700 disabled:opacity-30"
  1410. >
  1411. {t('cancel')}
  1412. </button>
  1413. <button
  1414. onClick={handleSaveKbSettings}
  1415. disabled={isSavingKbSettings || JSON.stringify(localKbSettings) === JSON.stringify(kbSettings)}
  1416. 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"
  1417. >
  1418. {isSavingKbSettings ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
  1419. {t('saveChanges')}
  1420. </button>
  1421. </div>
  1422. {/* Model Configuration */}
  1423. <section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
  1424. <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
  1425. <div className="w-8 h-8 rounded-xl bg-indigo-50 flex items-center justify-center text-indigo-600">
  1426. <Cpu size={16} />
  1427. </div>
  1428. {t('modelConfiguration')}
  1429. </div>
  1430. <div className="grid grid-cols-1 gap-6">
  1431. <div>
  1432. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('defaultLLMModel')}</label>
  1433. <select
  1434. value={localKbSettings.selectedLLMId || ''}
  1435. onChange={(e) => handleUpdateKbSettings('selectedLLMId', e.target.value)}
  1436. 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"
  1437. >
  1438. <option value="">{t('selectLLM')}</option>
  1439. {models.filter(m => m.type === ModelType.LLM).map(m => (
  1440. <option key={m.id} value={m.id}>{m.name} ({m.modelId})</option>
  1441. ))}
  1442. </select>
  1443. </div>
  1444. <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
  1445. <div>
  1446. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('embeddingModel')}</label>
  1447. <select
  1448. value={localKbSettings.selectedEmbeddingId || ''}
  1449. onChange={(e) => handleUpdateKbSettings('selectedEmbeddingId', e.target.value)}
  1450. 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"
  1451. >
  1452. <option value="">{t('selectEmbedding')}</option>
  1453. {models.filter(m => m.type === ModelType.EMBEDDING).map(m => (
  1454. <option key={m.id} value={m.id}>{m.name}</option>
  1455. ))}
  1456. </select>
  1457. </div>
  1458. <div>
  1459. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('rerankModel')}</label>
  1460. <select
  1461. value={localKbSettings.selectedRerankId || ''}
  1462. onChange={(e) => handleUpdateKbSettings('selectedRerankId', e.target.value)}
  1463. 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"
  1464. >
  1465. <option value="">{t('none')}</option>
  1466. {models.filter(m => m.type === ModelType.RERANK).map(m => (
  1467. <option key={m.id} value={m.id}>{m.name}</option>
  1468. ))}
  1469. </select>
  1470. </div>
  1471. </div>
  1472. </div>
  1473. </section>
  1474. {/* Indexing & Chunking Configuration */}
  1475. <section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
  1476. <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
  1477. <div className="w-8 h-8 rounded-xl bg-orange-50 flex items-center justify-center text-orange-600">
  1478. <BookOpen size={16} />
  1479. </div>
  1480. {t('indexingChunkingConfig')}
  1481. </div>
  1482. <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
  1483. <div>
  1484. <div className="flex justify-between mb-3 px-1">
  1485. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('chunkSize')}</label>
  1486. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.chunkSize || 1000}</span>
  1487. </div>
  1488. <input
  1489. type="range"
  1490. min="100"
  1491. max="8192"
  1492. step="100"
  1493. value={localKbSettings.chunkSize || 1000}
  1494. onChange={(e) => handleUpdateKbSettings('chunkSize', parseInt(e.target.value))}
  1495. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1496. />
  1497. </div>
  1498. <div>
  1499. <div className="flex justify-between mb-3 px-1">
  1500. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('chunkOverlap')}</label>
  1501. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.chunkOverlap || 100}</span>
  1502. </div>
  1503. <input
  1504. type="range"
  1505. min="0"
  1506. max="2048"
  1507. step="10"
  1508. value={localKbSettings.chunkOverlap || 100}
  1509. onChange={(e) => handleUpdateKbSettings('chunkOverlap', parseInt(e.target.value))}
  1510. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1511. />
  1512. </div>
  1513. </div>
  1514. </section >
  1515. {/* Chat Hyperparameters */}
  1516. < section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6" >
  1517. <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
  1518. <div className="w-8 h-8 rounded-xl bg-pink-50 flex items-center justify-center text-pink-600">
  1519. <Sparkles size={16} />
  1520. </div>
  1521. {t('chatHyperparameters')}
  1522. </div>
  1523. <div className="space-y-8">
  1524. <div>
  1525. <div className="flex justify-between mb-3 px-1">
  1526. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('temperature')}</label>
  1527. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.temperature}</span>
  1528. </div>
  1529. <input
  1530. type="range"
  1531. min="0"
  1532. max="1"
  1533. step="0.1"
  1534. value={localKbSettings.temperature || 0.7}
  1535. onChange={(e) => handleUpdateKbSettings('temperature', parseFloat(e.target.value))}
  1536. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1537. />
  1538. <div className="flex justify-between mt-2 px-1 text-[9px] font-bold text-slate-300 uppercase tracking-tighter">
  1539. <span>{t('precise')}</span>
  1540. <span>{t('creative')}</span>
  1541. </div>
  1542. </div>
  1543. <div>
  1544. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('maxResponseTokens')}</label>
  1545. <input
  1546. type="number"
  1547. value={localKbSettings.maxTokens || 2000}
  1548. onChange={(e) => handleUpdateKbSettings('maxTokens', parseInt(e.target.value))}
  1549. 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"
  1550. />
  1551. </div>
  1552. </div>
  1553. </section >
  1554. {/* Retrieval & Search Settings */}
  1555. < section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6" >
  1556. <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
  1557. <div className="w-8 h-8 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-600">
  1558. <Database size={16} />
  1559. </div>
  1560. {t('retrievalSearchSettings')}
  1561. </div>
  1562. <div className="space-y-8">
  1563. <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
  1564. <div>
  1565. <div className="flex justify-between mb-3 px-1">
  1566. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('topK')}</label>
  1567. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.topK}</span>
  1568. </div>
  1569. <input
  1570. type="range"
  1571. min="1"
  1572. max="50"
  1573. step="1"
  1574. value={localKbSettings.topK || 10}
  1575. onChange={(e) => handleUpdateKbSettings('topK', parseInt(e.target.value))}
  1576. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1577. />
  1578. </div>
  1579. <div>
  1580. <div className="flex justify-between mb-3 px-1">
  1581. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('similarityThreshold')}</label>
  1582. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.similarityThreshold}</span>
  1583. </div>
  1584. <input
  1585. type="range"
  1586. min="0"
  1587. max="1"
  1588. step="0.05"
  1589. value={localKbSettings.similarityThreshold || 0.5}
  1590. onChange={(e) => handleUpdateKbSettings('similarityThreshold', parseFloat(e.target.value))}
  1591. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1592. />
  1593. </div>
  1594. </div>
  1595. <div className="space-y-4 pt-4 border-t border-slate-100">
  1596. <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">
  1597. <div>
  1598. <div className="text-sm font-bold text-slate-800">{t('enableHybridSearch')}</div>
  1599. <div className="text-[10px] text-slate-400 font-medium">{t('hybridSearchDesc')}</div>
  1600. </div>
  1601. <button
  1602. onClick={() => handleUpdateKbSettings('enableFullTextSearch', !localKbSettings.enableFullTextSearch)}
  1603. 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'}`}
  1604. >
  1605. <span className={`${localKbSettings.enableFullTextSearch ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
  1606. </button>
  1607. </div>
  1608. {localKbSettings.enableFullTextSearch && (
  1609. <motion.div
  1610. initial={{ opacity: 0, y: -10 }}
  1611. animate={{ opacity: 1, y: 0 }}
  1612. className="p-5 bg-indigo-50/30 rounded-2xl border border-indigo-100/50 space-y-4"
  1613. >
  1614. <div className="flex justify-between mb-2 px-1">
  1615. <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">{t('hybridWeight')}</label>
  1616. <span className="text-sm font-black text-indigo-600">{localKbSettings.hybridVectorWeight || 0.5}</span>
  1617. </div>
  1618. <input
  1619. type="range"
  1620. min="0"
  1621. max="1"
  1622. step="0.05"
  1623. value={localKbSettings.hybridVectorWeight || 0.5}
  1624. onChange={(e) => handleUpdateKbSettings('hybridVectorWeight', parseFloat(e.target.value))}
  1625. className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1626. />
  1627. <div className="flex justify-between mt-1 px-1 text-[9px] font-bold text-indigo-300 uppercase">
  1628. <span>{t('pureText')}</span>
  1629. <span>{t('pureVector')}</span>
  1630. </div>
  1631. </motion.div>
  1632. )}
  1633. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  1634. <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">
  1635. <div>
  1636. <div className="text-sm font-bold text-slate-800">{t('enableQueryExpansion')}</div>
  1637. <div className="text-[10px] text-slate-400 font-medium">{t('queryExpansionDesc')}</div>
  1638. </div>
  1639. <button
  1640. onClick={() => handleUpdateKbSettings('enableQueryExpansion', !localKbSettings.enableQueryExpansion)}
  1641. 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'}`}
  1642. >
  1643. <span className={`${localKbSettings.enableQueryExpansion ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
  1644. </button>
  1645. </div>
  1646. <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">
  1647. <div>
  1648. <div className="text-sm font-bold text-slate-800">{t('enableHyDE')}</div>
  1649. <div className="text-[10px] text-slate-400 font-medium">{t('hydeDesc')}</div>
  1650. </div>
  1651. <button
  1652. onClick={() => handleUpdateKbSettings('enableHyDE', !localKbSettings.enableHyDE)}
  1653. 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'}`}
  1654. >
  1655. <span className={`${localKbSettings.enableHyDE ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
  1656. </button>
  1657. </div>
  1658. </div>
  1659. <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">
  1660. <div>
  1661. <div className="text-sm font-bold text-slate-800">{t('enableReranking')}</div>
  1662. <div className="text-[10px] text-slate-400 font-medium">{t('rerankingDesc')}</div>
  1663. </div>
  1664. <button
  1665. onClick={() => handleUpdateKbSettings('enableRerank', !localKbSettings.enableRerank)}
  1666. 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'}`}
  1667. >
  1668. <span className={`${localKbSettings.enableRerank ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
  1669. </button>
  1670. </div>
  1671. {localKbSettings.enableRerank && (
  1672. <motion.div
  1673. initial={{ opacity: 0, y: -10 }}
  1674. animate={{ opacity: 1, y: 0 }}
  1675. className="p-5 bg-indigo-50/30 rounded-2xl border border-indigo-100/50 space-y-4"
  1676. >
  1677. <div className="flex justify-between mb-2 px-1">
  1678. <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">{t('rerankSimilarityThreshold')}</label>
  1679. <span className="text-sm font-black text-indigo-600">{localKbSettings.rerankSimilarityThreshold || 0.5}</span>
  1680. </div>
  1681. <input
  1682. type="range"
  1683. min="0"
  1684. max="1"
  1685. step="0.05"
  1686. value={localKbSettings.rerankSimilarityThreshold || 0.5}
  1687. onChange={(e) => handleUpdateKbSettings('rerankSimilarityThreshold', parseFloat(e.target.value))}
  1688. className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1689. />
  1690. <div className="flex justify-between mt-1 px-1 text-[9px] font-bold text-indigo-300 uppercase">
  1691. <span>{t('broad')}</span>
  1692. <span>{t('strict')}</span>
  1693. </div>
  1694. </motion.div>
  1695. )}
  1696. </div>
  1697. </div>
  1698. </section>
  1699. </>
  1700. )}
  1701. </div>
  1702. );
  1703. const renderModelTab = () => (
  1704. <div className="w-full space-y-6">
  1705. <div className="flex justify-between items-center mb-6">
  1706. <div>
  1707. </div>
  1708. {!editingId && currentUser?.role === 'SUPER_ADMIN' && (
  1709. <button
  1710. onClick={() => { setEditingId('new'); setModelFormData({ type: ModelType.LLM, baseUrl: 'http://localhost:11434/v1', modelId: 'llama3', name: '', dimensions: 1536 }); }}
  1711. 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"
  1712. >
  1713. <Plus className="w-4 h-4" />
  1714. {t('mmAddBtn')}
  1715. </button>
  1716. )}
  1717. </div>
  1718. {editingId ? (
  1719. <motion.div
  1720. initial={{ opacity: 0, y: 10 }}
  1721. animate={{ opacity: 1, y: 0 }}
  1722. className="bg-white/90 backdrop-blur-md p-10 rounded-3xl border border-slate-200/50 shadow-xl space-y-8"
  1723. >
  1724. <div className="flex items-center gap-4 mb-2">
  1725. <div className="w-12 h-12 rounded-2xl bg-indigo-50 flex items-center justify-center text-indigo-600">
  1726. <Cpu className="w-6 h-6" />
  1727. </div>
  1728. <h3 className="text-xl font-black text-slate-900 tracking-tight">{editingId === 'new' ? t('mmAddBtn') : t('mmEdit')}</h3>
  1729. </div>
  1730. <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
  1731. <div className="space-y-2">
  1732. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormName')} *</label>
  1733. <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} />
  1734. </div>
  1735. <div className="space-y-2">
  1736. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormModelId')} *</label>
  1737. <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} />
  1738. </div>
  1739. </div>
  1740. <div className="space-y-2">
  1741. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormType')} *</label>
  1742. <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}>
  1743. <option value={ModelType.LLM}>{t('typeLLM')}</option>
  1744. <option value={ModelType.EMBEDDING}>{t('typeEmbedding')}</option>
  1745. <option value={ModelType.RERANK}>{t('typeRerank')}</option>
  1746. <option value={ModelType.VISION}>{t('typeVision')}</option>
  1747. </select>
  1748. </div>
  1749. <div className="space-y-2">
  1750. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormBaseUrl')} *</label>
  1751. <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} />
  1752. </div>
  1753. <div className="space-y-2">
  1754. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormApiKey')}</label>
  1755. <input
  1756. type="password"
  1757. 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"
  1758. value={modelFormData.apiKey || ''}
  1759. onChange={e => setModelFormData({ ...modelFormData, apiKey: e.target.value })}
  1760. disabled={isLoading}
  1761. placeholder={t('mmFormApiKeyPlaceholder')}
  1762. />
  1763. </div>
  1764. {modelFormData.type === ModelType.EMBEDDING && (
  1765. <div className="grid grid-cols-2 gap-6 p-6 bg-slate-50 rounded-3xl border border-slate-200/50">
  1766. <div className="space-y-2">
  1767. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('maxInput')}</label>
  1768. <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) })} />
  1769. </div>
  1770. <div className="space-y-2">
  1771. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('dimensions')}</label>
  1772. <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) })} />
  1773. </div>
  1774. </div>
  1775. )}
  1776. <div className="flex justify-end gap-3 pt-4">
  1777. <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>
  1778. <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}>
  1779. {isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
  1780. {t('mmSave')}
  1781. </button>
  1782. </div>
  1783. </motion.div>
  1784. ) : (
  1785. <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">
  1786. <AnimatePresence>
  1787. {models.map((model, index) => (
  1788. <motion.div
  1789. key={model.id}
  1790. initial={{ opacity: 0, scale: 0.95 }}
  1791. animate={{ opacity: 1, scale: 1 }}
  1792. transition={{ delay: index * 0.05 }}
  1793. 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"
  1794. >
  1795. {/* Subtle background pattern/glow */}
  1796. <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" />
  1797. <div className="relative z-10">
  1798. <div className="flex items-start justify-between mb-5">
  1799. <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'}`}>
  1800. <Cpu size={26} strokeWidth={2.5} />
  1801. </div>
  1802. <div className="flex gap-1 items-center bg-slate-50 p-1 rounded-xl border border-slate-100/50">
  1803. <button
  1804. onClick={() => handleToggleModel(model)}
  1805. 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'}`}
  1806. title={((currentUser?.role === 'SUPER_ADMIN' ? !!model.isEnabled : enabledModelIds.includes(model.id))) ? t('modelEnabled') : t('modelDisabled')}
  1807. >
  1808. {((currentUser?.role === 'SUPER_ADMIN' ? !!model.isEnabled : enabledModelIds.includes(model.id))) ? <ToggleRight size={24} /> : <ToggleLeft size={24} />}
  1809. </button>
  1810. </div>
  1811. </div>
  1812. <div className="space-y-1 mb-6">
  1813. <div className="flex items-center gap-2.5">
  1814. <h4 className="font-black text-slate-900 text-lg tracking-tight truncate">{model.name}</h4>
  1815. </div>
  1816. <div className="flex items-center gap-2">
  1817. <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">
  1818. {getTypeLabel(model.type)}
  1819. </span>
  1820. {model.isDefault && (
  1821. <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">
  1822. <Sparkles size={8} /> {t('defaultBadge')}
  1823. </span>
  1824. )}
  1825. </div>
  1826. <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">
  1827. {model.modelId}
  1828. </p>
  1829. </div>
  1830. {/* Additional info grid */}
  1831. <div className="grid grid-cols-2 gap-3 mb-6">
  1832. {model.type === ModelType.EMBEDDING && (
  1833. <>
  1834. <div className="bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50">
  1835. <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('dims')}</span>
  1836. <span className="text-xs font-bold text-slate-700">{model.dimensions || '-'}</span>
  1837. </div>
  1838. <div className="bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50">
  1839. <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('ctx')}</span>
  1840. <span className="text-xs font-bold text-slate-700">{model.maxInputTokens || '-'}</span>
  1841. </div>
  1842. </>
  1843. )}
  1844. {model.type === ModelType.LLM && (
  1845. <div className="col-span-2 bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50 flex items-center justify-between">
  1846. <span className="text-[8px] font-black text-slate-400 uppercase tracking-widest">{t('baseApi')}</span>
  1847. <span className="text-[9px] font-mono font-medium text-slate-600 max-w-[140px] truncate">{model.baseUrl}</span>
  1848. </div>
  1849. )}
  1850. </div>
  1851. </div>
  1852. <div className="flex items-center justify-between pt-4 border-t border-slate-100/50 relative z-10">
  1853. <div className="flex items-center gap-1 text-[10px] font-bold text-slate-400">
  1854. <SettingsIcon size={12} />
  1855. {t('configured')}
  1856. </div>
  1857. <div className="flex gap-2">
  1858. {currentUser?.role === 'SUPER_ADMIN' && (
  1859. <>
  1860. <button
  1861. onClick={() => { setEditingId(model.id); setModelFormData({ ...model }); }}
  1862. 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"
  1863. >
  1864. <Edit2 size={15} />
  1865. </button>
  1866. <button
  1867. onClick={() => handleDeleteModel(model.id)}
  1868. 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"
  1869. >
  1870. <Trash2 size={15} />
  1871. </button>
  1872. </>
  1873. )}
  1874. </div>
  1875. </div>
  1876. </motion.div>
  1877. ))}
  1878. </AnimatePresence>
  1879. {models.length === 0 && (
  1880. <div className="bg-white/50 border-2 border-dashed border-slate-200 rounded-3xl p-16 text-center">
  1881. <Cpu className="w-12 h-12 text-slate-200 mx-auto mb-4" />
  1882. <p className="text-slate-400 font-bold uppercase tracking-widest text-xs">{t('mmEmpty')}</p>
  1883. </div>
  1884. )}
  1885. </div>
  1886. )}
  1887. </div>
  1888. );
  1889. return (
  1890. <div className="flex h-full bg-[#FCFDFF] overflow-hidden relative">
  1891. {/* Settings Sidebar */}
  1892. <div className="w-64 bg-slate-50/50 border-r border-slate-200/60 flex flex-col shrink-0 z-20">
  1893. <div className="p-6 pb-2">
  1894. <h2 className="text-lg font-bold text-slate-900 tracking-tight">{t('tabSettings')}</h2>
  1895. </div>
  1896. <div className="flex-1 overflow-y-auto p-3 space-y-1">
  1897. <button
  1898. onClick={() => setActiveTab('general')}
  1899. 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'
  1900. }`}
  1901. >
  1902. <SettingsIcon size={18} />
  1903. {t('generalSettings')}
  1904. </button>
  1905. {currentUser?.role === 'SUPER_ADMIN' && (
  1906. <button
  1907. onClick={() => setActiveTab('user')}
  1908. 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'
  1909. }`}
  1910. >
  1911. <UserCircle size={18} />
  1912. {t('userManagement')}
  1913. </button>
  1914. )}
  1915. {isAdmin && (
  1916. <>
  1917. <button
  1918. onClick={() => setActiveTab('model')}
  1919. 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'
  1920. }`}
  1921. >
  1922. <HardDrive size={18} />
  1923. {t('modelManagement')}
  1924. </button>
  1925. <button
  1926. onClick={() => setActiveTab('knowledge_base')}
  1927. 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'
  1928. }`}
  1929. >
  1930. <Database size={18} />
  1931. {t('sidebarTitle')}
  1932. </button>
  1933. </>
  1934. )}
  1935. {currentUser?.role === 'SUPER_ADMIN' && (
  1936. <button
  1937. onClick={() => setActiveTab('tenants')}
  1938. 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'
  1939. }`}
  1940. >
  1941. <LayoutGrid size={18} />
  1942. {t('navTenants')}
  1943. </button>
  1944. )}
  1945. </div>
  1946. </div>
  1947. {/* Content Area */}
  1948. <div className="flex-1 flex flex-col min-w-0 h-full overflow-hidden relative bg-white z-10">
  1949. <div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
  1950. <div>
  1951. <h1 className="text-2xl font-bold text-slate-900 leading-tight">
  1952. {activeTab === 'general' ? t('generalSettings') : activeTab === 'user' ? t('userManagement') : activeTab === 'model' ? t('modelManagement') : activeTab === 'knowledge_base' ? t('sidebarTitle') : t('navTenants')}
  1953. </h1>
  1954. <p className="text-[15px] text-slate-500 mt-1">
  1955. {activeTab === 'general' ? t('generalSettingsSubtitle') : activeTab === 'user' ? t('userManagementSubtitle') : activeTab === 'model' ? t('modelManagementSubtitle') : activeTab === 'knowledge_base' ? t('kbSettingsSubtitle') : t('tenantsSubtitle')}
  1956. </p>
  1957. </div>
  1958. </div>
  1959. <div className="flex-1 overflow-y-auto px-10 pb-10 scrollbar-hide">
  1960. <div className="w-full">
  1961. {error && (
  1962. <motion.div
  1963. initial={{ opacity: 0, y: -10 }}
  1964. animate={{ opacity: 1, y: 0 }}
  1965. 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"
  1966. >
  1967. <div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center shrink-0">
  1968. <X className="w-4 h-4 text-red-600" />
  1969. </div>
  1970. <div>
  1971. <span className="font-black uppercase tracking-widest text-[10px] block mb-0.5">{t('errorLabel')}</span>
  1972. {error}
  1973. </div>
  1974. </motion.div>
  1975. )}
  1976. <AnimatePresence mode="wait">
  1977. <motion.div
  1978. key={activeTab}
  1979. initial={{ opacity: 0, x: 20 }}
  1980. animate={{ opacity: 1, x: 0 }}
  1981. exit={{ opacity: 0, x: -20 }}
  1982. transition={{ duration: 0.3 }}
  1983. >
  1984. {activeTab === 'general' && renderGeneralTab()}
  1985. {activeTab === 'user' && currentUser?.role === 'SUPER_ADMIN' && renderUserTab()}
  1986. {activeTab === 'model' && isAdmin && renderModelTab()}
  1987. {activeTab === 'knowledge_base' && isAdmin && renderKnowledgeBaseTab()}
  1988. {activeTab === 'tenants' && currentUser?.role === 'SUPER_ADMIN' && renderTenantsTab()}
  1989. </motion.div>
  1990. </AnimatePresence>
  1991. </div>
  1992. </div>
  1993. </div>
  1994. </div>
  1995. );
  1996. };