| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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'; |
| import TokensActions from './TokensActions'; |
| import TokensFilters from './TokensFilters'; |
| import TokensDescription from './TokensDescription'; |
| import EditTokenModal from './modals/EditTokenModal'; |
| import CCSwitchModal from './modals/CCSwitchModal'; |
| import { useTokensData } from '../../../hooks/tokens/useTokensData'; |
| import { useIsMobile } from '../../../hooks/common/useIsMobile'; |
| import { createCardProPagination } from '../../../helpers/utils'; |
|
|
| function TokensPage() { |
| |
| const openFluentNotificationRef = useRef(null); |
| const openCCSwitchModalRef = useRef(null); |
| const tokensData = useTokensData( |
| (key) => openFluentNotificationRef.current?.(key), |
| (key) => openCCSwitchModalRef.current?.(key), |
| ); |
| const isMobile = useIsMobile(); |
| const latestRef = useRef({ |
| tokens: [], |
| selectedKeys: [], |
| t: (k) => k, |
| selectedModel: '', |
| prefillKey: '', |
| fetchTokenKey: async () => '', |
| }); |
| const [modelOptions, setModelOptions] = useState([]); |
| const [selectedModel, setSelectedModel] = useState(''); |
| const [fluentNoticeOpen, setFluentNoticeOpen] = useState(false); |
| const [prefillKey, setPrefillKey] = useState(''); |
| const [ccSwitchVisible, setCCSwitchVisible] = useState(false); |
| const [ccSwitchKey, setCCSwitchKey] = useState(''); |
|
|
| |
| useEffect(() => { |
| latestRef.current = { |
| tokens: tokensData.tokens, |
| selectedKeys: tokensData.selectedKeys, |
| t: tokensData.t, |
| selectedModel, |
| prefillKey, |
| fetchTokenKey: tokensData.fetchTokenKey, |
| }; |
| }, [ |
| tokensData.tokens, |
| tokensData.selectedKeys, |
| tokensData.t, |
| selectedModel, |
| prefillKey, |
| tokensData.fetchTokenKey, |
| ]); |
|
|
| 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 (modelOptions.length === 0) { |
| |
| loadModels(); |
| } |
| if (!key && localStorage.getItem(SUPPRESS_KEY) === '1') return; |
| const container = document.getElementById('fluent-new-api-container'); |
| if (!container) { |
| Toast.warning(t('未检测到 FluentRead(流畅阅读),请确认扩展已启用')); |
| return; |
| } |
| setPrefillKey(key || ''); |
| setFluentNoticeOpen(true); |
| Notification.info({ |
| id: 'fluent-detected', |
| title: t('检测到 FluentRead(流畅阅读)'), |
| content: ( |
| <div> |
| <div style={{ marginBottom: 8 }}> |
| {key |
| ? t('请选择模型。') |
| : t('选择模型后可一键填充当前选中令牌(或本页第一个令牌)。')} |
| </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('一键填充到 FluentRead')} |
| </Button> |
| {!key && ( |
| <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, |
| }); |
| } |
| |
| openFluentNotificationRef.current = openFluentNotification; |
|
|
| function openCCSwitchModal(key) { |
| if (modelOptions.length === 0) { |
| loadModels(); |
| } |
| setCCSwitchKey(key || ''); |
| setCCSwitchVisible(true); |
| } |
| openCCSwitchModalRef.current = openCCSwitchModal; |
|
|
| |
| const handlePrefillToFluent = async () => { |
| const { |
| tokens, |
| selectedKeys, |
| t, |
| selectedModel: chosenModel, |
| prefillKey: overrideKey, |
| fetchTokenKey, |
| } = 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; |
| } |
| try { |
| apiKeyToUse = 'sk-' + (await fetchTokenKey(token)); |
| } catch (_) { |
| return; |
| } |
| } |
|
|
| 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'); |
| }; |
|
|
| |
| 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); |
| }; |
| }, []); |
|
|
| |
| useEffect(() => { |
| if (fluentNoticeOpen) { |
| openFluentNotification(); |
| } |
| |
| }, [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) { |
| |
| 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; |
| } |
| } |
| |
| 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 { |
| |
| showEdit, |
| editingToken, |
| closeEdit, |
| refresh, |
|
|
| |
| selectedKeys, |
| setEditingToken, |
| setShowEdit, |
| batchCopyTokens, |
| batchDeleteTokens, |
|
|
| |
| formInitValues, |
| setFormApi, |
| searchTokens, |
| loading, |
| searching, |
|
|
| |
| compactMode, |
| setCompactMode, |
|
|
| |
| t, |
| } = tokensData; |
|
|
| return ( |
| <> |
| <EditTokenModal |
| refresh={refresh} |
| editingToken={editingToken} |
| visiable={showEdit} |
| handleClose={closeEdit} |
| /> |
| |
| <CCSwitchModal |
| visible={ccSwitchVisible} |
| onClose={() => setCCSwitchVisible(false)} |
| tokenKey={ccSwitchKey} |
| modelOptions={modelOptions} |
| /> |
| |
| <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} |
| 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; |
|
|