ソースを参照

Merge branch 'ui/refactor' into alpha

Apple\Apple 9 ヶ月 前
コミット
933327baf7
81 ファイル変更879 行追加934 行削除
  1. 0 0
      web/public/example.png
  2. 12 14
      web/src/App.js
  3. 0 14
      web/src/components/AuthRedirect.js
  4. 0 12
      web/src/components/PrivateRoute.js
  5. 0 18
      web/src/components/SetupCheck.js
  6. 9 11
      web/src/components/auth/LoginForm.js
  7. 2 3
      web/src/components/auth/OAuth2Callback.js
  8. 3 3
      web/src/components/auth/PasswordResetConfirm.js
  9. 3 3
      web/src/components/auth/PasswordResetForm.js
  10. 9 9
      web/src/components/auth/RegisterForm.js
  11. 0 0
      web/src/components/common/Loading.js
  12. 2 3
      web/src/components/common/markdown/MarkdownRenderer.js
  13. 0 68
      web/src/components/fetchTokenKeys.js
  14. 2 2
      web/src/components/layout/Footer.js
  15. 6 7
      web/src/components/layout/HeaderBar.js
  16. 1 1
      web/src/components/layout/NoticeModal.js
  17. 5 6
      web/src/components/layout/PageLayout.js
  18. 6 21
      web/src/components/layout/SiderBar.js
  19. 1 1
      web/src/components/playground/CodeViewer.js
  20. 1 1
      web/src/components/playground/SettingsPanel.js
  21. 1 1
      web/src/components/playground/configStorage.js
  22. 4 4
      web/src/components/settings/ModelSetting.js
  23. 13 13
      web/src/components/settings/OperationSetting.js
  24. 2 2
      web/src/components/settings/OtherSetting.js
  25. 9 13
      web/src/components/settings/PersonalSetting.js
  26. 3 3
      web/src/components/settings/RateLimitSetting.js
  27. 3 3
      web/src/components/settings/SystemSetting.js
  28. 8 10
      web/src/components/table/ChannelsTable.js
  29. 14 16
      web/src/components/table/LogsTable.js
  30. 2 2
      web/src/components/table/MjLogsTable.js
  31. 4 4
      web/src/components/table/ModelPricing.js
  32. 4 4
      web/src/components/table/RedemptionsTable.js
  33. 2 2
      web/src/components/table/TaskLogsTable.js
  34. 6 5
      web/src/components/table/TokensTable.js
  35. 4 5
      web/src/components/table/UsersTable.js
  36. 0 76
      web/src/components/utils.js
  37. 1 0
      web/src/constants/index.js
  38. 0 0
      web/src/constants/playground.constants.js
  39. 1 1
      web/src/context/Style/index.js
  40. 184 1
      web/src/helpers/api.js
  41. 0 10
      web/src/helpers/auth-header.js
  42. 33 0
      web/src/helpers/auth.js
  43. 5 1
      web/src/helpers/index.js
  44. 1 1
      web/src/helpers/log.js
  45. 178 101
      web/src/helpers/render.js
  46. 45 0
      web/src/helpers/token.js
  47. 163 0
      web/src/helpers/utils.js
  48. 4 7
      web/src/hooks/useApiRequest.js
  49. 2 3
      web/src/hooks/useDataLoader.js
  50. 2 2
      web/src/hooks/useMessageActions.js
  51. 2 2
      web/src/hooks/useMessageEdit.js
  52. 2 2
      web/src/hooks/usePlaygroundState.js
  53. 32 0
      web/src/hooks/useSetupCheck.js
  54. 1 1
      web/src/hooks/useSyncMessageAndCustomBody.js
  55. 30 0
      web/src/hooks/useTokenKeys.js
  56. 2 1
      web/src/index.js
  57. 1 1
      web/src/pages/Channel/EditChannel.js
  58. 1 1
      web/src/pages/Channel/EditTagModal.js
  59. 1 1
      web/src/pages/Channel/index.js
  60. 1 1
      web/src/pages/Chat/index.js
  61. 1 1
      web/src/pages/Chat2Link/index.js
  62. 2 4
      web/src/pages/Detail/index.js
  63. 2 2
      web/src/pages/Home/index.js
  64. 1 1
      web/src/pages/Log/index.js
  65. 1 1
      web/src/pages/Midjourney/index.js
  66. 5 6
      web/src/pages/Playground/index.js
  67. 1 1
      web/src/pages/Pricing/index.js
  68. 2 4
      web/src/pages/Redemption/EditRedemption.js
  69. 1 1
      web/src/pages/Redemption/index.js
  70. 1 2
      web/src/pages/Setting/Operation/ModelRationNotSetEditor.js
  71. 10 15
      web/src/pages/Setting/Operation/ModelSettingsVisualEditor.js
  72. 6 6
      web/src/pages/Setting/index.js
  73. 1 1
      web/src/pages/Task/index.js
  74. 2 1
      web/src/pages/Token/EditToken.js
  75. 1 1
      web/src/pages/Token/index.js
  76. 7 4
      web/src/pages/TopUp/index.js
  77. 1 2
      web/src/pages/User/EditUser.js
  78. 1 1
      web/src/pages/User/index.js
  79. 0 105
      web/src/utils/apiUtils.js
  80. 0 201
      web/src/utils/messageUtils.js
  81. 0 77
      web/src/utils/rehypeSplitWordsIntoSpans.js

+ 0 - 0
web/src/images/example.png → web/public/example.png


+ 12 - 14
web/src/App.js

@@ -1,15 +1,15 @@
-import React, { lazy, Suspense, useContext, useEffect } from 'react';
+import React, { lazy, Suspense } from 'react';
 import { Route, Routes, useLocation } from 'react-router-dom';
-import Loading from './components/Loading';
+import Loading from './components/common/Loading.js';
 import User from './pages/User';
-import { PrivateRoute } from './components/PrivateRoute';
-import RegisterForm from './components/RegisterForm';
-import LoginForm from './components/LoginForm';
+import { AuthRedirect, PrivateRoute } from './helpers';
+import RegisterForm from './components/auth/RegisterForm.js';
+import LoginForm from './components/auth/LoginForm.js';
 import NotFound from './pages/NotFound';
 import Setting from './pages/Setting';
 import EditUser from './pages/User/EditUser';
-import PasswordResetForm from './components/PasswordResetForm';
-import PasswordResetConfirm from './components/PasswordResetConfirm';
+import PasswordResetForm from './components/auth/PasswordResetForm.js';
+import PasswordResetConfirm from './components/auth/PasswordResetConfirm.js';
 import Channel from './pages/Channel';
 import Token from './pages/Token';
 import EditChannel from './pages/Channel/EditChannel';
@@ -18,16 +18,14 @@ import TopUp from './pages/TopUp';
 import Log from './pages/Log';
 import Chat from './pages/Chat';
 import Chat2Link from './pages/Chat2Link';
-import { Layout } from '@douyinfe/semi-ui';
 import Midjourney from './pages/Midjourney';
 import Pricing from './pages/Pricing/index.js';
 import Task from './pages/Task/index.js';
 import Playground from './pages/Playground/index.js';
-import OAuth2Callback from './components/OAuth2Callback.js';
-import PersonalSetting from './components/PersonalSetting.js';
+import OAuth2Callback from './components/auth/OAuth2Callback.js';
+import PersonalSetting from './components/settings/PersonalSetting.js';
 import Setup from './pages/Setup/index.js';
-import SetupCheck from './components/SetupCheck';
-import AuthRedirect from './components/AuthRedirect';
+import { useSetupCheck } from './hooks/useSetupCheck.js';
 
 const Home = lazy(() => import('./pages/Home'));
 const Detail = lazy(() => import('./pages/Detail'));
@@ -37,7 +35,7 @@ function App() {
   const location = useLocation();
 
   return (
-    <SetupCheck>
+    <useSetupCheck>
       <Routes>
         <Route
           path='/'
@@ -292,7 +290,7 @@ function App() {
         />
         <Route path='*' element={<NotFound />} />
       </Routes>
-    </SetupCheck>
+    </useSetupCheck>
   );
 }
 

+ 0 - 14
web/src/components/AuthRedirect.js

@@ -1,14 +0,0 @@
-import React from 'react';
-import { Navigate } from 'react-router-dom';
-
-const AuthRedirect = ({ children }) => {
-  const user = localStorage.getItem('user');
-
-  if (user) {
-    return <Navigate to="/console" replace />;
-  }
-
-  return children;
-};
-
-export default AuthRedirect; 

+ 0 - 12
web/src/components/PrivateRoute.js

@@ -1,12 +0,0 @@
-import { Navigate } from 'react-router-dom';
-
-import { history } from '../helpers';
-
-function PrivateRoute({ children }) {
-  if (!localStorage.getItem('user')) {
-    return <Navigate to='/login' state={{ from: history.location }} />;
-  }
-  return children;
-}
-
-export { PrivateRoute };

+ 0 - 18
web/src/components/SetupCheck.js

@@ -1,18 +0,0 @@
-import React, { useContext, useEffect } from 'react';
-import { Navigate, useLocation } from 'react-router-dom';
-import { StatusContext } from '../context/Status';
-
-const SetupCheck = ({ children }) => {
-  const [statusState] = useContext(StatusContext);
-  const location = useLocation();
-
-  useEffect(() => {
-    if (statusState?.status?.setup === false && location.pathname !== '/setup') {
-      window.location.href = '/setup';
-    }
-  }, [statusState?.status?.setup, location.pathname]);
-
-  return children;
-};
-
-export default SetupCheck; 

+ 9 - 11
web/src/components/LoginForm.js → web/src/components/auth/LoginForm.js

@@ -1,6 +1,6 @@
 import React, { useContext, useEffect, useState } from 'react';
 import { Link, useNavigate, useSearchParams } from 'react-router-dom';
-import { UserContext } from '../context/User';
+import { UserContext } from '../../context/User/index.js';
 import {
   API,
   getLogo,
@@ -9,12 +9,11 @@ import {
   showSuccess,
   updateAPI,
   getSystemName,
-} from '../helpers';
-import {
+  setUserData,
   onGitHubOAuthClicked,
   onOIDCClicked,
-  onLinuxDOOAuthClicked,
-} from './utils';
+  onLinuxDOOAuthClicked
+} from '../../helpers/index.js';
 import Turnstile from 'react-turnstile';
 import {
   Button,
@@ -29,12 +28,11 @@ import Text from '@douyinfe/semi-ui/lib/es/typography/text';
 import TelegramLoginButton from 'react-telegram-login';
 
 import { IconGithubLogo, IconMail, IconLock } from '@douyinfe/semi-icons';
-import OIDCIcon from './common/logo/OIDCIcon.js';
-import WeChatIcon from './common/logo/WeChatIcon.js';
-import { setUserData } from '../helpers/data.js';
-import LinuxDoIcon from './common/logo/LinuxDoIcon.js';
+import OIDCIcon from '../common/logo/OIDCIcon.js';
+import WeChatIcon from '../common/logo/WeChatIcon.js';
+import LinuxDoIcon from '../common/logo/LinuxDoIcon.js';
 import { useTranslation } from 'react-i18next';
-import Background from '../images/example.png';
+import Background from '/example.png';
 
 const LoginForm = () => {
   const [inputs, setInputs] = useState({
@@ -505,7 +503,7 @@ const LoginForm = () => {
   };
 
   return (
-    <div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
+    <div className="relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
       {/* 背景图片容器 - 放大并保持居中 */}
       <div
         className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"

+ 2 - 3
web/src/components/OAuth2Callback.js → web/src/components/auth/OAuth2Callback.js

@@ -1,9 +1,8 @@
 import React, { useContext, useEffect, useState } from 'react';
 import { Spin, Typography, Space } from '@douyinfe/semi-ui';
 import { useNavigate, useSearchParams } from 'react-router-dom';
-import { API, showError, showSuccess, updateAPI } from '../helpers';
-import { UserContext } from '../context/User';
-import { setUserData } from '../helpers/data.js';
+import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers';
+import { UserContext } from '../../context/User';
 
 const OAuth2Callback = (props) => {
   const [searchParams, setSearchParams] = useSearchParams();

+ 3 - 3
web/src/components/PasswordResetConfirm.js → web/src/components/auth/PasswordResetConfirm.js

@@ -1,10 +1,10 @@
 import React, { useEffect, useState } from 'react';
-import { API, copy, showError, showNotice, getLogo, getSystemName } from '../helpers';
+import { API, copy, showError, showNotice, getLogo, getSystemName } from '../../helpers';
 import { useSearchParams, Link } from 'react-router-dom';
 import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
 import { IconMail, IconLock } from '@douyinfe/semi-icons';
 import { useTranslation } from 'react-i18next';
-import Background from '../images/example.png';
+import Background from '/example.png';
 
 const { Text, Title } = Typography;
 
@@ -69,7 +69,7 @@ const PasswordResetConfirm = () => {
   }
 
   return (
-    <div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
+    <div className="relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
       {/* 背景图片容器 - 放大并保持居中 */}
       <div
         className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"

+ 3 - 3
web/src/components/PasswordResetForm.js → web/src/components/auth/PasswordResetForm.js

@@ -1,11 +1,11 @@
 import React, { useEffect, useState } from 'react';
-import { API, getLogo, showError, showInfo, showSuccess, getSystemName } from '../helpers';
+import { API, getLogo, showError, showInfo, showSuccess, getSystemName } from '../../helpers';
 import Turnstile from 'react-turnstile';
 import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
 import { IconMail } from '@douyinfe/semi-icons';
 import { Link } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
-import Background from '../images/example.png';
+import Background from '/example.png';
 
 const { Text, Title } = Typography;
 
@@ -76,7 +76,7 @@ const PasswordResetForm = () => {
   }
 
   return (
-    <div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
+    <div className="relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
       {/* 背景图片容器 - 放大并保持居中 */}
       <div
         className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"

+ 9 - 9
web/src/components/RegisterForm.js → web/src/components/auth/RegisterForm.js

@@ -8,7 +8,8 @@ import {
   showSuccess,
   updateAPI,
   getSystemName,
-} from '../helpers';
+  setUserData
+} from '../../helpers/index.js';
 import Turnstile from 'react-turnstile';
 import {
   Button,
@@ -25,15 +26,14 @@ import {
   onGitHubOAuthClicked,
   onLinuxDOOAuthClicked,
   onOIDCClicked,
-} from './utils.js';
-import OIDCIcon from './common/logo/OIDCIcon.js';
-import LinuxDoIcon from './common/logo/LinuxDoIcon.js';
-import WeChatIcon from './common/logo/WeChatIcon.js';
+} from '../../helpers/index.js';
+import OIDCIcon from '../common/logo/OIDCIcon.js';
+import LinuxDoIcon from '../common/logo/LinuxDoIcon.js';
+import WeChatIcon from '../common/logo/WeChatIcon.js';
 import TelegramLoginButton from 'react-telegram-login/src';
-import { setUserData } from '../helpers/data.js';
-import { UserContext } from '../context/User/index.js';
+import { UserContext } from '../../context/User/index.js';
 import { useTranslation } from 'react-i18next';
-import Background from '../images/example.png';
+import Background from '/example.png';
 
 const RegisterForm = () => {
   const { t } = useTranslation();
@@ -549,7 +549,7 @@ const RegisterForm = () => {
   };
 
   return (
-    <div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
+    <div className="relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
       <div
         className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
         style={{

+ 0 - 0
web/src/components/Loading.js → web/src/components/common/Loading.js


+ 2 - 3
web/src/components/common/markdown/MarkdownRenderer.js

@@ -1,6 +1,6 @@
 import ReactMarkdown from 'react-markdown';
 import 'katex/dist/katex.min.css';
-import 'highlight.js/styles/default.css';
+import 'highlight.js/styles/github.css';
 import './markdown.css';
 import RemarkMath from 'remark-math';
 import RemarkBreaks from 'remark-breaks';
@@ -13,10 +13,9 @@ import React from 'react';
 import { useDebouncedCallback } from 'use-debounce';
 import clsx from 'clsx';
 import { Button, Tooltip, Toast } from '@douyinfe/semi-ui';
-import { copy } from '../../../helpers/utils';
+import { copy, rehypeSplitWordsIntoSpans } from '../../../helpers';
 import { IconCopy } from '@douyinfe/semi-icons';
 import { useTranslation } from 'react-i18next';
-import { rehypeSplitWordsIntoSpans } from '../../../utils/rehypeSplitWordsIntoSpans';
 
 mermaid.initialize({
   startOnLoad: false,

+ 0 - 68
web/src/components/fetchTokenKeys.js

@@ -1,68 +0,0 @@
-// src/hooks/useTokenKeys.js
-import { useEffect, useState } from 'react';
-import { API, showError } from '../helpers';
-
-async function fetchTokenKeys() {
-  try {
-    const response = await API.get('/api/token/?p=0&size=100');
-    const { success, data } = response.data;
-    if (success) {
-      const activeTokens = data.filter((token) => token.status === 1);
-      return activeTokens.map((token) => token.key);
-    } else {
-      throw new Error('Failed to fetch token keys');
-    }
-  } catch (error) {
-    console.error('Error fetching token keys:', error);
-    return [];
-  }
-}
-
-function getServerAddress() {
-  let status = localStorage.getItem('status');
-  let serverAddress = '';
-
-  if (status) {
-    try {
-      status = JSON.parse(status);
-      serverAddress = status.server_address || '';
-    } catch (error) {
-      console.error('Failed to parse status from localStorage:', error);
-    }
-  }
-
-  if (!serverAddress) {
-    serverAddress = window.location.origin;
-  }
-
-  return serverAddress;
-}
-
-export function useTokenKeys(id) {
-  const [keys, setKeys] = useState([]);
-  // const [chatLink, setChatLink] = useState('');
-  const [serverAddress, setServerAddress] = useState('');
-  const [isLoading, setIsLoading] = useState(true);
-
-  useEffect(() => {
-    const loadAllData = async () => {
-      const fetchedKeys = await fetchTokenKeys();
-      if (fetchedKeys.length === 0) {
-        showError('当前没有可用的启用令牌,请确认是否有令牌处于启用状态!');
-        setTimeout(() => {
-          window.location.href = '/token';
-        }, 1500); // 延迟 1.5 秒后跳转
-      }
-      setKeys(fetchedKeys);
-      setIsLoading(false);
-      // setChatLink(link);
-
-      const address = getServerAddress();
-      setServerAddress(address);
-    };
-
-    loadAllData();
-  }, []);
-
-  return { keys, serverAddress, isLoading };
-}

+ 2 - 2
web/src/components/Footer.js → web/src/components/layout/Footer.js

@@ -1,8 +1,8 @@
 import React, { useEffect, useState, useMemo, useContext } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Typography } from '@douyinfe/semi-ui';
-import { getFooterHTML, getLogo, getSystemName } from '../helpers';
-import { StatusContext } from '../context/Status';
+import { getFooterHTML, getLogo, getSystemName } from '../../helpers';
+import { StatusContext } from '../../context/Status';
 
 const FooterBar = () => {
   const { t } = useTranslation();

+ 6 - 7
web/src/components/HeaderBar.js → web/src/components/layout/HeaderBar.js

@@ -1,12 +1,12 @@
 import React, { useContext, useEffect, useState } from 'react';
 import { Link, useNavigate, useLocation } from 'react-router-dom';
-import { UserContext } from '../context/User';
-import { useSetTheme, useTheme } from '../context/Theme';
+import { UserContext } from '../../context/User/index.js';
+import { useSetTheme, useTheme } from '../../context/Theme/index.js';
 import { useTranslation } from 'react-i18next';
-import { API, getLogo, getSystemName, showSuccess } from '../helpers';
+import { API, getLogo, getSystemName, showSuccess, stringToColor } from '../../helpers/index.js';
 import fireworks from 'react-fireworks';
 import { CN, GB } from 'country-flag-icons/react/3x2';
-import NoticeModal from './NoticeModal';
+import NoticeModal from './NoticeModal.js';
 
 import {
   IconClose,
@@ -29,9 +29,8 @@ import {
   Typography,
   Skeleton,
 } from '@douyinfe/semi-ui';
-import { stringToColor } from '../helpers/render';
-import { StatusContext } from '../context/Status/index.js';
-import { useStyle, styleActions } from '../context/Style/index.js';
+import { StatusContext } from '../../context/Status/index.js';
+import { useStyle, styleActions } from '../../context/Style/index.js';
 
 const HeaderBar = () => {
   const { t, i18n } = useTranslation();

+ 1 - 1
web/src/components/NoticeModal.js → web/src/components/layout/NoticeModal.js

@@ -1,7 +1,7 @@
 import React, { useEffect, useState } from 'react';
 import { Button, Modal, Empty } from '@douyinfe/semi-ui';
 import { useTranslation } from 'react-i18next';
-import { API, showError } from '../helpers';
+import { API, showError } from '../../helpers';
 import { marked } from 'marked';
 import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
 

+ 5 - 6
web/src/components/PageLayout.js → web/src/components/layout/PageLayout.js

@@ -1,16 +1,15 @@
 import HeaderBar from './HeaderBar.js';
 import { Layout } from '@douyinfe/semi-ui';
 import SiderBar from './SiderBar.js';
-import App from '../App.js';
+import App from '../../App.js';
 import FooterBar from './Footer.js';
 import { ToastContainer } from 'react-toastify';
 import React, { useContext, useEffect } from 'react';
-import { useStyle } from '../context/Style/index.js';
+import { useStyle } from '../../context/Style/index.js';
 import { useTranslation } from 'react-i18next';
-import { API, getLogo, getSystemName, showError } from '../helpers/index.js';
-import { setStatusData } from '../helpers/data.js';
-import { UserContext } from '../context/User/index.js';
-import { StatusContext } from '../context/Status/index.js';
+import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers/index.js';
+import { UserContext } from '../../context/User/index.js';
+import { StatusContext } from '../../context/Status/index.js';
 import { useLocation } from 'react-router-dom';
 const { Sider, Content, Header, Footer } = Layout;
 

+ 6 - 21
web/src/components/SiderBar.js → web/src/components/layout/SiderBar.js

@@ -1,18 +1,12 @@
 import React, { useContext, useEffect, useMemo, useState } from 'react';
-import { Link, useNavigate, useLocation } from 'react-router-dom';
-import { UserContext } from '../context/User';
-import { StatusContext } from '../context/Status';
+import { Link, useLocation } from 'react-router-dom';
+import { StatusContext } from '../../context/Status/index.js';
 import { useTranslation } from 'react-i18next';
 
 import {
-  API,
-  getLogo,
-  getSystemName,
   isAdmin,
-  isMobile,
-  showError,
-} from '../helpers';
-import '../index.css';
+  showError
+} from '../../helpers/index.js';
 
 import {
   IconCalendarClock,
@@ -21,28 +15,19 @@ import {
   IconTerminal,
   IconCreditCard,
   IconGift,
-  IconHelpCircle,
   IconHistogram,
-  IconHome,
   IconImage,
   IconKey,
   IconLayers,
-  IconPriceTag,
   IconSetting,
   IconUser,
 } from '@douyinfe/semi-icons';
 import {
-  Avatar,
-  Dropdown,
-  Layout,
   Nav,
-  Switch,
   Divider,
 } from '@douyinfe/semi-ui';
-import { setStatusData } from '../helpers/data.js';
-import { stringToColor } from '../helpers/render.js';
-import { useSetTheme, useTheme } from '../context/Theme/index.js';
-import { useStyle, styleActions } from '../context/Style/index.js';
+import { useSetTheme, useTheme } from '../../context/Theme/index.js';
+import { useStyle, styleActions } from '../../context/Style/index.js';
 import Text from '@douyinfe/semi-ui/lib/es/typography/text';
 
 // 自定义侧边栏按钮样式

+ 1 - 1
web/src/components/playground/CodeViewer.js

@@ -2,7 +2,7 @@ import React, { useState, useMemo, useCallback } from 'react';
 import { Button, Tooltip, Toast } from '@douyinfe/semi-ui';
 import { Copy, ChevronDown, ChevronUp } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
-import { copy } from '../../helpers/utils';
+import { copy } from '../../helpers';
 
 const PERFORMANCE_CONFIG = {
   MAX_DISPLAY_LENGTH: 50000, // 最大显示字符数

+ 1 - 1
web/src/components/playground/SettingsPanel.js

@@ -14,7 +14,7 @@ import {
   Settings,
 } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
-import { renderGroupOption } from '../../helpers/render.js';
+import { renderGroupOption } from '../../helpers';
 import ParameterControl from './ParameterControl';
 import ImageUrlInput from './ImageUrlInput';
 import ConfigManager from './ConfigManager';

+ 1 - 1
web/src/components/playground/configStorage.js

@@ -1,4 +1,4 @@
-import { STORAGE_KEYS, DEFAULT_CONFIG } from '../../utils/constants';
+import { STORAGE_KEYS, DEFAULT_CONFIG } from '../../constants/playground.constants';
 
 const MESSAGES_STORAGE_KEY = 'playground_messages';
 

+ 4 - 4
web/src/components/ModelSetting.js → web/src/components/settings/ModelSetting.js

@@ -1,11 +1,11 @@
 import React, { useEffect, useState } from 'react';
 import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
 
-import { API, showError, showSuccess } from '../helpers';
+import { API, showError, showSuccess } from '../../helpers';
 import { useTranslation } from 'react-i18next';
-import SettingGeminiModel from '../pages/Setting/Model/SettingGeminiModel.js';
-import SettingClaudeModel from '../pages/Setting/Model/SettingClaudeModel.js';
-import SettingGlobalModel from '../pages/Setting/Model/SettingGlobalModel.js';
+import SettingGeminiModel from '../../pages/Setting/Model/SettingGeminiModel.js';
+import SettingClaudeModel from '../../pages/Setting/Model/SettingClaudeModel.js';
+import SettingGlobalModel from '../../pages/Setting/Model/SettingGlobalModel.js';
 
 const ModelSetting = () => {
   const { t } = useTranslation();

+ 13 - 13
web/src/components/OperationSetting.js → web/src/components/settings/OperationSetting.js

@@ -1,20 +1,20 @@
 import React, { useEffect, useState } from 'react';
 import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
-import SettingsGeneral from '../pages/Setting/Operation/SettingsGeneral.js';
-import SettingsDrawing from '../pages/Setting/Operation/SettingsDrawing.js';
-import SettingsSensitiveWords from '../pages/Setting/Operation/SettingsSensitiveWords.js';
-import SettingsLog from '../pages/Setting/Operation/SettingsLog.js';
-import SettingsDataDashboard from '../pages/Setting/Operation/SettingsDataDashboard.js';
-import SettingsMonitoring from '../pages/Setting/Operation/SettingsMonitoring.js';
-import SettingsCreditLimit from '../pages/Setting/Operation/SettingsCreditLimit.js';
-import ModelSettingsVisualEditor from '../pages/Setting/Operation/ModelSettingsVisualEditor.js';
-import GroupRatioSettings from '../pages/Setting/Operation/GroupRatioSettings.js';
-import ModelRatioSettings from '../pages/Setting/Operation/ModelRatioSettings.js';
+import SettingsGeneral from '../../pages/Setting/Operation/SettingsGeneral.js';
+import SettingsDrawing from '../../pages/Setting/Operation/SettingsDrawing.js';
+import SettingsSensitiveWords from '../../pages/Setting/Operation/SettingsSensitiveWords.js';
+import SettingsLog from '../../pages/Setting/Operation/SettingsLog.js';
+import SettingsDataDashboard from '../../pages/Setting/Operation/SettingsDataDashboard.js';
+import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring.js';
+import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit.js';
+import ModelSettingsVisualEditor from '../../pages/Setting/Operation/ModelSettingsVisualEditor.js';
+import GroupRatioSettings from '../../pages/Setting/Operation/GroupRatioSettings.js';
+import ModelRatioSettings from '../../pages/Setting/Operation/ModelRatioSettings.js';
 
-import { API, showError, showSuccess } from '../helpers';
-import SettingsChats from '../pages/Setting/Operation/SettingsChats.js';
+import { API, showError, showSuccess } from '../../helpers';
+import SettingsChats from '../../pages/Setting/Operation/SettingsChats.js';
 import { useTranslation } from 'react-i18next';
-import ModelRatioNotSetEditor from '../pages/Setting/Operation/ModelRationNotSetEditor.js';
+import ModelRatioNotSetEditor from '../../pages/Setting/Operation/ModelRationNotSetEditor.js';
 
 const OperationSetting = () => {
   const { t } = useTranslation();

+ 2 - 2
web/src/components/OtherSetting.js → web/src/components/settings/OtherSetting.js

@@ -9,10 +9,10 @@ import {
   Space,
   Card,
 } from '@douyinfe/semi-ui';
-import { API, showError, showSuccess, timestamp2string } from '../helpers';
+import { API, showError, showSuccess, timestamp2string } from '../../helpers';
 import { marked } from 'marked';
 import { useTranslation } from 'react-i18next';
-import { StatusContext } from '../context/Status/index.js';
+import { StatusContext } from '../../context/Status/index.js';
 import Text from '@douyinfe/semi-ui/lib/es/typography/text';
 
 const OtherSetting = () => {

+ 9 - 13
web/src/components/PersonalSetting.js → web/src/components/settings/PersonalSetting.js

@@ -8,14 +8,16 @@ import {
   showError,
   showInfo,
   showSuccess,
-} from '../helpers';
-import Turnstile from 'react-turnstile';
-import { UserContext } from '../context/User';
-import {
+  getQuotaPerUnit,
+  renderQuota,
+  renderQuotaWithPrompt,
+  stringToColor,
   onGitHubOAuthClicked,
   onOIDCClicked,
-  onLinuxDOOAuthClicked,
-} from './utils';
+  onLinuxDOOAuthClicked
+} from '../../helpers';
+import Turnstile from 'react-turnstile';
+import { UserContext } from '../../context/User';
 import {
   Avatar,
   Banner,
@@ -54,12 +56,6 @@ import {
 } from '@douyinfe/semi-icons';
 import { SiTelegram, SiWechat, SiLinux } from 'react-icons/si';
 import { Bell, Shield, Webhook, Globe, Settings, UserPlus, ShieldCheck } from 'lucide-react';
-import {
-  getQuotaPerUnit,
-  renderQuota,
-  renderQuotaWithPrompt,
-  stringToColor,
-} from '../helpers/render';
 import TelegramLoginButton from 'react-telegram-login';
 import { useTranslation } from 'react-i18next';
 
@@ -410,7 +406,7 @@ const PersonalSetting = () => {
   };
 
   return (
-    <div className="min-h-screen bg-gray-50">
+    <div className="bg-gray-50">
       <Layout>
         <Layout.Content>
           {/* 划转模态框 */}

+ 3 - 3
web/src/components/RateLimitSetting.js → web/src/components/settings/RateLimitSetting.js

@@ -1,10 +1,10 @@
 import React, { useEffect, useState } from 'react';
 import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
 
-import { API, showError, showSuccess } from '../helpers';
-import SettingsChats from '../pages/Setting/Operation/SettingsChats.js';
+import { API, showError, showSuccess } from '../../helpers/index.js';
+import SettingsChats from '../../pages/Setting/Operation/SettingsChats.js';
 import { useTranslation } from 'react-i18next';
-import RequestRateLimit from '../pages/Setting/RateLimit/SettingsRequestRateLimit.js';
+import RequestRateLimit from '../../pages/Setting/RateLimit/SettingsRequestRateLimit.js';
 
 const RateLimitSetting = () => {
   const { t } = useTranslation();

+ 3 - 3
web/src/components/SystemSetting.js → web/src/components/settings/SystemSetting.js

@@ -13,12 +13,12 @@ import {
 } from '@douyinfe/semi-ui';
 const { Text } = Typography;
 import {
+  API,
   removeTrailingSlash,
   showError,
   showSuccess,
-  verifyJSON,
-} from '../helpers/utils';
-import { API } from '../helpers/api';
+  verifyJSON
+} from '../../helpers';
 import axios from 'axios';
 
 const SystemSetting = () => {

+ 8 - 10
web/src/components/ChannelsTable.js → web/src/components/table/ChannelsTable.js

@@ -5,14 +5,12 @@ import {
   showInfo,
   showSuccess,
   timestamp2string,
-} from '../helpers';
-
-import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants';
-import {
   renderGroup,
   renderNumberWithPoint,
-  renderQuota,
-} from '../helpers/render';
+  renderQuota
+} from '../../helpers/index.js';
+
+import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../../constants/index.js';
 import {
   Button,
   Divider,
@@ -29,9 +27,9 @@ import {
   Typography,
   Checkbox,
   Card,
-  Select,
+  Select
 } from '@douyinfe/semi-ui';
-import EditChannel from '../pages/Channel/EditChannel';
+import EditChannel from '../../pages/Channel/EditChannel.js';
 import {
   IconList,
   IconTreeTriangleDown,
@@ -48,8 +46,8 @@ import {
   IconCopy,
   IconSmallTriangleRight
 } from '@douyinfe/semi-icons';
-import { loadChannelModels } from './utils.js';
-import EditTagModal from '../pages/Channel/EditTagModal.js';
+import { loadChannelModels } from '../../helpers/index.js';
+import EditTagModal from '../../pages/Channel/EditTagModal.js';
 import { useTranslation } from 'react-i18next';
 
 const ChannelsTable = () => {

+ 14 - 16
web/src/components/LogsTable.js → web/src/components/table/LogsTable.js

@@ -8,7 +8,19 @@ import {
   showError,
   showSuccess,
   timestamp2string,
-} from '../helpers';
+  renderAudioModelPrice,
+  renderClaudeLogContent,
+  renderClaudeModelPrice,
+  renderClaudeModelPriceSimple,
+  renderGroup,
+  renderLogContent,
+  renderModelPrice,
+  renderModelPriceSimple,
+  renderNumber,
+  renderQuota,
+  stringToColor,
+  getLogOther
+} from '../../helpers';
 
 import {
   Avatar,
@@ -29,22 +41,8 @@ import {
   Input,
   DatePicker,
 } from '@douyinfe/semi-ui';
-import { ITEMS_PER_PAGE } from '../constants';
-import {
-  renderAudioModelPrice,
-  renderClaudeLogContent,
-  renderClaudeModelPrice,
-  renderClaudeModelPriceSimple,
-  renderGroup,
-  renderLogContent,
-  renderModelPrice,
-  renderModelPriceSimple,
-  renderNumber,
-  renderQuota,
-  stringToColor,
-} from '../helpers/render';
+import { ITEMS_PER_PAGE } from '../../constants';
 import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
-import { getLogOther } from '../helpers/other.js';
 import {
   IconRefresh,
   IconSetting,

+ 2 - 2
web/src/components/MjLogsTable.js → web/src/components/table/MjLogsTable.js

@@ -7,7 +7,7 @@ import {
   showError,
   showSuccess,
   timestamp2string,
-} from '../helpers';
+} from '../../helpers';
 
 import {
   Button,
@@ -25,7 +25,7 @@ import {
   Tag,
   Typography,
 } from '@douyinfe/semi-ui';
-import { ITEMS_PER_PAGE } from '../constants';
+import { ITEMS_PER_PAGE } from '../../constants';
 import {
   IconEyeOpened,
   IconSearch,

+ 4 - 4
web/src/components/ModelPricing.js → web/src/components/table/ModelPricing.js

@@ -1,5 +1,5 @@
 import React, { useContext, useEffect, useRef, useMemo, useState } from 'react';
-import { API, copy, showError, showInfo, showSuccess } from '../helpers';
+import { API, copy, showError, showInfo, showSuccess } from '../../helpers/index.js';
 import { useTranslation } from 'react-i18next';
 
 import {
@@ -26,9 +26,9 @@ import {
   IconInfoCircle,
   IconCrown,
 } from '@douyinfe/semi-icons';
-import { UserContext } from '../context/User/index.js';
+import { UserContext } from '../../context/User/index.js';
 import { AlertCircle } from 'lucide-react';
-import { MODEL_CATEGORIES } from '../constants';
+import { MODEL_CATEGORIES } from '../../constants/index.js';
 
 const ModelPricing = () => {
   const { t } = useTranslation();
@@ -481,7 +481,7 @@ const ModelPricing = () => {
   ), [filteredModels, loading, columns, rowSelection, pageSize, t]);
 
   return (
-    <div className="min-h-screen bg-gray-50">
+    <div className="bg-gray-50">
       <Layout>
         <Layout.Content>
           <div className="flex justify-center p-4 sm:p-6 md:p-8">

+ 4 - 4
web/src/components/RedemptionsTable.js → web/src/components/table/RedemptionsTable.js

@@ -5,10 +5,10 @@ import {
   showError,
   showSuccess,
   timestamp2string,
-} from '../helpers';
+  renderQuota
+} from '../../helpers';
 
-import { ITEMS_PER_PAGE } from '../constants';
-import { renderQuota } from '../helpers/render';
+import { ITEMS_PER_PAGE } from '../../constants';
 import {
   Button,
   Card,
@@ -33,7 +33,7 @@ import {
   IconPlay,
   IconMore,
 } from '@douyinfe/semi-icons';
-import EditRedemption from '../pages/Redemption/EditRedemption';
+import EditRedemption from '../../pages/Redemption/EditRedemption';
 import { useTranslation } from 'react-i18next';
 
 const { Text } = Typography;

+ 2 - 2
web/src/components/TaskLogsTable.js → web/src/components/table/TaskLogsTable.js

@@ -7,7 +7,7 @@ import {
   showError,
   showSuccess,
   timestamp2string,
-} from '../helpers';
+} from '../../helpers';
 
 import {
   Button,
@@ -24,7 +24,7 @@ import {
   Tag,
   Typography,
 } from '@douyinfe/semi-ui';
-import { ITEMS_PER_PAGE } from '../constants';
+import { ITEMS_PER_PAGE } from '../../constants';
 import {
   IconEyeOpened,
   IconSearch,

+ 6 - 5
web/src/components/TokensTable.js → web/src/components/table/TokensTable.js

@@ -6,10 +6,11 @@ import {
   showError,
   showSuccess,
   timestamp2string,
-} from '../helpers';
+  renderGroup,
+  renderQuota
+} from '../../helpers';
 
-import { ITEMS_PER_PAGE } from '../constants';
-import { renderGroup, renderQuota } from '../helpers/render';
+import { ITEMS_PER_PAGE } from '../../constants';
 import {
   Button,
   Card,
@@ -39,9 +40,9 @@ import {
   IconHistogram,
   IconRotate,
 } from '@douyinfe/semi-icons';
-import EditToken from '../pages/Token/EditToken';
+import EditToken from '../../pages/Token/EditToken';
 import { useTranslation } from 'react-i18next';
-import { UserContext } from '../context/User';
+import { UserContext } from '../../context/User';
 
 function renderTimestamp(timestamp) {
   return <>{timestamp2string(timestamp)}</>;

+ 4 - 5
web/src/components/UsersTable.js → web/src/components/table/UsersTable.js

@@ -1,5 +1,5 @@
 import React, { useEffect, useState } from 'react';
-import { API, showError, showSuccess } from '../helpers';
+import { API, showError, showSuccess, renderGroup, renderNumber, renderQuota } from '../../helpers';
 import {
   Button,
   Card,
@@ -25,10 +25,9 @@ import {
   IconArrowUp,
   IconArrowDown,
 } from '@douyinfe/semi-icons';
-import { ITEMS_PER_PAGE } from '../constants';
-import { renderGroup, renderNumber, renderQuota } from '../helpers/render';
-import AddUser from '../pages/User/AddUser';
-import EditUser from '../pages/User/EditUser';
+import { ITEMS_PER_PAGE } from '../../constants';
+import AddUser from '../../pages/User/AddUser';
+import EditUser from '../../pages/User/EditUser';
 import { useTranslation } from 'react-i18next';
 
 const { Text } = Typography;

+ 0 - 76
web/src/components/utils.js

@@ -1,76 +0,0 @@
-import { API, showError } from '../helpers';
-
-export async function getOAuthState() {
-  let path = '/api/oauth/state';
-  let affCode = localStorage.getItem('aff');
-  if (affCode && affCode.length > 0) {
-    path += `?aff=${affCode}`;
-  }
-  const res = await API.get(path);
-  const { success, message, data } = res.data;
-  if (success) {
-    return data;
-  } else {
-    showError(message);
-    return '';
-  }
-}
-
-export async function onOIDCClicked(auth_url, client_id, openInNewTab = false) {
-  const state = await getOAuthState();
-  if (!state) return;
-  const redirect_uri = `${window.location.origin}/oauth/oidc`;
-  const response_type = 'code';
-  const scope = 'openid profile email';
-  const url = `${auth_url}?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`;
-  if (openInNewTab) {
-    window.open(url);
-  } else {
-    window.location.href = url;
-  }
-}
-
-export async function onGitHubOAuthClicked(github_client_id) {
-  const state = await getOAuthState();
-  if (!state) return;
-  window.open(
-    `https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`,
-  );
-}
-
-export async function onLinuxDOOAuthClicked(linuxdo_client_id) {
-  const state = await getOAuthState();
-  if (!state) return;
-  window.open(
-    `https://connect.linux.do/oauth2/authorize?response_type=code&client_id=${linuxdo_client_id}&state=${state}`,
-  );
-}
-
-let channelModels = undefined;
-export async function loadChannelModels() {
-  const res = await API.get('/api/models');
-  const { success, data } = res.data;
-  if (!success) {
-    return;
-  }
-  channelModels = data;
-  localStorage.setItem('channel_models', JSON.stringify(data));
-}
-
-export function getChannelModels(type) {
-  if (channelModels !== undefined && type in channelModels) {
-    if (!channelModels[type]) {
-      return [];
-    }
-    return channelModels[type];
-  }
-  let models = localStorage.getItem('channel_models');
-  if (!models) {
-    return [];
-  }
-  channelModels = JSON.parse(models);
-  if (type in channelModels) {
-    return channelModels[type];
-  }
-  return [];
-}

+ 1 - 0
web/src/constants/index.js

@@ -3,3 +3,4 @@ export * from './user.constants';
 export * from './toast.constants';
 export * from './common.constant';
 export * from './model.constants';
+export * from './playground.constants';

+ 0 - 0
web/src/utils/constants.js → web/src/constants/playground.constants.js


+ 1 - 1
web/src/context/Style/index.js

@@ -2,7 +2,7 @@
 
 import React, { useReducer, useEffect, useMemo, createContext } from 'react';
 import { useLocation } from 'react-router-dom';
-import { isMobile as getIsMobile } from '../../helpers/index.js';
+import { isMobile as getIsMobile } from '../../helpers';
 
 // Action Types
 const ACTION_TYPES = {

+ 184 - 1
web/src/helpers/api.js

@@ -1,5 +1,6 @@
-import { getUserIdFromLocalStorage, showError } from './utils';
+import { getUserIdFromLocalStorage, showError, formatMessageForAPI, isValidMessage } from './utils';
 import axios from 'axios';
+import { MESSAGE_ROLES } from '../constants/playground.constants';
 
 export let API = axios.create({
   baseURL: import.meta.env.VITE_REACT_APP_SERVER_URL
@@ -29,3 +30,185 @@ API.interceptors.response.use(
     showError(error);
   },
 );
+
+// playground
+
+// 构建API请求负载
+export const buildApiPayload = (messages, systemPrompt, inputs, parameterEnabled) => {
+  const processedMessages = messages
+    .filter(isValidMessage)
+    .map(formatMessageForAPI)
+    .filter(Boolean);
+
+  // 如果有系统提示,插入到消息开头
+  if (systemPrompt && systemPrompt.trim()) {
+    processedMessages.unshift({
+      role: MESSAGE_ROLES.SYSTEM,
+      content: systemPrompt.trim()
+    });
+  }
+
+  const payload = {
+    model: inputs.model,
+    messages: processedMessages,
+    stream: inputs.stream,
+  };
+
+  // 添加启用的参数
+  const parameterMappings = {
+    temperature: 'temperature',
+    top_p: 'top_p',
+    max_tokens: 'max_tokens',
+    frequency_penalty: 'frequency_penalty',
+    presence_penalty: 'presence_penalty',
+    seed: 'seed'
+  };
+
+  Object.entries(parameterMappings).forEach(([key, param]) => {
+    if (parameterEnabled[key] && inputs[param] !== undefined && inputs[param] !== null) {
+      payload[param] = inputs[param];
+    }
+  });
+
+  return payload;
+};
+
+// 处理API错误响应
+export const handleApiError = (error, response = null) => {
+  const errorInfo = {
+    error: error.message || '未知错误',
+    timestamp: new Date().toISOString(),
+    stack: error.stack
+  };
+
+  if (response) {
+    errorInfo.status = response.status;
+    errorInfo.statusText = response.statusText;
+  }
+
+  if (error.message.includes('HTTP error')) {
+    errorInfo.details = '服务器返回了错误状态码';
+  } else if (error.message.includes('Failed to fetch')) {
+    errorInfo.details = '网络连接失败或服务器无响应';
+  }
+
+  return errorInfo;
+};
+
+// 处理模型数据
+export const processModelsData = (data, currentModel) => {
+  const modelOptions = data.map(model => ({
+    label: model,
+    value: model,
+  }));
+
+  const hasCurrentModel = modelOptions.some(option => option.value === currentModel);
+  const selectedModel = hasCurrentModel && modelOptions.length > 0
+    ? currentModel
+    : modelOptions[0]?.value;
+
+  return { modelOptions, selectedModel };
+};
+
+// 处理分组数据
+export const processGroupsData = (data, userGroup) => {
+  let groupOptions = Object.entries(data).map(([group, info]) => ({
+    label: info.desc.length > 20 ? info.desc.substring(0, 20) + '...' : info.desc,
+    value: group,
+    ratio: info.ratio,
+    fullLabel: info.desc,
+  }));
+
+  if (groupOptions.length === 0) {
+    groupOptions = [{
+      label: '用户分组',
+      value: '',
+      ratio: 1,
+    }];
+  } else if (userGroup) {
+    const userGroupIndex = groupOptions.findIndex(g => g.value === userGroup);
+    if (userGroupIndex > -1) {
+      const userGroupOption = groupOptions.splice(userGroupIndex, 1)[0];
+      groupOptions.unshift(userGroupOption);
+    }
+  }
+
+  return groupOptions;
+};
+
+// 原来components中的utils.js
+
+export async function getOAuthState() {
+  let path = '/api/oauth/state';
+  let affCode = localStorage.getItem('aff');
+  if (affCode && affCode.length > 0) {
+    path += `?aff=${affCode}`;
+  }
+  const res = await API.get(path);
+  const { success, message, data } = res.data;
+  if (success) {
+    return data;
+  } else {
+    showError(message);
+    return '';
+  }
+}
+
+export async function onOIDCClicked(auth_url, client_id, openInNewTab = false) {
+  const state = await getOAuthState();
+  if (!state) return;
+  const redirect_uri = `${window.location.origin}/oauth/oidc`;
+  const response_type = 'code';
+  const scope = 'openid profile email';
+  const url = `${auth_url}?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`;
+  if (openInNewTab) {
+    window.open(url);
+  } else {
+    window.location.href = url;
+  }
+}
+
+export async function onGitHubOAuthClicked(github_client_id) {
+  const state = await getOAuthState();
+  if (!state) return;
+  window.open(
+    `https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`,
+  );
+}
+
+export async function onLinuxDOOAuthClicked(linuxdo_client_id) {
+  const state = await getOAuthState();
+  if (!state) return;
+  window.open(
+    `https://connect.linux.do/oauth2/authorize?response_type=code&client_id=${linuxdo_client_id}&state=${state}`,
+  );
+}
+
+let channelModels = undefined;
+export async function loadChannelModels() {
+  const res = await API.get('/api/models');
+  const { success, data } = res.data;
+  if (!success) {
+    return;
+  }
+  channelModels = data;
+  localStorage.setItem('channel_models', JSON.stringify(data));
+}
+
+export function getChannelModels(type) {
+  if (channelModels !== undefined && type in channelModels) {
+    if (!channelModels[type]) {
+      return [];
+    }
+    return channelModels[type];
+  }
+  let models = localStorage.getItem('channel_models');
+  if (!models) {
+    return [];
+  }
+  channelModels = JSON.parse(models);
+  if (type in channelModels) {
+    return channelModels[type];
+  }
+  return [];
+}

+ 0 - 10
web/src/helpers/auth-header.js

@@ -1,10 +0,0 @@
-export function authHeader() {
-  // return authorization header with jwt token
-  let user = JSON.parse(localStorage.getItem('user'));
-
-  if (user && user.token) {
-    return { Authorization: 'Bearer ' + user.token };
-  } else {
-    return {};
-  }
-}

+ 33 - 0
web/src/helpers/auth.js

@@ -0,0 +1,33 @@
+import React from 'react';
+import { Navigate } from 'react-router-dom';
+import { history } from './history';
+
+export function authHeader() {
+  // return authorization header with jwt token
+  let user = JSON.parse(localStorage.getItem('user'));
+
+  if (user && user.token) {
+    return { Authorization: 'Bearer ' + user.token };
+  } else {
+    return {};
+  }
+}
+
+export const AuthRedirect = ({ children }) => {
+  const user = localStorage.getItem('user');
+
+  if (user) {
+    return <Navigate to="/console" replace />;
+  }
+
+  return children;
+};
+
+function PrivateRoute({ children }) {
+  if (!localStorage.getItem('user')) {
+    return <Navigate to='/login' state={{ from: history.location }} />;
+  }
+  return children;
+}
+
+export { PrivateRoute };

+ 5 - 1
web/src/helpers/index.js

@@ -1,4 +1,8 @@
 export * from './history';
-export * from './auth-header';
+export * from './auth';
 export * from './utils';
 export * from './api';
+export * from './render';
+export * from './log';
+export * from './data';
+export * from './token';

+ 1 - 1
web/src/helpers/other.js → web/src/helpers/log.js

@@ -4,4 +4,4 @@ export function getLogOther(otherStr) {
   }
   let other = JSON.parse(otherStr);
   return other;
-}
+} 

+ 178 - 101
web/src/helpers/render.js

@@ -1,6 +1,7 @@
 import i18next from 'i18next';
 import { Modal, Tag, Typography } from '@douyinfe/semi-ui';
-import { copy, isMobile, showSuccess } from './utils.js';
+import { copy, isMobile, showSuccess } from './utils';
+import { visit } from 'unist-util-visit';
 
 export function renderText(text, limit) {
   if (text.length > limit) {
@@ -419,11 +420,25 @@ export function renderModelPrice(
           <p>
             {cacheTokens > 0 && !image && !webSearch && !fileSearch
               ? i18next.t(
-                  '输入 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
+                '输入 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
+                {
+                  nonCacheInput: inputTokens - cacheTokens,
+                  cacheInput: cacheTokens,
+                  cachePrice: inputRatioPrice * cacheRatio,
+                  price: inputRatioPrice,
+                  completion: completionTokens,
+                  compPrice: completionRatioPrice,
+                  ratio: groupRatio,
+                  total: price.toFixed(6),
+                },
+              )
+              : image && imageOutputTokens > 0 && !webSearch && !fileSearch
+                ? i18next.t(
+                  '输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
                   {
-                    nonCacheInput: inputTokens - cacheTokens,
-                    cacheInput: cacheTokens,
-                    cachePrice: inputRatioPrice * cacheRatio,
+                    nonImageInput: inputTokens - imageOutputTokens,
+                    imageInput: imageOutputTokens,
+                    imageRatio: imageRatio,
                     price: inputRatioPrice,
                     completion: completionTokens,
                     compPrice: completionRatioPrice,
@@ -431,82 +446,68 @@ export function renderModelPrice(
                     total: price.toFixed(6),
                   },
                 )
-              : image && imageOutputTokens > 0 && !webSearch && !fileSearch
-                ? i18next.t(
-                    '输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
+                : webSearch && webSearchCallCount > 0 && !image && !fileSearch
+                  ? i18next.t(
+                    '输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + Web搜索 {{webSearchCallCount}}次 / 1K 次 * ${{webSearchPrice}} * {{ratio}} = ${{total}}',
                     {
-                      nonImageInput: inputTokens - imageOutputTokens,
-                      imageInput: imageOutputTokens,
-                      imageRatio: imageRatio,
+                      input: inputTokens,
                       price: inputRatioPrice,
                       completion: completionTokens,
                       compPrice: completionRatioPrice,
                       ratio: groupRatio,
+                      webSearchCallCount,
+                      webSearchPrice,
                       total: price.toFixed(6),
                     },
                   )
-                : webSearch && webSearchCallCount > 0 && !image && !fileSearch
-                  ? i18next.t(
-                      '输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + Web搜索 {{webSearchCallCount}}次 / 1K 次 * ${{webSearchPrice}} * {{ratio}} = ${{total}}',
+                  : fileSearch &&
+                    fileSearchCallCount > 0 &&
+                    !image &&
+                    !webSearch
+                    ? i18next.t(
+                      '输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + 文件搜索 {{fileSearchCallCount}}次 / 1K 次 * ${{fileSearchPrice}} * {{ratio}}= ${{total}}',
                       {
                         input: inputTokens,
                         price: inputRatioPrice,
                         completion: completionTokens,
                         compPrice: completionRatioPrice,
                         ratio: groupRatio,
-                        webSearchCallCount,
-                        webSearchPrice,
+                        fileSearchCallCount,
+                        fileSearchPrice,
                         total: price.toFixed(6),
                       },
                     )
-                  : fileSearch &&
+                    : webSearch &&
+                      webSearchCallCount > 0 &&
+                      fileSearch &&
                       fileSearchCallCount > 0 &&
-                      !image &&
-                      !webSearch
-                    ? i18next.t(
-                        '输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + 文件搜索 {{fileSearchCallCount}}次 / 1K 次 * ${{fileSearchPrice}} * {{ratio}}= ${{total}}',
+                      !image
+                      ? i18next.t(
+                        '输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + Web搜索 {{webSearchCallCount}}次 / 1K 次 * ${{webSearchPrice}} * {{ratio}}+ 文件搜索 {{fileSearchCallCount}}次 / 1K 次 * ${{fileSearchPrice}} * {{ratio}}= ${{total}}',
                         {
                           input: inputTokens,
                           price: inputRatioPrice,
                           completion: completionTokens,
                           compPrice: completionRatioPrice,
                           ratio: groupRatio,
+                          webSearchCallCount,
+                          webSearchPrice,
                           fileSearchCallCount,
                           fileSearchPrice,
                           total: price.toFixed(6),
                         },
                       )
-                    : webSearch &&
-                        webSearchCallCount > 0 &&
-                        fileSearch &&
-                        fileSearchCallCount > 0 &&
-                        !image
-                      ? i18next.t(
-                          '输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + Web搜索 {{webSearchCallCount}}次 / 1K 次 * ${{webSearchPrice}} * {{ratio}}+ 文件搜索 {{fileSearchCallCount}}次 / 1K 次 * ${{fileSearchPrice}} * {{ratio}}= ${{total}}',
-                          {
-                            input: inputTokens,
-                            price: inputRatioPrice,
-                            completion: completionTokens,
-                            compPrice: completionRatioPrice,
-                            ratio: groupRatio,
-                            webSearchCallCount,
-                            webSearchPrice,
-                            fileSearchCallCount,
-                            fileSearchPrice,
-                            total: price.toFixed(6),
-                          },
-                        )
                       : i18next.t(
-                          '输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
-                          {
-                            input: inputTokens,
-                            price: inputRatioPrice,
-                            completion: completionTokens,
-                            compPrice: completionRatioPrice,
-                            ratio: groupRatio,
-                            total: price.toFixed(6),
-                          },
-                        )}
+                        '输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
+                        {
+                          input: inputTokens,
+                          price: inputRatioPrice,
+                          completion: completionTokens,
+                          compPrice: completionRatioPrice,
+                          ratio: groupRatio,
+                          total: price.toFixed(6),
+                        },
+                      )}
           </p>
           <p>{i18next.t('仅供参考,以实际扣费为准')}</p>
         </article>
@@ -677,10 +678,10 @@ export function renderAudioModelPrice(
     let audioPrice =
       (audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio +
       (audioCompletionTokens / 1000000) *
-        inputRatioPrice *
-        audioRatio *
-        audioCompletionRatio *
-        groupRatio;
+      inputRatioPrice *
+      audioRatio *
+      audioCompletionRatio *
+      groupRatio;
     let price = textPrice + audioPrice;
     return (
       <>
@@ -736,27 +737,27 @@ export function renderAudioModelPrice(
           <p>
             {cacheTokens > 0
               ? i18next.t(
-                  '文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
-                  {
-                    nonCacheInput: inputTokens - cacheTokens,
-                    cacheInput: cacheTokens,
-                    cachePrice: inputRatioPrice * cacheRatio,
-                    price: inputRatioPrice,
-                    completion: completionTokens,
-                    compPrice: completionRatioPrice,
-                    total: textPrice.toFixed(6),
-                  },
-                )
+                '文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
+                {
+                  nonCacheInput: inputTokens - cacheTokens,
+                  cacheInput: cacheTokens,
+                  cachePrice: inputRatioPrice * cacheRatio,
+                  price: inputRatioPrice,
+                  completion: completionTokens,
+                  compPrice: completionRatioPrice,
+                  total: textPrice.toFixed(6),
+                },
+              )
               : i18next.t(
-                  '文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
-                  {
-                    input: inputTokens,
-                    price: inputRatioPrice,
-                    completion: completionTokens,
-                    compPrice: completionRatioPrice,
-                    total: textPrice.toFixed(6),
-                  },
-                )}
+                '文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
+                {
+                  input: inputTokens,
+                  price: inputRatioPrice,
+                  completion: completionTokens,
+                  compPrice: completionRatioPrice,
+                  total: textPrice.toFixed(6),
+                },
+              )}
           </p>
           <p>
             {i18next.t(
@@ -1024,33 +1025,33 @@ export function renderClaudeModelPrice(
           <p>
             {cacheTokens > 0 || cacheCreationTokens > 0
               ? i18next.t(
-                  '提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
-                  {
-                    nonCacheInput: nonCachedTokens,
-                    cacheInput: cacheTokens,
-                    cacheRatio: cacheRatio,
-                    cacheCreationInput: cacheCreationTokens,
-                    cacheCreationRatio: cacheCreationRatio,
-                    cachePrice: cacheRatioPrice,
-                    cacheCreationPrice: cacheCreationRatioPrice,
-                    price: inputRatioPrice,
-                    completion: completionTokens,
-                    compPrice: completionRatioPrice,
-                    ratio: groupRatio,
-                    total: price.toFixed(6),
-                  },
-                )
+                '提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
+                {
+                  nonCacheInput: nonCachedTokens,
+                  cacheInput: cacheTokens,
+                  cacheRatio: cacheRatio,
+                  cacheCreationInput: cacheCreationTokens,
+                  cacheCreationRatio: cacheCreationRatio,
+                  cachePrice: cacheRatioPrice,
+                  cacheCreationPrice: cacheCreationRatioPrice,
+                  price: inputRatioPrice,
+                  completion: completionTokens,
+                  compPrice: completionRatioPrice,
+                  ratio: groupRatio,
+                  total: price.toFixed(6),
+                },
+              )
               : i18next.t(
-                  '提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
-                  {
-                    input: inputTokens,
-                    price: inputRatioPrice,
-                    completion: completionTokens,
-                    compPrice: completionRatioPrice,
-                    ratio: groupRatio,
-                    total: price.toFixed(6),
-                  },
-                )}
+                '提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
+                {
+                  input: inputTokens,
+                  price: inputRatioPrice,
+                  completion: completionTokens,
+                  compPrice: completionRatioPrice,
+                  ratio: groupRatio,
+                  total: price.toFixed(6),
+                },
+              )}
           </p>
           <p>{i18next.t('仅供参考,以实际扣费为准')}</p>
         </article>
@@ -1128,3 +1129,79 @@ export function renderClaudeModelPriceSimple(
     }
   }
 }
+
+/**
+ * rehype 插件:将段落等文本节点拆分为逐词 <span>,并添加淡入动画 class。
+ * 仅在流式渲染阶段使用,避免已渲染文字重复动画。
+ */
+export function rehypeSplitWordsIntoSpans(options = {}) {
+  const { previousContentLength = 0 } = options;
+
+  return (tree) => {
+    let currentCharCount = 0; // 当前已处理的字符数
+
+    visit(tree, 'element', (node) => {
+      if (
+        ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'strong'].includes(node.tagName) &&
+        node.children
+      ) {
+        const newChildren = [];
+        node.children.forEach((child) => {
+          if (child.type === 'text') {
+            try {
+              // 使用 Intl.Segmenter 精准拆分中英文及标点
+              const segmenter = new Intl.Segmenter('zh', { granularity: 'word' });
+              const segments = segmenter.segment(child.value);
+
+              Array.from(segments)
+                .map((seg) => seg.segment)
+                .filter(Boolean)
+                .forEach((word) => {
+                  const wordStartPos = currentCharCount;
+                  const wordEndPos = currentCharCount + word.length;
+
+                  // 判断这个词是否是新增的(在 previousContentLength 之后)
+                  const isNewContent = wordStartPos >= previousContentLength;
+
+                  newChildren.push({
+                    type: 'element',
+                    tagName: 'span',
+                    properties: {
+                      className: isNewContent ? ['animate-fade-in'] : [],
+                    },
+                    children: [{ type: 'text', value: word }],
+                  });
+
+                  currentCharCount = wordEndPos;
+                });
+            } catch (_) {
+              // Fallback:如果浏览器不支持 Segmenter
+              const textStartPos = currentCharCount;
+              const isNewContent = textStartPos >= previousContentLength;
+
+              if (isNewContent) {
+                // 新内容,添加动画
+                newChildren.push({
+                  type: 'element',
+                  tagName: 'span',
+                  properties: {
+                    className: ['animate-fade-in'],
+                  },
+                  children: [{ type: 'text', value: child.value }],
+                });
+              } else {
+                // 旧内容,不添加动画
+                newChildren.push(child);
+              }
+
+              currentCharCount += child.value.length;
+            }
+          } else {
+            newChildren.push(child);
+          }
+        });
+        node.children = newChildren;
+      }
+    });
+  };
+} 

+ 45 - 0
web/src/helpers/token.js

@@ -0,0 +1,45 @@
+import { API } from './api';
+
+/**
+ * 获取可用的token keys
+ * @returns {Promise<string[]>} 返回active状态的token key数组
+ */
+export async function fetchTokenKeys() {
+  try {
+    const response = await API.get('/api/token/?p=0&size=100');
+    const { success, data } = response.data;
+    if (success) {
+      const activeTokens = data.filter((token) => token.status === 1);
+      return activeTokens.map((token) => token.key);
+    } else {
+      throw new Error('Failed to fetch token keys');
+    }
+  } catch (error) {
+    console.error('Error fetching token keys:', error);
+    return [];
+  }
+}
+
+/**
+ * 获取服务器地址
+ * @returns {string} 服务器地址
+ */
+export function getServerAddress() {
+  let status = localStorage.getItem('status');
+  let serverAddress = '';
+
+  if (status) {
+    try {
+      status = JSON.parse(status);
+      serverAddress = status.server_address || '';
+    } catch (error) {
+      console.error('Failed to parse status from localStorage:', error);
+    }
+  }
+
+  if (!serverAddress) {
+    serverAddress = window.location.origin;
+  }
+
+  return serverAddress;
+} 

+ 163 - 0
web/src/helpers/utils.js

@@ -2,6 +2,7 @@ import { Toast } from '@douyinfe/semi-ui';
 import { toastConstants } from '../constants';
 import React from 'react';
 import { toast } from 'react-toastify';
+import { THINK_TAG_REGEX, MESSAGE_ROLES } from '../constants/playground.constants';
 
 const HTMLToastContent = ({ htmlContent }) => {
   return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
@@ -283,3 +284,165 @@ export function compareObjects(oldObject, newObject) {
 
   return changedProperties;
 }
+
+// playground message
+
+// 生成唯一ID
+let messageId = 4;
+export const generateMessageId = () => `${messageId++}`;
+
+// 提取消息中的文本内容
+export const getTextContent = (message) => {
+  if (!message || !message.content) return '';
+
+  if (Array.isArray(message.content)) {
+    const textContent = message.content.find(item => item.type === 'text');
+    return textContent?.text || '';
+  }
+  return typeof message.content === 'string' ? message.content : '';
+};
+
+// 处理 think 标签
+export const processThinkTags = (content, reasoningContent = '') => {
+  if (!content || !content.includes('<think>')) {
+    return { content, reasoningContent };
+  }
+
+  const thoughts = [];
+  const replyParts = [];
+  let lastIndex = 0;
+  let match;
+
+  THINK_TAG_REGEX.lastIndex = 0;
+  while ((match = THINK_TAG_REGEX.exec(content)) !== null) {
+    replyParts.push(content.substring(lastIndex, match.index));
+    thoughts.push(match[1]);
+    lastIndex = match.index + match[0].length;
+  }
+  replyParts.push(content.substring(lastIndex));
+
+  const processedContent = replyParts.join('').replace(/<\/?think>/g, '').trim();
+  const thoughtsStr = thoughts.join('\n\n---\n\n');
+  const processedReasoningContent = reasoningContent && thoughtsStr
+    ? `${reasoningContent}\n\n---\n\n${thoughtsStr}`
+    : reasoningContent || thoughtsStr;
+
+  return {
+    content: processedContent,
+    reasoningContent: processedReasoningContent
+  };
+};
+
+// 处理未完成的 think 标签
+export const processIncompleteThinkTags = (content, reasoningContent = '') => {
+  if (!content) return { content: '', reasoningContent };
+
+  const lastOpenThinkIndex = content.lastIndexOf('<think>');
+  if (lastOpenThinkIndex === -1) {
+    return processThinkTags(content, reasoningContent);
+  }
+
+  const fragmentAfterLastOpen = content.substring(lastOpenThinkIndex);
+  if (!fragmentAfterLastOpen.includes('</think>')) {
+    const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim();
+    const cleanContent = content.substring(0, lastOpenThinkIndex);
+    const processedReasoningContent = unclosedThought
+      ? reasoningContent ? `${reasoningContent}\n\n---\n\n${unclosedThought}` : unclosedThought
+      : reasoningContent;
+
+    return processThinkTags(cleanContent, processedReasoningContent);
+  }
+
+  return processThinkTags(content, reasoningContent);
+};
+
+// 构建消息内容(包含图片)
+export const buildMessageContent = (textContent, imageUrls = [], imageEnabled = false) => {
+  if (!textContent && (!imageUrls || imageUrls.length === 0)) {
+    return '';
+  }
+
+  const validImageUrls = imageUrls.filter(url => url && url.trim() !== '');
+
+  if (imageEnabled && validImageUrls.length > 0) {
+    return [
+      { type: 'text', text: textContent || '' },
+      ...validImageUrls.map(url => ({
+        type: 'image_url',
+        image_url: { url: url.trim() }
+      }))
+    ];
+  }
+
+  return textContent || '';
+};
+
+// 创建新消息
+export const createMessage = (role, content, options = {}) => ({
+  role,
+  content,
+  createAt: Date.now(),
+  id: generateMessageId(),
+  ...options
+});
+
+// 创建加载中的助手消息
+export const createLoadingAssistantMessage = () => createMessage(
+  MESSAGE_ROLES.ASSISTANT,
+  '',
+  {
+    reasoningContent: '',
+    isReasoningExpanded: true,
+    isThinkingComplete: false,
+    hasAutoCollapsed: false,
+    status: 'loading'
+  }
+);
+
+// 检查消息是否包含图片
+export const hasImageContent = (message) => {
+  return message &&
+    Array.isArray(message.content) &&
+    message.content.some(item => item.type === 'image_url');
+};
+
+// 格式化消息用于API请求
+export const formatMessageForAPI = (message) => {
+  if (!message) return null;
+
+  return {
+    role: message.role,
+    content: message.content
+  };
+};
+
+// 验证消息是否有效
+export const isValidMessage = (message) => {
+  return message &&
+    message.role &&
+    (message.content || message.content === '');
+};
+
+// 获取最后一条用户消息
+export const getLastUserMessage = (messages) => {
+  if (!Array.isArray(messages)) return null;
+
+  for (let i = messages.length - 1; i >= 0; i--) {
+    if (messages[i].role === MESSAGE_ROLES.USER) {
+      return messages[i];
+    }
+  }
+  return null;
+};
+
+// 获取最后一条助手消息
+export const getLastAssistantMessage = (messages) => {
+  if (!Array.isArray(messages)) return null;
+
+  for (let i = messages.length - 1; i >= 0; i--) {
+    if (messages[i].role === MESSAGE_ROLES.ASSISTANT) {
+      return messages[i];
+    }
+  }
+  return null;
+};

+ 4 - 7
web/src/hooks/useApiRequest.js

@@ -1,20 +1,17 @@
 import { useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 import { SSE } from 'sse';
-import { getUserIdFromLocalStorage } from '../helpers/index.js';
 import {
   API_ENDPOINTS,
   MESSAGE_STATUS,
   DEBUG_TABS
-} from '../utils/constants';
-import {
-  buildApiPayload,
-  handleApiError
-} from '../utils/apiUtils';
+} from '../constants/playground.constants';
 import {
+  getUserIdFromLocalStorage,
+  handleApiError,
   processThinkTags,
   processIncompleteThinkTags
-} from '../utils/messageUtils';
+} from '../helpers';
 
 export const useApiRequest = (
   setMessage,

+ 2 - 3
web/src/hooks/useDataLoader.js

@@ -1,8 +1,7 @@
 import { useCallback, useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
-import { API, showError } from '../helpers/index.js';
-import { API_ENDPOINTS } from '../utils/constants';
-import { processModelsData, processGroupsData } from '../utils/apiUtils';
+import { API, processModelsData, processGroupsData } from '../helpers';
+import { API_ENDPOINTS } from '../constants/playground.constants';
 
 export const useDataLoader = (
   userState,

+ 2 - 2
web/src/hooks/useMessageActions.js

@@ -1,8 +1,8 @@
 import { useCallback } from 'react';
 import { Toast, Modal } from '@douyinfe/semi-ui';
 import { useTranslation } from 'react-i18next';
-import { getTextContent } from '../utils/messageUtils';
-import { ERROR_MESSAGES } from '../utils/constants';
+import { getTextContent } from '../helpers';
+import { ERROR_MESSAGES } from '../constants/playground.constants';
 
 export const useMessageActions = (message, setMessage, onMessageSend, saveMessages) => {
   const { t } = useTranslation();

+ 2 - 2
web/src/hooks/useMessageEdit.js

@@ -1,8 +1,8 @@
 import { useCallback, useState, useRef } from 'react';
 import { Toast, Modal } from '@douyinfe/semi-ui';
 import { useTranslation } from 'react-i18next';
-import { getTextContent, buildApiPayload, createLoadingAssistantMessage } from '../utils/messageUtils';
-import { MESSAGE_ROLES } from '../utils/constants';
+import { getTextContent, buildApiPayload, createLoadingAssistantMessage } from '../helpers';
+import { MESSAGE_ROLES } from '../constants/playground.constants';
 
 export const useMessageEdit = (
   setMessage,

+ 2 - 2
web/src/hooks/usePlaygroundState.js

@@ -1,7 +1,7 @@
 import { useState, useCallback, useRef, useEffect } from 'react';
-import { DEFAULT_MESSAGES, DEFAULT_CONFIG, DEBUG_TABS, MESSAGE_STATUS } from '../utils/constants';
+import { DEFAULT_MESSAGES, DEFAULT_CONFIG, DEBUG_TABS, MESSAGE_STATUS } from '../constants/playground.constants';
 import { loadConfig, saveConfig, loadMessages, saveMessages } from '../components/playground/configStorage';
-import { processIncompleteThinkTags } from '../utils/messageUtils';
+import { processIncompleteThinkTags } from '../helpers';
 
 export const usePlaygroundState = () => {
   // 使用惰性初始化,确保只在组件首次挂载时加载配置和消息

+ 32 - 0
web/src/hooks/useSetupCheck.js

@@ -0,0 +1,32 @@
+import { useContext, useEffect } from 'react';
+import { useLocation } from 'react-router-dom';
+import { StatusContext } from '../context/Status';
+
+/**
+ * 自定义Hook:检查系统setup状态并进行重定向
+ * @param {Object} options - 配置选项
+ * @param {boolean} options.autoRedirect - 是否自动重定向,默认true
+ * @param {string} options.setupPath - setup页面路径,默认'/setup'
+ * @returns {Object} 返回setup状态信息
+ */
+export function useSetupCheck(options = {}) {
+  const { autoRedirect = true, setupPath = '/setup' } = options;
+  const [statusState] = useContext(StatusContext);
+  const location = useLocation();
+
+  const isSetupComplete = statusState?.status?.setup !== false;
+  const needsSetup = !isSetupComplete && location.pathname !== setupPath;
+
+  useEffect(() => {
+    if (autoRedirect && needsSetup) {
+      window.location.href = setupPath;
+    }
+  }, [autoRedirect, needsSetup, setupPath]);
+
+  return {
+    isSetupComplete,
+    needsSetup,
+    statusState,
+    currentPath: location.pathname
+  };
+} 

+ 1 - 1
web/src/hooks/useSyncMessageAndCustomBody.js

@@ -1,5 +1,5 @@
 import { useCallback, useRef } from 'react';
-import { MESSAGE_ROLES } from '../utils/constants';
+import { MESSAGE_ROLES } from '../constants/playground.constants';
 
 export const useSyncMessageAndCustomBody = (
   customRequestMode,

+ 30 - 0
web/src/hooks/useTokenKeys.js

@@ -0,0 +1,30 @@
+import { useEffect, useState } from 'react';
+import { fetchTokenKeys, getServerAddress } from '../helpers/token';
+import { showError } from '../helpers';
+
+export function useTokenKeys(id) {
+  const [keys, setKeys] = useState([]);
+  const [serverAddress, setServerAddress] = useState('');
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    const loadAllData = async () => {
+      const fetchedKeys = await fetchTokenKeys();
+      if (fetchedKeys.length === 0) {
+        showError('当前没有可用的启用令牌,请确认是否有令牌处于启用状态!');
+        setTimeout(() => {
+          window.location.href = '/token';
+        }, 1500); // 延迟 1.5 秒后跳转
+      }
+      setKeys(fetchedKeys);
+      setIsLoading(false);
+
+      const address = getServerAddress();
+      setServerAddress(address);
+    };
+
+    loadAllData();
+  }, []);
+
+  return { keys, serverAddress, isLoading };
+} 

+ 2 - 1
web/src/index.js

@@ -7,8 +7,9 @@ import { StatusProvider } from './context/Status';
 import { Layout } from '@douyinfe/semi-ui';
 import { ThemeProvider } from './context/Theme';
 import { StyleProvider } from './context/Style/index.js';
-import PageLayout from './components/PageLayout.js';
+import PageLayout from './components/layout/PageLayout.js';
 import './i18n/i18n.js';
+import './index.css';
 
 // initialization
 

+ 1 - 1
web/src/pages/Channel/EditChannel.js

@@ -26,7 +26,7 @@ import {
   Card,
   Tag,
 } from '@douyinfe/semi-ui';
-import { getChannelModels } from '../../components/utils.js';
+import { getChannelModels } from '../../helpers';
 import {
   IconSave,
   IconClose,

+ 1 - 1
web/src/pages/Channel/EditTagModal.js

@@ -27,7 +27,7 @@ import {
   IconUser,
   IconCode,
 } from '@douyinfe/semi-icons';
-import { getChannelModels } from '../../components/utils.js';
+import { getChannelModels } from '../../helpers';
 import { useTranslation } from 'react-i18next';
 
 const { Text, Title } = Typography;

+ 1 - 1
web/src/pages/Channel/index.js

@@ -1,5 +1,5 @@
 import React from 'react';
-import ChannelsTable from '../../components/ChannelsTable';
+import ChannelsTable from '../../components/table/ChannelsTable';
 
 const File = () => {
   return (

+ 1 - 1
web/src/pages/Chat/index.js

@@ -1,5 +1,5 @@
 import React, { useEffect } from 'react';
-import { useTokenKeys } from '../../components/fetchTokenKeys';
+import { useTokenKeys } from '../../hooks/useTokenKeys';
 import { Banner, Layout } from '@douyinfe/semi-ui';
 import { useParams } from 'react-router-dom';
 

+ 1 - 1
web/src/pages/Chat2Link/index.js

@@ -1,5 +1,5 @@
 import React from 'react';
-import { useTokenKeys } from '../../components/fetchTokenKeys';
+import { useTokenKeys } from '../../hooks/useTokenKeys';
 
 const chat2page = () => {
   const { keys, chatLink, serverAddress, isLoading } = useTokenKeys();

+ 2 - 4
web/src/pages/Detail/index.js

@@ -30,14 +30,12 @@ import {
   showError,
   timestamp2string,
   timestamp2string1,
-} from '../../helpers';
-import {
   getQuotaWithUnit,
   modelColorMap,
   renderNumber,
   renderQuota,
-  modelToColor,
-} from '../../helpers/render';
+  modelToColor
+} from '../../helpers';
 import { UserContext } from '../../context/User/index.js';
 import { useTranslation } from 'react-i18next';
 

+ 2 - 2
web/src/pages/Home/index.js

@@ -5,9 +5,9 @@ import { StatusContext } from '../../context/Status';
 import { marked } from 'marked';
 import { useTranslation } from 'react-i18next';
 import { IconGithubLogo } from '@douyinfe/semi-icons';
-import exampleImage from '../../images/example.png';
+import exampleImage from '/example.png';
 import { Link } from 'react-router-dom';
-import NoticeModal from '../../components/NoticeModal';
+import NoticeModal from '../../components/layout/NoticeModal';
 import { Moonshot, OpenAI, XAI, Zhipu, Volcengine, Cohere, Claude, Gemini, Suno, Minimax, Wenxin, Spark, Qingyan, DeepSeek, Qwen, Midjourney, Grok, AzureAI, Hunyuan, Xinference } from '@lobehub/icons';
 
 const { Text } = Typography;

+ 1 - 1
web/src/pages/Log/index.js

@@ -1,5 +1,5 @@
 import React from 'react';
-import LogsTable from '../../components/LogsTable';
+import LogsTable from '../../components/table/LogsTable';
 
 const Token = () => (
   <>

+ 1 - 1
web/src/pages/Midjourney/index.js

@@ -1,5 +1,5 @@
 import React from 'react';
-import MjLogsTable from '../../components/MjLogsTable';
+import MjLogsTable from '../../components/table/MjLogsTable';
 
 const Midjourney = () => (
   <>

+ 5 - 6
web/src/pages/Playground/index.js

@@ -7,9 +7,7 @@ import { Layout, Toast, Modal } from '@douyinfe/semi-ui';
 import { UserContext } from '../../context/User/index.js';
 import { useStyle, styleActions } from '../../context/Style/index.js';
 
-// Utils and hooks
-import { getLogo } from '../../helpers/index.js';
-import { stringToColor } from '../../helpers/render.js';
+// hooks
 import { usePlaygroundState } from '../../hooks/usePlaygroundState.js';
 import { useMessageActions } from '../../hooks/useMessageActions.js';
 import { useApiRequest } from '../../hooks/useApiRequest.js';
@@ -19,17 +17,18 @@ import { useDataLoader } from '../../hooks/useDataLoader.js';
 
 // Constants and utils
 import {
-  DEFAULT_MESSAGES,
   MESSAGE_ROLES,
   ERROR_MESSAGES
-} from '../../utils/constants.js';
+} from '../../constants/playground.constants.js';
 import {
+  getLogo,
+  stringToColor,
   buildMessageContent,
   createMessage,
   createLoadingAssistantMessage,
   getTextContent,
   buildApiPayload
-} from '../../utils/messageUtils.js';
+} from '../../helpers';
 
 // Components
 import {

+ 1 - 1
web/src/pages/Pricing/index.js

@@ -1,5 +1,5 @@
 import React from 'react';
-import ModelPricing from '../../components/ModelPricing.js';
+import ModelPricing from '../../components/table/ModelPricing.js';
 
 const Pricing = () => (
   <>

+ 2 - 4
web/src/pages/Redemption/EditRedemption.js

@@ -6,11 +6,9 @@ import {
   isMobile,
   showError,
   showSuccess,
-} from '../../helpers';
-import {
   renderQuota,
-  renderQuotaWithPrompt,
-} from '../../helpers/render';
+  renderQuotaWithPrompt
+} from '../../helpers';
 import {
   AutoComplete,
   Button,

+ 1 - 1
web/src/pages/Redemption/index.js

@@ -1,5 +1,5 @@
 import React from 'react';
-import RedemptionsTable from '../../components/RedemptionsTable';
+import RedemptionsTable from '../../components/table/RedemptionsTable';
 
 const Redemption = () => {
   return (

+ 1 - 2
web/src/pages/Setting/Operation/ModelRationNotSetEditor.js

@@ -17,8 +17,7 @@ import {
   IconSave,
   IconBolt,
 } from '@douyinfe/semi-icons';
-import { showError, showSuccess } from '../../../helpers';
-import { API } from '../../../helpers';
+import { API, showError, showSuccess } from '../../../helpers';
 import { useTranslation } from 'react-i18next';
 
 export default function ModelRatioNotSetEditor(props) {

+ 10 - 15
web/src/pages/Setting/Operation/ModelSettingsVisualEditor.js

@@ -1,5 +1,5 @@
 // ModelSettingsVisualEditor.js
-import React, { useContext, useEffect, useState, useRef } from 'react';
+import React, { useEffect, useState, useRef } from 'react';
 import {
   Table,
   Button,
@@ -8,9 +8,7 @@ import {
   Form,
   Space,
   RadioGroup,
-  Radio,
-  Tabs,
-  TabPane,
+  Radio
 } from '@douyinfe/semi-ui';
 import {
   IconDelete,
@@ -19,11 +17,8 @@ import {
   IconSave,
   IconEdit,
 } from '@douyinfe/semi-icons';
-import { showError, showSuccess } from '../../../helpers';
-import { API } from '../../../helpers';
+import { API, showError, showSuccess, getQuotaPerUnit } from '../../../helpers';
 import { useTranslation } from 'react-i18next';
-import { StatusContext } from '../../../context/Status/index.js';
-import { getQuotaPerUnit } from '../../../helpers/render.js';
 
 export default function ModelSettingsVisualEditor(props) {
   const { t } = useTranslation();
@@ -304,11 +299,11 @@ export default function ModelSettingsVisualEditor(props) {
         prev.map((model, index) =>
           index === existingModelIndex
             ? {
-                name: values.name,
-                price: values.price || '',
-                ratio: values.ratio || '',
-                completionRatio: values.completionRatio || '',
-              }
+              name: values.name,
+              price: values.price || '',
+              ratio: values.ratio || '',
+              completionRatio: values.completionRatio || '',
+            }
             : model,
         ),
       );
@@ -456,8 +451,8 @@ export default function ModelSettingsVisualEditor(props) {
       <Modal
         title={
           currentModel &&
-          currentModel.name &&
-          models.some((model) => model.name === currentModel.name)
+            currentModel.name &&
+            models.some((model) => model.name === currentModel.name)
             ? t('编辑模型')
             : t('添加模型')
         }

+ 6 - 6
web/src/pages/Setting/index.js

@@ -3,13 +3,13 @@ import { Layout, TabPane, Tabs } from '@douyinfe/semi-ui';
 import { useNavigate, useLocation } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 
-import SystemSetting from '../../components/SystemSetting';
+import SystemSetting from '../../components/settings/SystemSetting.js';
 import { isRoot } from '../../helpers';
-import OtherSetting from '../../components/OtherSetting';
-import PersonalSetting from '../../components/PersonalSetting';
-import OperationSetting from '../../components/OperationSetting';
-import RateLimitSetting from '../../components/RateLimitSetting.js';
-import ModelSetting from '../../components/ModelSetting.js';
+import OtherSetting from '../../components/settings/OtherSetting';
+import PersonalSetting from '../../components/settings/PersonalSetting.js';
+import OperationSetting from '../../components/settings/OperationSetting.js';
+import RateLimitSetting from '../../components/settings/RateLimitSetting.js';
+import ModelSetting from '../../components/settings/ModelSetting.js';
 
 const Setting = () => {
   const { t } = useTranslation();

+ 1 - 1
web/src/pages/Task/index.js

@@ -1,5 +1,5 @@
 import React from 'react';
-import TaskLogsTable from '../../components/TaskLogsTable.js';
+import TaskLogsTable from '../../components/table/TaskLogsTable.js';
 
 const Task = () => (
   <>

+ 2 - 1
web/src/pages/Token/EditToken.js

@@ -6,8 +6,9 @@ import {
   showError,
   showSuccess,
   timestamp2string,
+  renderGroupOption,
+  renderQuotaWithPrompt
 } from '../../helpers';
-import { renderGroupOption, renderQuotaWithPrompt } from '../../helpers/render';
 import {
   AutoComplete,
   Banner,

+ 1 - 1
web/src/pages/Token/index.js

@@ -1,5 +1,5 @@
 import React from 'react';
-import TokensTable from '../../components/TokensTable';
+import TokensTable from '../../components/table/TokensTable';
 
 const Token = () => {
   return (

+ 7 - 4
web/src/pages/TopUp/index.js

@@ -1,10 +1,13 @@
 import React, { useEffect, useState, useContext } from 'react';
-import { API, showError, showInfo, showSuccess } from '../../helpers';
 import {
+  API,
+  showError,
+  showInfo,
+  showSuccess,
   renderQuota,
   renderQuotaWithAmount,
-  stringToColor,
-} from '../../helpers/render';
+  stringToColor
+} from '../../helpers';
 import {
   Layout,
   Typography,
@@ -262,7 +265,7 @@ const TopUp = () => {
   };
 
   return (
-    <div className="min-h-screen bg-gray-50">
+    <div className="bg-gray-50">
       <Layout>
         <Layout.Content>
           <Modal

+ 1 - 2
web/src/pages/User/EditUser.js

@@ -1,7 +1,6 @@
 import React, { useEffect, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
-import { API, isMobile, showError, showSuccess } from '../../helpers';
-import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
+import { API, isMobile, showError, showSuccess, renderQuota, renderQuotaWithPrompt } from '../../helpers';
 import {
   Button,
   Input,

+ 1 - 1
web/src/pages/User/index.js

@@ -1,5 +1,5 @@
 import React from 'react';
-import UsersTable from '../../components/UsersTable';
+import UsersTable from '../../components/table/UsersTable';
 
 const User = () => {
   return (

+ 0 - 105
web/src/utils/apiUtils.js

@@ -1,105 +0,0 @@
-import { formatMessageForAPI } from './messageUtils';
-
-// 构建API请求载荷
-export const buildApiPayload = (messages, systemPrompt, inputs, parameterEnabled) => {
-  const processedMessages = messages.map(formatMessageForAPI);
-
-  // 如果有系统提示,插入到消息开头
-  if (systemPrompt && systemPrompt.trim()) {
-    processedMessages.unshift({
-      role: 'system',
-      content: systemPrompt.trim()
-    });
-  }
-
-  const payload = {
-    model: inputs.model,
-    messages: processedMessages,
-    stream: inputs.stream,
-  };
-
-  // 添加启用的参数
-  if (parameterEnabled.temperature && inputs.temperature !== undefined) {
-    payload.temperature = inputs.temperature;
-  }
-  if (parameterEnabled.top_p && inputs.top_p !== undefined) {
-    payload.top_p = inputs.top_p;
-  }
-  if (parameterEnabled.max_tokens && inputs.max_tokens !== undefined) {
-    payload.max_tokens = inputs.max_tokens;
-  }
-  if (parameterEnabled.frequency_penalty && inputs.frequency_penalty !== undefined) {
-    payload.frequency_penalty = inputs.frequency_penalty;
-  }
-  if (parameterEnabled.presence_penalty && inputs.presence_penalty !== undefined) {
-    payload.presence_penalty = inputs.presence_penalty;
-  }
-  if (parameterEnabled.seed && inputs.seed !== undefined && inputs.seed !== null) {
-    payload.seed = inputs.seed;
-  }
-
-  return payload;
-};
-
-// 处理API错误响应
-export const handleApiError = (error, response = null) => {
-  const errorInfo = {
-    error: error.message || '未知错误',
-    timestamp: new Date().toISOString(),
-    stack: error.stack
-  };
-
-  if (response) {
-    errorInfo.status = response.status;
-    errorInfo.statusText = response.statusText;
-  }
-
-  if (error.message.includes('HTTP error')) {
-    errorInfo.details = '服务器返回了错误状态码';
-  } else if (error.message.includes('Failed to fetch')) {
-    errorInfo.details = '网络连接失败或服务器无响应';
-  }
-
-  return errorInfo;
-};
-
-// 处理模型数据
-export const processModelsData = (data, currentModel) => {
-  const modelOptions = data.map(model => ({
-    label: model,
-    value: model,
-  }));
-
-  const hasCurrentModel = modelOptions.some(option => option.value === currentModel);
-  const selectedModel = hasCurrentModel && modelOptions.length > 0
-    ? currentModel
-    : modelOptions[0]?.value;
-
-  return { modelOptions, selectedModel };
-};
-
-// 处理分组数据
-export const processGroupsData = (data, userGroup) => {
-  let groupOptions = Object.entries(data).map(([group, info]) => ({
-    label: info.desc.length > 20 ? info.desc.substring(0, 20) + '...' : info.desc,
-    value: group,
-    ratio: info.ratio,
-    fullLabel: info.desc,
-  }));
-
-  if (groupOptions.length === 0) {
-    groupOptions = [{
-      label: '用户分组',
-      value: '',
-      ratio: 1,
-    }];
-  } else if (userGroup) {
-    const userGroupIndex = groupOptions.findIndex(g => g.value === userGroup);
-    if (userGroupIndex > -1) {
-      const userGroupOption = groupOptions.splice(userGroupIndex, 1)[0];
-      groupOptions.unshift(userGroupOption);
-    }
-  }
-
-  return groupOptions;
-}; 

+ 0 - 201
web/src/utils/messageUtils.js

@@ -1,201 +0,0 @@
-import { THINK_TAG_REGEX, MESSAGE_ROLES } from './constants';
-
-// 生成唯一ID
-let messageId = 4;
-export const generateMessageId = () => `${messageId++}`;
-
-// 提取消息中的文本内容
-export const getTextContent = (message) => {
-  if (!message || !message.content) return '';
-
-  if (Array.isArray(message.content)) {
-    const textContent = message.content.find(item => item.type === 'text');
-    return textContent?.text || '';
-  }
-  return typeof message.content === 'string' ? message.content : '';
-};
-
-// 处理 think 标签
-export const processThinkTags = (content, reasoningContent = '') => {
-  if (!content || !content.includes('<think>')) {
-    return { content, reasoningContent };
-  }
-
-  const thoughts = [];
-  const replyParts = [];
-  let lastIndex = 0;
-  let match;
-
-  THINK_TAG_REGEX.lastIndex = 0;
-  while ((match = THINK_TAG_REGEX.exec(content)) !== null) {
-    replyParts.push(content.substring(lastIndex, match.index));
-    thoughts.push(match[1]);
-    lastIndex = match.index + match[0].length;
-  }
-  replyParts.push(content.substring(lastIndex));
-
-  const processedContent = replyParts.join('').replace(/<\/?think>/g, '').trim();
-  const thoughtsStr = thoughts.join('\n\n---\n\n');
-  const processedReasoningContent = reasoningContent && thoughtsStr
-    ? `${reasoningContent}\n\n---\n\n${thoughtsStr}`
-    : reasoningContent || thoughtsStr;
-
-  return {
-    content: processedContent,
-    reasoningContent: processedReasoningContent
-  };
-};
-
-// 处理未完成的 think 标签
-export const processIncompleteThinkTags = (content, reasoningContent = '') => {
-  if (!content) return { content: '', reasoningContent };
-
-  const lastOpenThinkIndex = content.lastIndexOf('<think>');
-  if (lastOpenThinkIndex === -1) {
-    return processThinkTags(content, reasoningContent);
-  }
-
-  const fragmentAfterLastOpen = content.substring(lastOpenThinkIndex);
-  if (!fragmentAfterLastOpen.includes('</think>')) {
-    const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim();
-    const cleanContent = content.substring(0, lastOpenThinkIndex);
-    const processedReasoningContent = unclosedThought
-      ? reasoningContent ? `${reasoningContent}\n\n---\n\n${unclosedThought}` : unclosedThought
-      : reasoningContent;
-
-    return processThinkTags(cleanContent, processedReasoningContent);
-  }
-
-  return processThinkTags(content, reasoningContent);
-};
-
-// 构建消息内容(包含图片)
-export const buildMessageContent = (textContent, imageUrls = [], imageEnabled = false) => {
-  if (!textContent && (!imageUrls || imageUrls.length === 0)) {
-    return '';
-  }
-
-  const validImageUrls = imageUrls.filter(url => url && url.trim() !== '');
-
-  if (imageEnabled && validImageUrls.length > 0) {
-    return [
-      { type: 'text', text: textContent || '' },
-      ...validImageUrls.map(url => ({
-        type: 'image_url',
-        image_url: { url: url.trim() }
-      }))
-    ];
-  }
-
-  return textContent || '';
-};
-
-// 创建新消息
-export const createMessage = (role, content, options = {}) => ({
-  role,
-  content,
-  createAt: Date.now(),
-  id: generateMessageId(),
-  ...options
-});
-
-// 创建加载中的助手消息
-export const createLoadingAssistantMessage = () => createMessage(
-  MESSAGE_ROLES.ASSISTANT,
-  '',
-  {
-    reasoningContent: '',
-    isReasoningExpanded: true,
-    isThinkingComplete: false,
-    hasAutoCollapsed: false,
-    status: 'loading'
-  }
-);
-
-// 检查消息是否包含图片
-export const hasImageContent = (message) => {
-  return message &&
-    Array.isArray(message.content) &&
-    message.content.some(item => item.type === 'image_url');
-};
-
-// 格式化消息用于API请求
-export const formatMessageForAPI = (message) => {
-  if (!message) return null;
-
-  return {
-    role: message.role,
-    content: message.content
-  };
-};
-
-// 验证消息是否有效
-export const isValidMessage = (message) => {
-  return message &&
-    message.role &&
-    (message.content || message.content === '');
-};
-
-// 获取最后一条用户消息
-export const getLastUserMessage = (messages) => {
-  if (!Array.isArray(messages)) return null;
-
-  for (let i = messages.length - 1; i >= 0; i--) {
-    if (messages[i].role === MESSAGE_ROLES.USER) {
-      return messages[i];
-    }
-  }
-  return null;
-};
-
-// 获取最后一条助手消息
-export const getLastAssistantMessage = (messages) => {
-  if (!Array.isArray(messages)) return null;
-
-  for (let i = messages.length - 1; i >= 0; i--) {
-    if (messages[i].role === MESSAGE_ROLES.ASSISTANT) {
-      return messages[i];
-    }
-  }
-  return null;
-};
-
-// 构建API请求负载(从apiUtils移动过来)
-export const buildApiPayload = (messages, systemPrompt, inputs, parameterEnabled) => {
-  const processedMessages = messages
-    .filter(isValidMessage)
-    .map(formatMessageForAPI)
-    .filter(Boolean);
-
-  // 如果有系统提示,插入到消息开头
-  if (systemPrompt && systemPrompt.trim()) {
-    processedMessages.unshift({
-      role: MESSAGE_ROLES.SYSTEM,
-      content: systemPrompt.trim()
-    });
-  }
-
-  const payload = {
-    model: inputs.model,
-    messages: processedMessages,
-    stream: inputs.stream,
-  };
-
-  // 添加启用的参数
-  const parameterMappings = {
-    temperature: 'temperature',
-    top_p: 'top_p',
-    max_tokens: 'max_tokens',
-    frequency_penalty: 'frequency_penalty',
-    presence_penalty: 'presence_penalty',
-    seed: 'seed'
-  };
-
-  Object.entries(parameterMappings).forEach(([key, param]) => {
-    if (parameterEnabled[key] && inputs[param] !== undefined && inputs[param] !== null) {
-      payload[param] = inputs[param];
-    }
-  });
-
-  return payload;
-}; 

+ 0 - 77
web/src/utils/rehypeSplitWordsIntoSpans.js

@@ -1,77 +0,0 @@
-import { visit } from 'unist-util-visit';
-
-/**
- * rehype 插件:将段落等文本节点拆分为逐词 <span>,并添加淡入动画 class。
- * 仅在流式渲染阶段使用,避免已渲染文字重复动画。
- */
-export function rehypeSplitWordsIntoSpans(options = {}) {
-  const { previousContentLength = 0 } = options;
-
-  return (tree) => {
-    let currentCharCount = 0; // 当前已处理的字符数
-
-    visit(tree, 'element', (node) => {
-      if (
-        ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'strong'].includes(node.tagName) &&
-        node.children
-      ) {
-        const newChildren = [];
-        node.children.forEach((child) => {
-          if (child.type === 'text') {
-            try {
-              // 使用 Intl.Segmenter 精准拆分中英文及标点
-              const segmenter = new Intl.Segmenter('zh', { granularity: 'word' });
-              const segments = segmenter.segment(child.value);
-
-              Array.from(segments)
-                .map((seg) => seg.segment)
-                .filter(Boolean)
-                .forEach((word) => {
-                  const wordStartPos = currentCharCount;
-                  const wordEndPos = currentCharCount + word.length;
-
-                  // 判断这个词是否是新增的(在 previousContentLength 之后)
-                  const isNewContent = wordStartPos >= previousContentLength;
-
-                  newChildren.push({
-                    type: 'element',
-                    tagName: 'span',
-                    properties: {
-                      className: isNewContent ? ['animate-fade-in'] : [],
-                    },
-                    children: [{ type: 'text', value: word }],
-                  });
-
-                  currentCharCount = wordEndPos;
-                });
-            } catch (_) {
-              // Fallback:如果浏览器不支持 Segmenter
-              const textStartPos = currentCharCount;
-              const isNewContent = textStartPos >= previousContentLength;
-
-              if (isNewContent) {
-                // 新内容,添加动画
-                newChildren.push({
-                  type: 'element',
-                  tagName: 'span',
-                  properties: {
-                    className: ['animate-fade-in'],
-                  },
-                  children: [{ type: 'text', value: child.value }],
-                });
-              } else {
-                // 旧内容,不添加动画
-                newChildren.push(child);
-              }
-
-              currentCharCount += child.value.length;
-            }
-          } else {
-            newChildren.push(child);
-          }
-        });
-        node.children = newChildren;
-      }
-    });
-  };
-}