index.jsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  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 { Notification, Button, Space, Toast, Typography, Select } from '@douyinfe/semi-ui';
  17. import { API, showError, getModelCategories, selectFilter } from '../../../helpers';
  18. import CardPro from '../../common/ui/CardPro';
  19. import TokensTable from './TokensTable.jsx';
  20. import TokensActions from './TokensActions.jsx';
  21. import TokensFilters from './TokensFilters.jsx';
  22. import TokensDescription from './TokensDescription.jsx';
  23. import EditTokenModal from './modals/EditTokenModal';
  24. import { useTokensData } from '../../../hooks/tokens/useTokensData';
  25. import { useIsMobile } from '../../../hooks/common/useIsMobile';
  26. import { createCardProPagination } from '../../../helpers/utils';
  27. function TokensPage() {
  28. // Define the function first, then pass it into the hook to avoid TDZ errors
  29. const openFluentNotificationRef = useRef(null);
  30. const tokensData = useTokensData((key) => openFluentNotificationRef.current?.(key));
  31. const isMobile = useIsMobile();
  32. const latestRef = useRef({ tokens: [], selectedKeys: [], t: (k) => k, selectedModel: '', prefillKey: '' });
  33. const [modelOptions, setModelOptions] = useState([]);
  34. const [selectedModel, setSelectedModel] = useState('');
  35. const [fluentNoticeOpen, setFluentNoticeOpen] = useState(false);
  36. const [prefillKey, setPrefillKey] = useState('');
  37. // Keep latest data for handlers inside notifications
  38. useEffect(() => {
  39. latestRef.current = {
  40. tokens: tokensData.tokens,
  41. selectedKeys: tokensData.selectedKeys,
  42. t: tokensData.t,
  43. selectedModel,
  44. prefillKey,
  45. };
  46. }, [tokensData.tokens, tokensData.selectedKeys, tokensData.t, selectedModel, prefillKey]);
  47. const loadModels = async () => {
  48. try {
  49. const res = await API.get('/api/user/models');
  50. const { success, message, data } = res.data || {};
  51. if (success) {
  52. const categories = getModelCategories(tokensData.t);
  53. const options = (data || []).map((model) => {
  54. let icon = null;
  55. for (const [key, category] of Object.entries(categories)) {
  56. if (key !== 'all' && category.filter({ model_name: model })) {
  57. icon = category.icon;
  58. break;
  59. }
  60. }
  61. return {
  62. label: (
  63. <span className="flex items-center gap-1">
  64. {icon}
  65. {model}
  66. </span>
  67. ),
  68. value: model,
  69. };
  70. });
  71. setModelOptions(options);
  72. } else {
  73. showError(tokensData.t(message));
  74. }
  75. } catch (e) {
  76. showError(e.message || 'Failed to load models');
  77. }
  78. };
  79. function openFluentNotification(key) {
  80. const { t } = latestRef.current;
  81. const SUPPRESS_KEY = 'fluent_notify_suppressed';
  82. if (localStorage.getItem(SUPPRESS_KEY) === '1') return;
  83. const container = document.getElementById('fluent-new-api-container');
  84. if (!container) {
  85. Toast.warning(t('未检测到 Fluent 容器,请确认扩展已启用'));
  86. return;
  87. }
  88. setPrefillKey(key || '');
  89. setFluentNoticeOpen(true);
  90. if (modelOptions.length === 0) {
  91. // fire-and-forget; a later effect will refresh the notice content
  92. loadModels()
  93. }
  94. Notification.info({
  95. id: 'fluent-detected',
  96. title: t('检测到 Fluent(流畅阅读)'),
  97. content: (
  98. <div>
  99. <div style={{ marginBottom: 8 }}>
  100. {prefillKey
  101. ? t('已检测到 Fluent 扩展,已从操作中指定密钥,将使用该密钥进行填充。请选择模型后继续。')
  102. : t('已检测到 Fluent 扩展,请选择模型后可一键填充当前选中令牌(或本页第一个令牌)。')}
  103. </div>
  104. <div style={{ marginBottom: 8 }}>
  105. <Select
  106. placeholder={t('请选择模型')}
  107. optionList={modelOptions}
  108. onChange={setSelectedModel}
  109. filter={selectFilter}
  110. style={{ width: 320 }}
  111. showClear
  112. searchable
  113. emptyContent={t('暂无数据')}
  114. />
  115. </div>
  116. <Space>
  117. <Button theme="solid" type="primary" onClick={handlePrefillToFluent}>
  118. {t('一键填充到 Fluent')}
  119. </Button>
  120. <Button type="warning" onClick={() => {
  121. localStorage.setItem(SUPPRESS_KEY, '1');
  122. Notification.close('fluent-detected');
  123. Toast.info(t('已关闭后续提醒'));
  124. }}>
  125. {t('不再提醒')}
  126. </Button>
  127. <Button type="tertiary" onClick={() => Notification.close('fluent-detected')}>
  128. {t('关闭')}
  129. </Button>
  130. </Space>
  131. </div>
  132. ),
  133. duration: 0,
  134. });
  135. }
  136. // assign after definition so hook callback can call it safely
  137. openFluentNotificationRef.current = openFluentNotification;
  138. // Prefill to Fluent handler
  139. const handlePrefillToFluent = () => {
  140. const { tokens, selectedKeys, t, selectedModel: chosenModel, prefillKey: overrideKey } = latestRef.current;
  141. const container = document.getElementById('fluent-new-api-container');
  142. if (!container) {
  143. Toast.error(t('未检测到 Fluent 容器'));
  144. return;
  145. }
  146. if (!chosenModel) {
  147. Toast.warning(t('请选择模型'));
  148. return;
  149. }
  150. let status = localStorage.getItem('status');
  151. let serverAddress = '';
  152. if (status) {
  153. try {
  154. status = JSON.parse(status);
  155. serverAddress = status.server_address || '';
  156. } catch (_) { }
  157. }
  158. if (!serverAddress) serverAddress = window.location.origin;
  159. let apiKeyToUse = '';
  160. if (overrideKey) {
  161. apiKeyToUse = 'sk-' + overrideKey;
  162. } else {
  163. const token = (selectedKeys && selectedKeys.length === 1)
  164. ? selectedKeys[0]
  165. : (tokens && tokens.length > 0 ? tokens[0] : null);
  166. if (!token) {
  167. Toast.warning(t('没有可用令牌用于填充'));
  168. return;
  169. }
  170. apiKeyToUse = 'sk-' + token.key;
  171. }
  172. const payload = {
  173. id: 'new-api',
  174. baseUrl: serverAddress,
  175. apiKey: apiKeyToUse,
  176. model: chosenModel,
  177. };
  178. container.dispatchEvent(new CustomEvent('fluent:prefill', { detail: payload }));
  179. Toast.success(t('已发送到 Fluent'));
  180. Notification.close('fluent-detected');
  181. };
  182. // Show notification when Fluent container is available
  183. useEffect(() => {
  184. const onAppeared = () => {
  185. openFluentNotification();
  186. };
  187. const onRemoved = () => {
  188. setFluentNoticeOpen(false);
  189. Notification.close('fluent-detected');
  190. };
  191. window.addEventListener('fluent-container:appeared', onAppeared);
  192. window.addEventListener('fluent-container:removed', onRemoved);
  193. return () => {
  194. window.removeEventListener('fluent-container:appeared', onAppeared);
  195. window.removeEventListener('fluent-container:removed', onRemoved);
  196. };
  197. }, []);
  198. // When modelOptions or language changes while the notice is open, refresh the content
  199. useEffect(() => {
  200. if (fluentNoticeOpen) {
  201. openFluentNotification();
  202. }
  203. // eslint-disable-next-line react-hooks/exhaustive-deps
  204. }, [modelOptions, selectedModel, tokensData.t, fluentNoticeOpen]);
  205. useEffect(() => {
  206. const selector = '#fluent-new-api-container';
  207. const root = document.body || document.documentElement;
  208. const existing = document.querySelector(selector);
  209. if (existing) {
  210. console.log('Fluent container detected (initial):', existing);
  211. window.dispatchEvent(new CustomEvent('fluent-container:appeared', { detail: existing }));
  212. }
  213. const isOrContainsTarget = (node) => {
  214. if (!(node && node.nodeType === 1)) return false;
  215. if (node.id === 'fluent-new-api-container') return true;
  216. return typeof node.querySelector === 'function' && !!node.querySelector(selector);
  217. };
  218. const observer = new MutationObserver((mutations) => {
  219. for (const m of mutations) {
  220. // appeared
  221. for (const added of m.addedNodes) {
  222. if (isOrContainsTarget(added)) {
  223. const el = document.querySelector(selector);
  224. if (el) {
  225. console.log('Fluent container appeared:', el);
  226. window.dispatchEvent(new CustomEvent('fluent-container:appeared', { detail: el }));
  227. }
  228. break;
  229. }
  230. }
  231. // removed
  232. for (const removed of m.removedNodes) {
  233. if (isOrContainsTarget(removed)) {
  234. const elNow = document.querySelector(selector);
  235. if (!elNow) {
  236. console.log('Fluent container removed');
  237. window.dispatchEvent(new CustomEvent('fluent-container:removed'));
  238. }
  239. break;
  240. }
  241. }
  242. }
  243. });
  244. observer.observe(root, { childList: true, subtree: true });
  245. return () => observer.disconnect();
  246. }, []);
  247. const {
  248. // Edit state
  249. showEdit,
  250. editingToken,
  251. closeEdit,
  252. refresh,
  253. // Actions state
  254. selectedKeys,
  255. setEditingToken,
  256. setShowEdit,
  257. batchCopyTokens,
  258. batchDeleteTokens,
  259. copyText,
  260. // Filters state
  261. formInitValues,
  262. setFormApi,
  263. searchTokens,
  264. loading,
  265. searching,
  266. // Description state
  267. compactMode,
  268. setCompactMode,
  269. // Translation
  270. t,
  271. } = tokensData;
  272. return (
  273. <>
  274. <EditTokenModal
  275. refresh={refresh}
  276. editingToken={editingToken}
  277. visiable={showEdit}
  278. handleClose={closeEdit}
  279. />
  280. <CardPro
  281. type="type1"
  282. descriptionArea={
  283. <TokensDescription
  284. compactMode={compactMode}
  285. setCompactMode={setCompactMode}
  286. t={t}
  287. />
  288. }
  289. actionsArea={
  290. <div className="flex flex-col md:flex-row justify-between items-center gap-2 w-full">
  291. <TokensActions
  292. selectedKeys={selectedKeys}
  293. setEditingToken={setEditingToken}
  294. setShowEdit={setShowEdit}
  295. batchCopyTokens={batchCopyTokens}
  296. batchDeleteTokens={batchDeleteTokens}
  297. copyText={copyText}
  298. t={t}
  299. />
  300. <div className="w-full md:w-full lg:w-auto order-1 md:order-2">
  301. <TokensFilters
  302. formInitValues={formInitValues}
  303. setFormApi={setFormApi}
  304. searchTokens={searchTokens}
  305. loading={loading}
  306. searching={searching}
  307. t={t}
  308. />
  309. </div>
  310. </div>
  311. }
  312. paginationArea={createCardProPagination({
  313. currentPage: tokensData.activePage,
  314. pageSize: tokensData.pageSize,
  315. total: tokensData.tokenCount,
  316. onPageChange: tokensData.handlePageChange,
  317. onPageSizeChange: tokensData.handlePageSizeChange,
  318. isMobile: isMobile,
  319. t: tokensData.t,
  320. })}
  321. t={tokensData.t}
  322. >
  323. <TokensTable {...tokensData} />
  324. </CardPro>
  325. </>
  326. );
  327. }
  328. export default TokensPage;