Browse Source

♻️ refactor(helpers): refactor the helpers folder and related imports

Apple\Apple 7 months ago
parent
commit
64b565dc15
39 changed files with 523 additions and 589 deletions
  1. 4 6
      web/src/components/ChannelsTable.js
  2. 1 2
      web/src/components/HeaderBar.js
  3. 1 1
      web/src/components/LoginForm.js
  4. 12 14
      web/src/components/LogsTable.js
  5. 1 2
      web/src/components/OAuth2Callback.js
  6. 1 2
      web/src/components/PageLayout.js
  7. 4 6
      web/src/components/PersonalSetting.js
  8. 1 1
      web/src/components/RedemptionsTable.js
  9. 1 1
      web/src/components/RegisterForm.js
  10. 1 7
      web/src/components/SiderBar.js
  11. 3 3
      web/src/components/SystemSetting.js
  12. 2 1
      web/src/components/TokensTable.js
  13. 1 2
      web/src/components/UsersTable.js
  14. 1 2
      web/src/components/common/markdown/MarkdownRenderer.js
  15. 1 1
      web/src/components/playground/CodeViewer.js
  16. 1 1
      web/src/components/playground/SettingsPanel.js
  17. 1 1
      web/src/context/Style/index.js
  18. 106 0
      web/src/helpers/api.js
  19. 0 105
      web/src/helpers/apiUtils.js
  20. 0 0
      web/src/helpers/auth.js
  21. 4 5
      web/src/helpers/index.js
  22. 0 0
      web/src/helpers/log.js
  23. 0 201
      web/src/helpers/messageUtils.js
  24. 178 101
      web/src/helpers/render.js
  25. 0 77
      web/src/helpers/textAnimationUtils.js
  26. 163 0
      web/src/helpers/utils.js
  27. 3 6
      web/src/hooks/useApiRequest.js
  28. 1 2
      web/src/hooks/useDataLoader.js
  29. 1 1
      web/src/hooks/useMessageActions.js
  30. 1 1
      web/src/hooks/useMessageEdit.js
  31. 1 1
      web/src/hooks/usePlaygroundState.js
  32. 2 4
      web/src/pages/Detail/index.js
  33. 4 5
      web/src/pages/Playground/index.js
  34. 2 4
      web/src/pages/Redemption/EditRedemption.js
  35. 1 2
      web/src/pages/Setting/Operation/ModelRationNotSetEditor.js
  36. 10 15
      web/src/pages/Setting/Operation/ModelSettingsVisualEditor.js
  37. 2 1
      web/src/pages/Token/EditToken.js
  38. 6 3
      web/src/pages/TopUp/index.js
  39. 1 2
      web/src/pages/User/EditUser.js

+ 4 - 6
web/src/components/ChannelsTable.js

@@ -5,14 +5,12 @@ import {
   showInfo,
   showSuccess,
   timestamp2string,
+  renderGroup,
+  renderNumberWithPoint,
+  renderQuota
 } from '../helpers';
 
 import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants';
-import {
-  renderGroup,
-  renderNumberWithPoint,
-  renderQuota,
-} from '../helpers/render';
 import {
   Button,
   Divider,
@@ -29,7 +27,7 @@ import {
   Typography,
   Checkbox,
   Card,
-  Select,
+  Select
 } from '@douyinfe/semi-ui';
 import EditChannel from '../pages/Channel/EditChannel';
 import {

+ 1 - 2
web/src/components/HeaderBar.js

@@ -3,7 +3,7 @@ import { Link, useNavigate, useLocation } from 'react-router-dom';
 import { UserContext } from '../context/User';
 import { useSetTheme, useTheme } from '../context/Theme';
 import { useTranslation } from 'react-i18next';
-import { API, getLogo, getSystemName, showSuccess } from '../helpers';
+import { API, getLogo, getSystemName, showSuccess, stringToColor } from '../helpers';
 import fireworks from 'react-fireworks';
 import { CN, GB } from 'country-flag-icons/react/3x2';
 import NoticeModal from './NoticeModal';
@@ -29,7 +29,6 @@ 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';
 

+ 1 - 1
web/src/components/LoginForm.js

@@ -9,6 +9,7 @@ import {
   showSuccess,
   updateAPI,
   getSystemName,
+  setUserData
 } from '../helpers';
 import {
   onGitHubOAuthClicked,
@@ -31,7 +32,6 @@ 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 { useTranslation } from 'react-i18next';
 import Background from '../images/example.png';

+ 12 - 14
web/src/components/LogsTable.js

@@ -8,6 +8,18 @@ import {
   showError,
   showSuccess,
   timestamp2string,
+  renderAudioModelPrice,
+  renderClaudeLogContent,
+  renderClaudeModelPrice,
+  renderClaudeModelPriceSimple,
+  renderGroup,
+  renderLogContent,
+  renderModelPrice,
+  renderModelPriceSimple,
+  renderNumber,
+  renderQuota,
+  stringToColor,
+  getLogOther
 } from '../helpers';
 
 import {
@@ -30,21 +42,7 @@ import {
   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 Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
-import { getLogOther } from '../helpers/logUtils.js';
 import {
   IconRefresh,
   IconSetting,

+ 1 - 2
web/src/components/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 { API, showError, showSuccess, updateAPI, setUserData } from '../helpers';
 import { UserContext } from '../context/User';
-import { setUserData } from '../helpers/data.js';
 
 const OAuth2Callback = (props) => {
   const [searchParams, setSearchParams] = useSearchParams();

+ 1 - 2
web/src/components/PageLayout.js

@@ -7,8 +7,7 @@ import { ToastContainer } from 'react-toastify';
 import React, { useContext, useEffect } from 'react';
 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 { API, getLogo, getSystemName, showError, setStatusData } from '../helpers';
 import { UserContext } from '../context/User/index.js';
 import { StatusContext } from '../context/Status/index.js';
 import { useLocation } from 'react-router-dom';

+ 4 - 6
web/src/components/PersonalSetting.js

@@ -8,6 +8,10 @@ import {
   showError,
   showInfo,
   showSuccess,
+  getQuotaPerUnit,
+  renderQuota,
+  renderQuotaWithPrompt,
+  stringToColor
 } from '../helpers';
 import Turnstile from 'react-turnstile';
 import { UserContext } from '../context/User';
@@ -54,12 +58,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';
 

+ 1 - 1
web/src/components/RedemptionsTable.js

@@ -5,10 +5,10 @@ import {
   showError,
   showSuccess,
   timestamp2string,
+  renderQuota
 } from '../helpers';
 
 import { ITEMS_PER_PAGE } from '../constants';
-import { renderQuota } from '../helpers/render';
 import {
   Button,
   Card,

+ 1 - 1
web/src/components/RegisterForm.js

@@ -8,6 +8,7 @@ import {
   showSuccess,
   updateAPI,
   getSystemName,
+  setUserData
 } from '../helpers';
 import Turnstile from 'react-turnstile';
 import {
@@ -30,7 +31,6 @@ 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 { useTranslation } from 'react-i18next';
 import Background from '../images/example.png';

+ 1 - 7
web/src/components/SiderBar.js

@@ -5,12 +5,8 @@ import { StatusContext } from '../context/Status';
 import { useTranslation } from 'react-i18next';
 
 import {
-  API,
-  getLogo,
-  getSystemName,
   isAdmin,
-  isMobile,
-  showError,
+  showError
 } from '../helpers';
 import '../index.css';
 
@@ -39,8 +35,6 @@ import {
   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 Text from '@douyinfe/semi-ui/lib/es/typography/text';

+ 3 - 3
web/src/components/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 = () => {

+ 2 - 1
web/src/components/TokensTable.js

@@ -6,10 +6,11 @@ import {
   showError,
   showSuccess,
   timestamp2string,
+  renderGroup,
+  renderQuota
 } from '../helpers';
 
 import { ITEMS_PER_PAGE } from '../constants';
-import { renderGroup, renderQuota } from '../helpers/render';
 import {
   Button,
   Card,

+ 1 - 2
web/src/components/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,
@@ -26,7 +26,6 @@ import {
   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 { useTranslation } from 'react-i18next';

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

@@ -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 '../../../helpers/textAnimationUtils';
 
 mermaid.initialize({
   startOnLoad: false,

+ 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/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 = {

+ 106 - 0
web/src/helpers/api.js

@@ -1,5 +1,6 @@
 import { getUserIdFromLocalStorage, showError } from './utils';
 import axios from 'axios';
+import { formatMessageForAPI } from './index.js';
 
 export let API = axios.create({
   baseURL: import.meta.env.VITE_REACT_APP_SERVER_URL
@@ -29,3 +30,108 @@ 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;
+}; 

+ 0 - 105
web/src/helpers/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 - 0
web/src/helpers/authUtils.js → web/src/helpers/auth.js


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

@@ -1,8 +1,7 @@
 export * from './history';
-export * from './authUtils';
+export * from './auth';
 export * from './utils';
 export * from './api';
-export * from './apiUtils';
-export * from './messageUtils';
-export * from './textAnimationUtils';
-export * from './logUtils';
+export * from './render';
+export * from './log';
+export * from './data';

+ 0 - 0
web/src/helpers/logUtils.js → web/src/helpers/log.js


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

@@ -1,201 +0,0 @@
-import { THINK_TAG_REGEX, MESSAGE_ROLES } from '../constants/playground.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;
-}; 

+ 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 './index.js';
+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;
+      }
+    });
+  };
+} 

+ 0 - 77
web/src/helpers/textAnimationUtils.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;
-      }
-    });
-  };
-} 

+ 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;
+};

+ 3 - 6
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 '../constants/playground.constants';
 import {
-  buildApiPayload,
-  handleApiError
-} from '../helpers/apiUtils';
-import {
+  getUserIdFromLocalStorage,
+  handleApiError,
   processThinkTags,
   processIncompleteThinkTags
-} from '../helpers/messageUtils';
+} from '../helpers';
 
 export const useApiRequest = (
   setMessage,

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

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

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

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

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

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

+ 1 - 1
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 '../constants/playground.constants';
 import { loadConfig, saveConfig, loadMessages, saveMessages } from '../components/playground/configStorage';
-import { processIncompleteThinkTags } from '../helpers/messageUtils';
+import { processIncompleteThinkTags } from '../helpers';
 
 export const usePlaygroundState = () => {
   // 使用惰性初始化,确保只在组件首次挂载时加载配置和消息

+ 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';
 

+ 4 - 5
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 '../../constants/playground.constants.js';
 import {
+  getLogo,
+  stringToColor,
   buildMessageContent,
   createMessage,
   createLoadingAssistantMessage,
   getTextContent,
   buildApiPayload
-} from '../../helpers/messageUtils.js';
+} from '../../helpers';
 
 // Components
 import {

+ 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 - 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('添加模型')
         }

+ 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,

+ 6 - 3
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,

+ 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,