diff --git a/web/src/components/table/tokens/index.jsx b/web/src/components/table/tokens/index.jsx
index 85229b26..ee1ff75d 100644
--- a/web/src/components/table/tokens/index.jsx
+++ b/web/src/components/table/tokens/index.jsx
@@ -17,7 +17,9 @@ along with this program. If not, see .
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: (
+
+ {icon}
+ {model}
+
+ ),
+ 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: (
+
+
+ {prefillKey
+ ? t('已检测到 Fluent 扩展,已从操作中指定密钥,将使用该密钥进行填充。请选择模型后继续。')
+ : t('已检测到 Fluent 扩展,请选择模型后可一键填充当前选中令牌(或本页第一个令牌)。')}
+
+
+
+
+
+
+
+
+
+
+ ),
+ 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 = () => {
>
);
-};
+}
export default TokensPage;
\ No newline at end of file
diff --git a/web/src/hooks/tokens/useTokensData.js b/web/src/hooks/tokens/useTokensData.js
index cfa78cc6..160d7f70 100644
--- a/web/src/hooks/tokens/useTokensData.js
+++ b/web/src/hooks/tokens/useTokensData.js
@@ -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) {