Просмотр исходного кода

feat: enhance TokensPage and useTokensData to support Fluent integration and notifications

CaIon 7 месяцев назад
Родитель
Сommit
4cec55c9a4
2 измененных файлов с 245 добавлено и 5 удалено
  1. 240 4
      web/src/components/table/tokens/index.jsx
  2. 5 1
      web/src/hooks/tokens/useTokensData.js

+ 240 - 4
web/src/components/table/tokens/index.jsx

@@ -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; 

+ 5 - 1
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) {