feat: enhance TokensPage and useTokensData to support Fluent integration and notifications
This commit is contained in:
@@ -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;
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
|
||||
export const useTokensData = () => {
|
||||
export const useTokensData = (openFluentNotification) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Basic state
|
||||
@@ -121,6 +121,10 @@ export const useTokensData = () => {
|
||||
|
||||
// Open link function for chat integrations
|
||||
const onOpenLink = async (type, url, record) => {
|
||||
if (url && url.startsWith('fluent')) {
|
||||
openFluentNotification(record.key);
|
||||
return;
|
||||
}
|
||||
let status = localStorage.getItem('status');
|
||||
let serverAddress = '';
|
||||
if (status) {
|
||||
|
||||
Reference in New Issue
Block a user