| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360 |
- /*
- Copyright (C) 2025 QuantumNous
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
- For commercial licensing, please contact support@quantumnous.com
- */
- import React, { useEffect, useRef, useState } from 'react';
- import { Notification, Button, Space, Toast, Typography, Select } from '@douyinfe/semi-ui';
- import { API, showError, getModelCategories, selectFilter } from '../../../helpers';
- import CardPro from '../../common/ui/CardPro';
- import TokensTable from './TokensTable.jsx';
- import TokensActions from './TokensActions.jsx';
- import TokensFilters from './TokensFilters.jsx';
- import TokensDescription from './TokensDescription.jsx';
- import EditTokenModal from './modals/EditTokenModal';
- import { useTokensData } from '../../../hooks/tokens/useTokensData';
- import { useIsMobile } from '../../../hooks/common/useIsMobile';
- import { createCardProPagination } from '../../../helpers/utils';
- function TokensPage() {
- // Define the function first, then pass it into the hook to avoid TDZ errors
- const openFluentNotificationRef = useRef(null);
- const tokensData = useTokensData((key) => openFluentNotificationRef.current?.(key));
- const isMobile = useIsMobile();
- const latestRef = useRef({ tokens: [], selectedKeys: [], t: (k) => k, selectedModel: '', prefillKey: '' });
- const [modelOptions, setModelOptions] = useState([]);
- const [selectedModel, setSelectedModel] = useState('');
- const [fluentNoticeOpen, setFluentNoticeOpen] = useState(false);
- const [prefillKey, setPrefillKey] = useState('');
- // Keep latest data for handlers inside notifications
- useEffect(() => {
- latestRef.current = {
- tokens: tokensData.tokens,
- selectedKeys: tokensData.selectedKeys,
- t: tokensData.t,
- selectedModel,
- prefillKey,
- };
- }, [tokensData.tokens, tokensData.selectedKeys, tokensData.t, selectedModel, prefillKey]);
- const loadModels = async () => {
- try {
- const res = await API.get('/api/user/models');
- const { success, message, data } = res.data || {};
- if (success) {
- const categories = getModelCategories(tokensData.t);
- const options = (data || []).map((model) => {
- let icon = null;
- for (const [key, category] of Object.entries(categories)) {
- if (key !== 'all' && category.filter({ model_name: model })) {
- icon = category.icon;
- break;
- }
- }
- return {
- label: (
- <span className="flex items-center gap-1">
- {icon}
- {model}
- </span>
- ),
- value: model,
- };
- });
- setModelOptions(options);
- } else {
- showError(tokensData.t(message));
- }
- } catch (e) {
- showError(e.message || 'Failed to load models');
- }
- };
- function openFluentNotification(key) {
- const { t } = latestRef.current;
- const SUPPRESS_KEY = 'fluent_notify_suppressed';
- if (localStorage.getItem(SUPPRESS_KEY) === '1') return;
- const container = document.getElementById('fluent-new-api-container');
- if (!container) {
- Toast.warning(t('未检测到 Fluent 容器,请确认扩展已启用'));
- return;
- }
- setPrefillKey(key || '');
- setFluentNoticeOpen(true);
- if (modelOptions.length === 0) {
- // fire-and-forget; a later effect will refresh the notice content
- loadModels()
- }
- Notification.info({
- id: 'fluent-detected',
- title: t('检测到 Fluent(流畅阅读)'),
- content: (
- <div>
- <div style={{ marginBottom: 8 }}>
- {prefillKey
- ? t('已检测到 Fluent 扩展,已从操作中指定密钥,将使用该密钥进行填充。请选择模型后继续。')
- : t('已检测到 Fluent 扩展,请选择模型后可一键填充当前选中令牌(或本页第一个令牌)。')}
- </div>
- <div style={{ marginBottom: 8 }}>
- <Select
- placeholder={t('请选择模型')}
- optionList={modelOptions}
- onChange={setSelectedModel}
- filter={selectFilter}
- style={{ width: 320 }}
- showClear
- searchable
- emptyContent={t('暂无数据')}
- />
- </div>
- <Space>
- <Button theme="solid" type="primary" onClick={handlePrefillToFluent}>
- {t('一键填充到 Fluent')}
- </Button>
- <Button type="warning" onClick={() => {
- localStorage.setItem(SUPPRESS_KEY, '1');
- Notification.close('fluent-detected');
- Toast.info(t('已关闭后续提醒'));
- }}>
- {t('不再提醒')}
- </Button>
- <Button type="tertiary" onClick={() => Notification.close('fluent-detected')}>
- {t('关闭')}
- </Button>
- </Space>
- </div>
- ),
- duration: 0,
- });
- }
- // assign after definition so hook callback can call it safely
- openFluentNotificationRef.current = openFluentNotification;
- // Prefill to Fluent handler
- const handlePrefillToFluent = () => {
- const { tokens, selectedKeys, t, selectedModel: chosenModel, prefillKey: overrideKey } = latestRef.current;
- const container = document.getElementById('fluent-new-api-container');
- if (!container) {
- Toast.error(t('未检测到 Fluent 容器'));
- return;
- }
- if (!chosenModel) {
- Toast.warning(t('请选择模型'));
- return;
- }
- let status = localStorage.getItem('status');
- let serverAddress = '';
- if (status) {
- try {
- status = JSON.parse(status);
- serverAddress = status.server_address || '';
- } catch (_) { }
- }
- if (!serverAddress) serverAddress = window.location.origin;
- let apiKeyToUse = '';
- if (overrideKey) {
- apiKeyToUse = 'sk-' + overrideKey;
- } else {
- const token = (selectedKeys && selectedKeys.length === 1)
- ? selectedKeys[0]
- : (tokens && tokens.length > 0 ? tokens[0] : null);
- if (!token) {
- Toast.warning(t('没有可用令牌用于填充'));
- return;
- }
- apiKeyToUse = 'sk-' + token.key;
- }
- const payload = {
- id: 'new-api',
- baseUrl: serverAddress,
- apiKey: apiKeyToUse,
- model: chosenModel,
- };
- container.dispatchEvent(new CustomEvent('fluent:prefill', { detail: payload }));
- Toast.success(t('已发送到 Fluent'));
- Notification.close('fluent-detected');
- };
- // Show notification when Fluent container is available
- useEffect(() => {
- const onAppeared = () => {
- openFluentNotification();
- };
- const onRemoved = () => {
- setFluentNoticeOpen(false);
- Notification.close('fluent-detected');
- };
- window.addEventListener('fluent-container:appeared', onAppeared);
- window.addEventListener('fluent-container:removed', onRemoved);
- return () => {
- window.removeEventListener('fluent-container:appeared', onAppeared);
- window.removeEventListener('fluent-container:removed', onRemoved);
- };
- }, []);
- // When modelOptions or language changes while the notice is open, refresh the content
- useEffect(() => {
- if (fluentNoticeOpen) {
- openFluentNotification();
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [modelOptions, selectedModel, tokensData.t, fluentNoticeOpen]);
-
- useEffect(() => {
- const selector = '#fluent-new-api-container';
- const root = document.body || document.documentElement;
- const existing = document.querySelector(selector);
- if (existing) {
- console.log('Fluent container detected (initial):', existing);
- window.dispatchEvent(new CustomEvent('fluent-container:appeared', { detail: existing }));
- }
- const isOrContainsTarget = (node) => {
- if (!(node && node.nodeType === 1)) return false;
- if (node.id === 'fluent-new-api-container') return true;
- return typeof node.querySelector === 'function' && !!node.querySelector(selector);
- };
- const observer = new MutationObserver((mutations) => {
- for (const m of mutations) {
- // appeared
- for (const added of m.addedNodes) {
- if (isOrContainsTarget(added)) {
- const el = document.querySelector(selector);
- if (el) {
- console.log('Fluent container appeared:', el);
- window.dispatchEvent(new CustomEvent('fluent-container:appeared', { detail: el }));
- }
- break;
- }
- }
- // removed
- for (const removed of m.removedNodes) {
- if (isOrContainsTarget(removed)) {
- const elNow = document.querySelector(selector);
- if (!elNow) {
- console.log('Fluent container removed');
- window.dispatchEvent(new CustomEvent('fluent-container:removed'));
- }
- break;
- }
- }
- }
- });
- observer.observe(root, { childList: true, subtree: true });
- return () => observer.disconnect();
- }, []);
- const {
- // Edit state
- showEdit,
- editingToken,
- closeEdit,
- refresh,
- // Actions state
- selectedKeys,
- setEditingToken,
- setShowEdit,
- batchCopyTokens,
- batchDeleteTokens,
- copyText,
- // Filters state
- formInitValues,
- setFormApi,
- searchTokens,
- loading,
- searching,
- // Description state
- compactMode,
- setCompactMode,
- // Translation
- t,
- } = tokensData;
- return (
- <>
- <EditTokenModal
- refresh={refresh}
- editingToken={editingToken}
- visiable={showEdit}
- handleClose={closeEdit}
- />
- <CardPro
- type="type1"
- descriptionArea={
- <TokensDescription
- compactMode={compactMode}
- setCompactMode={setCompactMode}
- t={t}
- />
- }
- actionsArea={
- <div className="flex flex-col md:flex-row justify-between items-center gap-2 w-full">
- <TokensActions
- selectedKeys={selectedKeys}
- setEditingToken={setEditingToken}
- setShowEdit={setShowEdit}
- batchCopyTokens={batchCopyTokens}
- batchDeleteTokens={batchDeleteTokens}
- copyText={copyText}
- t={t}
- />
- <div className="w-full md:w-full lg:w-auto order-1 md:order-2">
- <TokensFilters
- formInitValues={formInitValues}
- setFormApi={setFormApi}
- searchTokens={searchTokens}
- loading={loading}
- searching={searching}
- t={t}
- />
- </div>
- </div>
- }
- paginationArea={createCardProPagination({
- currentPage: tokensData.activePage,
- pageSize: tokensData.pageSize,
- total: tokensData.tokenCount,
- onPageChange: tokensData.handlePageChange,
- onPageSizeChange: tokensData.handlePageSizeChange,
- isMobile: isMobile,
- t: tokensData.t,
- })}
- t={tokensData.t}
- >
- <TokensTable {...tokensData} />
- </CardPro>
- </>
- );
- }
- export default TokensPage;
|