|
|
@@ -17,7 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
For commercial licensing, please contact support@quantumnous.com
|
|
|
*/
|
|
|
|
|
|
-import React from 'react';
|
|
|
+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';
|
|
|
@@ -28,9 +30,243 @@ import { useTokensData } from '../../../hooks/tokens/useTokensData';
|
|
|
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
|
|
import { createCardProPagination } from '../../../helpers/utils';
|
|
|
|
|
|
-const TokensPage = () => {
|
|
|
- const tokensData = useTokensData();
|
|
|
+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
|
|
|
@@ -119,6 +355,6 @@ const TokensPage = () => {
|
|
|
</CardPro>
|
|
|
</>
|
|
|
);
|
|
|
-};
|
|
|
+}
|
|
|
|
|
|
export default TokensPage;
|