index.jsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, { useEffect, useRef, useState } from 'react';
  16. import {
  17. Notification,
  18. Button,
  19. Space,
  20. Toast,
  21. Typography,
  22. Select,
  23. } from '@douyinfe/semi-ui';
  24. import {
  25. API,
  26. showError,
  27. getModelCategories,
  28. selectFilter,
  29. } from '../../../helpers';
  30. import CardPro from '../../common/ui/CardPro';
  31. import TokensTable from './TokensTable';
  32. import TokensActions from './TokensActions';
  33. import TokensFilters from './TokensFilters';
  34. import TokensDescription from './TokensDescription';
  35. import EditTokenModal from './modals/EditTokenModal';
  36. import CCSwitchModal from './modals/CCSwitchModal';
  37. import { useTokensData } from '../../../hooks/tokens/useTokensData';
  38. import { useIsMobile } from '../../../hooks/common/useIsMobile';
  39. import { createCardProPagination } from '../../../helpers/utils';
  40. function TokensPage() {
  41. // Define the function first, then pass it into the hook to avoid TDZ errors
  42. const openFluentNotificationRef = useRef(null);
  43. const openCCSwitchModalRef = useRef(null);
  44. const tokensData = useTokensData(
  45. (key) => openFluentNotificationRef.current?.(key),
  46. (key) => openCCSwitchModalRef.current?.(key),
  47. );
  48. const isMobile = useIsMobile();
  49. const latestRef = useRef({
  50. tokens: [],
  51. selectedKeys: [],
  52. t: (k) => k,
  53. selectedModel: '',
  54. prefillKey: '',
  55. });
  56. const [modelOptions, setModelOptions] = useState([]);
  57. const [selectedModel, setSelectedModel] = useState('');
  58. const [fluentNoticeOpen, setFluentNoticeOpen] = useState(false);
  59. const [prefillKey, setPrefillKey] = useState('');
  60. const [ccSwitchVisible, setCCSwitchVisible] = useState(false);
  61. const [ccSwitchKey, setCCSwitchKey] = useState('');
  62. // Keep latest data for handlers inside notifications
  63. useEffect(() => {
  64. latestRef.current = {
  65. tokens: tokensData.tokens,
  66. selectedKeys: tokensData.selectedKeys,
  67. t: tokensData.t,
  68. selectedModel,
  69. prefillKey,
  70. };
  71. }, [
  72. tokensData.tokens,
  73. tokensData.selectedKeys,
  74. tokensData.t,
  75. selectedModel,
  76. prefillKey,
  77. ]);
  78. const loadModels = async () => {
  79. try {
  80. const res = await API.get('/api/user/models');
  81. const { success, message, data } = res.data || {};
  82. if (success) {
  83. const categories = getModelCategories(tokensData.t);
  84. const options = (data || []).map((model) => {
  85. let icon = null;
  86. for (const [key, category] of Object.entries(categories)) {
  87. if (key !== 'all' && category.filter({ model_name: model })) {
  88. icon = category.icon;
  89. break;
  90. }
  91. }
  92. return {
  93. label: (
  94. <span className='flex items-center gap-1'>
  95. {icon}
  96. {model}
  97. </span>
  98. ),
  99. value: model,
  100. };
  101. });
  102. setModelOptions(options);
  103. } else {
  104. showError(tokensData.t(message));
  105. }
  106. } catch (e) {
  107. showError(e.message || 'Failed to load models');
  108. }
  109. };
  110. function openFluentNotification(key) {
  111. const { t } = latestRef.current;
  112. const SUPPRESS_KEY = 'fluent_notify_suppressed';
  113. if (modelOptions.length === 0) {
  114. // fire-and-forget; a later effect will refresh the notice content
  115. loadModels();
  116. }
  117. if (!key && localStorage.getItem(SUPPRESS_KEY) === '1') return;
  118. const container = document.getElementById('fluent-new-api-container');
  119. if (!container) {
  120. Toast.warning(t('未检测到 FluentRead(流畅阅读),请确认扩展已启用'));
  121. return;
  122. }
  123. setPrefillKey(key || '');
  124. setFluentNoticeOpen(true);
  125. Notification.info({
  126. id: 'fluent-detected',
  127. title: t('检测到 FluentRead(流畅阅读)'),
  128. content: (
  129. <div>
  130. <div style={{ marginBottom: 8 }}>
  131. {key
  132. ? t('请选择模型。')
  133. : t('选择模型后可一键填充当前选中令牌(或本页第一个令牌)。')}
  134. </div>
  135. <div style={{ marginBottom: 8 }}>
  136. <Select
  137. placeholder={t('请选择模型')}
  138. optionList={modelOptions}
  139. onChange={setSelectedModel}
  140. filter={selectFilter}
  141. style={{ width: 320 }}
  142. showClear
  143. searchable
  144. emptyContent={t('暂无数据')}
  145. />
  146. </div>
  147. <Space>
  148. <Button
  149. theme='solid'
  150. type='primary'
  151. onClick={handlePrefillToFluent}
  152. >
  153. {t('一键填充到 FluentRead')}
  154. </Button>
  155. {!key && (
  156. <Button
  157. type='warning'
  158. onClick={() => {
  159. localStorage.setItem(SUPPRESS_KEY, '1');
  160. Notification.close('fluent-detected');
  161. Toast.info(t('已关闭后续提醒'));
  162. }}
  163. >
  164. {t('不再提醒')}
  165. </Button>
  166. )}
  167. <Button
  168. type='tertiary'
  169. onClick={() => Notification.close('fluent-detected')}
  170. >
  171. {t('关闭')}
  172. </Button>
  173. </Space>
  174. </div>
  175. ),
  176. duration: 0,
  177. });
  178. }
  179. // assign after definition so hook callback can call it safely
  180. openFluentNotificationRef.current = openFluentNotification;
  181. function openCCSwitchModal(key) {
  182. if (modelOptions.length === 0) {
  183. loadModels();
  184. }
  185. setCCSwitchKey(key || '');
  186. setCCSwitchVisible(true);
  187. }
  188. openCCSwitchModalRef.current = openCCSwitchModal;
  189. // Prefill to Fluent handler
  190. const handlePrefillToFluent = () => {
  191. const {
  192. tokens,
  193. selectedKeys,
  194. t,
  195. selectedModel: chosenModel,
  196. prefillKey: overrideKey,
  197. } = latestRef.current;
  198. const container = document.getElementById('fluent-new-api-container');
  199. if (!container) {
  200. Toast.error(t('未检测到 Fluent 容器'));
  201. return;
  202. }
  203. if (!chosenModel) {
  204. Toast.warning(t('请选择模型'));
  205. return;
  206. }
  207. let status = localStorage.getItem('status');
  208. let serverAddress = '';
  209. if (status) {
  210. try {
  211. status = JSON.parse(status);
  212. serverAddress = status.server_address || '';
  213. } catch (_) {}
  214. }
  215. if (!serverAddress) serverAddress = window.location.origin;
  216. let apiKeyToUse = '';
  217. if (overrideKey) {
  218. apiKeyToUse = 'sk-' + overrideKey;
  219. } else {
  220. const token =
  221. selectedKeys && selectedKeys.length === 1
  222. ? selectedKeys[0]
  223. : tokens && tokens.length > 0
  224. ? tokens[0]
  225. : null;
  226. if (!token) {
  227. Toast.warning(t('没有可用令牌用于填充'));
  228. return;
  229. }
  230. apiKeyToUse = 'sk-' + token.key;
  231. }
  232. const payload = {
  233. id: 'new-api',
  234. baseUrl: serverAddress,
  235. apiKey: apiKeyToUse,
  236. model: chosenModel,
  237. };
  238. container.dispatchEvent(
  239. new CustomEvent('fluent:prefill', { detail: payload }),
  240. );
  241. Toast.success(t('已发送到 Fluent'));
  242. Notification.close('fluent-detected');
  243. };
  244. // Show notification when Fluent container is available
  245. useEffect(() => {
  246. const onAppeared = () => {
  247. openFluentNotification();
  248. };
  249. const onRemoved = () => {
  250. setFluentNoticeOpen(false);
  251. Notification.close('fluent-detected');
  252. };
  253. window.addEventListener('fluent-container:appeared', onAppeared);
  254. window.addEventListener('fluent-container:removed', onRemoved);
  255. return () => {
  256. window.removeEventListener('fluent-container:appeared', onAppeared);
  257. window.removeEventListener('fluent-container:removed', onRemoved);
  258. };
  259. }, []);
  260. // When modelOptions or language changes while the notice is open, refresh the content
  261. useEffect(() => {
  262. if (fluentNoticeOpen) {
  263. openFluentNotification();
  264. }
  265. // eslint-disable-next-line react-hooks/exhaustive-deps
  266. }, [modelOptions, selectedModel, tokensData.t, fluentNoticeOpen]);
  267. useEffect(() => {
  268. const selector = '#fluent-new-api-container';
  269. const root = document.body || document.documentElement;
  270. const existing = document.querySelector(selector);
  271. if (existing) {
  272. console.log('Fluent container detected (initial):', existing);
  273. window.dispatchEvent(
  274. new CustomEvent('fluent-container:appeared', { detail: existing }),
  275. );
  276. }
  277. const isOrContainsTarget = (node) => {
  278. if (!(node && node.nodeType === 1)) return false;
  279. if (node.id === 'fluent-new-api-container') return true;
  280. return (
  281. typeof node.querySelector === 'function' &&
  282. !!node.querySelector(selector)
  283. );
  284. };
  285. const observer = new MutationObserver((mutations) => {
  286. for (const m of mutations) {
  287. // appeared
  288. for (const added of m.addedNodes) {
  289. if (isOrContainsTarget(added)) {
  290. const el = document.querySelector(selector);
  291. if (el) {
  292. console.log('Fluent container appeared:', el);
  293. window.dispatchEvent(
  294. new CustomEvent('fluent-container:appeared', { detail: el }),
  295. );
  296. }
  297. break;
  298. }
  299. }
  300. // removed
  301. for (const removed of m.removedNodes) {
  302. if (isOrContainsTarget(removed)) {
  303. const elNow = document.querySelector(selector);
  304. if (!elNow) {
  305. console.log('Fluent container removed');
  306. window.dispatchEvent(new CustomEvent('fluent-container:removed'));
  307. }
  308. break;
  309. }
  310. }
  311. }
  312. });
  313. observer.observe(root, { childList: true, subtree: true });
  314. return () => observer.disconnect();
  315. }, []);
  316. const {
  317. // Edit state
  318. showEdit,
  319. editingToken,
  320. closeEdit,
  321. refresh,
  322. // Actions state
  323. selectedKeys,
  324. setEditingToken,
  325. setShowEdit,
  326. batchCopyTokens,
  327. batchDeleteTokens,
  328. copyText,
  329. // Filters state
  330. formInitValues,
  331. setFormApi,
  332. searchTokens,
  333. loading,
  334. searching,
  335. // Description state
  336. compactMode,
  337. setCompactMode,
  338. // Translation
  339. t,
  340. } = tokensData;
  341. return (
  342. <>
  343. <EditTokenModal
  344. refresh={refresh}
  345. editingToken={editingToken}
  346. visiable={showEdit}
  347. handleClose={closeEdit}
  348. />
  349. <CCSwitchModal
  350. visible={ccSwitchVisible}
  351. onClose={() => setCCSwitchVisible(false)}
  352. tokenKey={ccSwitchKey}
  353. modelOptions={modelOptions}
  354. />
  355. <CardPro
  356. type='type1'
  357. descriptionArea={
  358. <TokensDescription
  359. compactMode={compactMode}
  360. setCompactMode={setCompactMode}
  361. t={t}
  362. />
  363. }
  364. actionsArea={
  365. <div className='flex flex-col md:flex-row justify-between items-center gap-2 w-full'>
  366. <TokensActions
  367. selectedKeys={selectedKeys}
  368. setEditingToken={setEditingToken}
  369. setShowEdit={setShowEdit}
  370. batchCopyTokens={batchCopyTokens}
  371. batchDeleteTokens={batchDeleteTokens}
  372. copyText={copyText}
  373. t={t}
  374. />
  375. <div className='w-full md:w-full lg:w-auto order-1 md:order-2'>
  376. <TokensFilters
  377. formInitValues={formInitValues}
  378. setFormApi={setFormApi}
  379. searchTokens={searchTokens}
  380. loading={loading}
  381. searching={searching}
  382. t={t}
  383. />
  384. </div>
  385. </div>
  386. }
  387. paginationArea={createCardProPagination({
  388. currentPage: tokensData.activePage,
  389. pageSize: tokensData.pageSize,
  390. total: tokensData.tokenCount,
  391. onPageChange: tokensData.handlePageChange,
  392. onPageSizeChange: tokensData.handlePageSizeChange,
  393. isMobile: isMobile,
  394. t: tokensData.t,
  395. })}
  396. t={tokensData.t}
  397. >
  398. <TokensTable {...tokensData} />
  399. </CardPro>
  400. </>
  401. );
  402. }
  403. export default TokensPage;