diff --git a/web/src/components/RedemptionsTable.js b/web/src/components/table/RedemptionsTable.js
similarity index 98%
rename from web/src/components/RedemptionsTable.js
rename to web/src/components/table/RedemptionsTable.js
index f9fd344a..23813bbb 100644
--- a/web/src/components/RedemptionsTable.js
+++ b/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;
diff --git a/web/src/components/TaskLogsTable.js b/web/src/components/table/TaskLogsTable.js
similarity index 99%
rename from web/src/components/TaskLogsTable.js
rename to web/src/components/table/TaskLogsTable.js
index c4a8414b..791ae9ce 100644
--- a/web/src/components/TaskLogsTable.js
+++ b/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,
diff --git a/web/src/components/TokensTable.js b/web/src/components/table/TokensTable.js
similarity index 98%
rename from web/src/components/TokensTable.js
rename to web/src/components/table/TokensTable.js
index 92b74550..d4637953 100644
--- a/web/src/components/TokensTable.js
+++ b/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)}>;
diff --git a/web/src/components/UsersTable.js b/web/src/components/table/UsersTable.js
similarity index 98%
rename from web/src/components/UsersTable.js
rename to web/src/components/table/UsersTable.js
index e5085212..b5e5f7da 100644
--- a/web/src/components/UsersTable.js
+++ b/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;
diff --git a/web/src/components/utils.js b/web/src/components/utils.js
deleted file mode 100644
index 93a5fb85..00000000
--- a/web/src/components/utils.js
+++ /dev/null
@@ -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 [];
-}
diff --git a/web/src/constants/index.js b/web/src/constants/index.js
index 3f8c0232..9538a7dc 100644
--- a/web/src/constants/index.js
+++ b/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';
diff --git a/web/src/utils/constants.js b/web/src/constants/playground.constants.js
similarity index 100%
rename from web/src/utils/constants.js
rename to web/src/constants/playground.constants.js
diff --git a/web/src/context/Style/index.js b/web/src/context/Style/index.js
index 41d4633a..7bfe0ef7 100644
--- a/web/src/context/Style/index.js
+++ b/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 = {
diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js
index 84d2df1f..eb70cd27 100644
--- a/web/src/helpers/api.js
+++ b/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 [];
+}
diff --git a/web/src/helpers/auth-header.js b/web/src/helpers/auth-header.js
deleted file mode 100644
index f094dd1b..00000000
--- a/web/src/helpers/auth-header.js
+++ /dev/null
@@ -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 {};
- }
-}
diff --git a/web/src/helpers/auth.js b/web/src/helpers/auth.js
new file mode 100644
index 00000000..cb694ccf
--- /dev/null
+++ b/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
;
+ }
+
+ return children;
+};
+
+function PrivateRoute({ children }) {
+ if (!localStorage.getItem('user')) {
+ return
;
+ }
+ return children;
+}
+
+export { PrivateRoute };
diff --git a/web/src/helpers/index.js b/web/src/helpers/index.js
index ac164960..1524afbe 100644
--- a/web/src/helpers/index.js
+++ b/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';
diff --git a/web/src/helpers/other.js b/web/src/helpers/log.js
similarity index 98%
rename from web/src/helpers/other.js
rename to web/src/helpers/log.js
index c5d8c269..ffbe0d74 100644
--- a/web/src/helpers/other.js
+++ b/web/src/helpers/log.js
@@ -4,4 +4,4 @@ export function getLogOther(otherStr) {
}
let other = JSON.parse(otherStr);
return other;
-}
+}
\ No newline at end of file
diff --git a/web/src/helpers/render.js b/web/src/helpers/render.js
index fa1de023..c8302feb 100644
--- a/web/src/helpers/render.js
+++ b/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(
{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),
+ },
+ )}
{i18next.t('仅供参考,以实际扣费为准')}
@@ -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(
{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),
+ },
+ )}
{i18next.t(
@@ -1024,33 +1025,33 @@ export function renderClaudeModelPrice(
{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),
+ },
+ )}
{i18next.t('仅供参考,以实际扣费为准')}
@@ -1128,3 +1129,79 @@ export function renderClaudeModelPriceSimple(
}
}
}
+
+/**
+ * rehype 插件:将段落等文本节点拆分为逐词
,并添加淡入动画 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;
+ }
+ });
+ };
+}
\ No newline at end of file
diff --git a/web/src/helpers/token.js b/web/src/helpers/token.js
new file mode 100644
index 00000000..ecdeaa3a
--- /dev/null
+++ b/web/src/helpers/token.js
@@ -0,0 +1,45 @@
+import { API } from './api';
+
+/**
+ * 获取可用的token keys
+ * @returns {Promise} 返回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;
+}
\ No newline at end of file
diff --git a/web/src/helpers/utils.js b/web/src/helpers/utils.js
index 35f20c89..cd05653e 100644
--- a/web/src/helpers/utils.js
+++ b/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 ;
@@ -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('')) {
+ 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('');
+ if (lastOpenThinkIndex === -1) {
+ return processThinkTags(content, reasoningContent);
+ }
+
+ const fragmentAfterLastOpen = content.substring(lastOpenThinkIndex);
+ if (!fragmentAfterLastOpen.includes('')) {
+ const unclosedThought = fragmentAfterLastOpen.substring(''.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;
+};
diff --git a/web/src/hooks/useApiRequest.js b/web/src/hooks/useApiRequest.js
index 55688726..4a3b7c3a 100644
--- a/web/src/hooks/useApiRequest.js
+++ b/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,
diff --git a/web/src/hooks/useDataLoader.js b/web/src/hooks/useDataLoader.js
index 2454b007..83d53199 100644
--- a/web/src/hooks/useDataLoader.js
+++ b/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,
diff --git a/web/src/hooks/useMessageActions.js b/web/src/hooks/useMessageActions.js
index 84d94d4d..4cfcf9f1 100644
--- a/web/src/hooks/useMessageActions.js
+++ b/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();
diff --git a/web/src/hooks/useMessageEdit.js b/web/src/hooks/useMessageEdit.js
index 513f2b32..479524b6 100644
--- a/web/src/hooks/useMessageEdit.js
+++ b/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,
diff --git a/web/src/hooks/usePlaygroundState.js b/web/src/hooks/usePlaygroundState.js
index 9e76aa2e..e8c4727d 100644
--- a/web/src/hooks/usePlaygroundState.js
+++ b/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 = () => {
// 使用惰性初始化,确保只在组件首次挂载时加载配置和消息
diff --git a/web/src/hooks/useSetupCheck.js b/web/src/hooks/useSetupCheck.js
new file mode 100644
index 00000000..d2233de8
--- /dev/null
+++ b/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
+ };
+}
\ No newline at end of file
diff --git a/web/src/hooks/useSyncMessageAndCustomBody.js b/web/src/hooks/useSyncMessageAndCustomBody.js
index 36e5fc67..6f0c19ad 100644
--- a/web/src/hooks/useSyncMessageAndCustomBody.js
+++ b/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,
diff --git a/web/src/hooks/useTokenKeys.js b/web/src/hooks/useTokenKeys.js
new file mode 100644
index 00000000..a6583591
--- /dev/null
+++ b/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 };
+}
\ No newline at end of file
diff --git a/web/src/index.js b/web/src/index.js
index 1f180bbd..0f57f5a1 100644
--- a/web/src/index.js
+++ b/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
diff --git a/web/src/pages/Channel/EditChannel.js b/web/src/pages/Channel/EditChannel.js
index 810df9dc..feed8beb 100644
--- a/web/src/pages/Channel/EditChannel.js
+++ b/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,
diff --git a/web/src/pages/Channel/EditTagModal.js b/web/src/pages/Channel/EditTagModal.js
index 5ce05bec..52dd4bbb 100644
--- a/web/src/pages/Channel/EditTagModal.js
+++ b/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;
diff --git a/web/src/pages/Channel/index.js b/web/src/pages/Channel/index.js
index 523ea27c..dc9f8c48 100644
--- a/web/src/pages/Channel/index.js
+++ b/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 (
diff --git a/web/src/pages/Chat/index.js b/web/src/pages/Chat/index.js
index 77ece906..bd4e19a1 100644
--- a/web/src/pages/Chat/index.js
+++ b/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';
diff --git a/web/src/pages/Chat2Link/index.js b/web/src/pages/Chat2Link/index.js
index a7b5e6ae..553dab3f 100644
--- a/web/src/pages/Chat2Link/index.js
+++ b/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();
diff --git a/web/src/pages/Detail/index.js b/web/src/pages/Detail/index.js
index 44cbd857..8dfdbe97 100644
--- a/web/src/pages/Detail/index.js
+++ b/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';
diff --git a/web/src/pages/Home/index.js b/web/src/pages/Home/index.js
index f676fe69..4e8a61d6 100644
--- a/web/src/pages/Home/index.js
+++ b/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;
diff --git a/web/src/pages/Log/index.js b/web/src/pages/Log/index.js
index 4ee109ca..4b6f8ba3 100644
--- a/web/src/pages/Log/index.js
+++ b/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 = () => (
<>
diff --git a/web/src/pages/Midjourney/index.js b/web/src/pages/Midjourney/index.js
index ed22ecd0..7c605bd4 100644
--- a/web/src/pages/Midjourney/index.js
+++ b/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 = () => (
<>
diff --git a/web/src/pages/Playground/index.js b/web/src/pages/Playground/index.js
index 3b890259..8940dee3 100644
--- a/web/src/pages/Playground/index.js
+++ b/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 {
diff --git a/web/src/pages/Pricing/index.js b/web/src/pages/Pricing/index.js
index cb56a477..21fd7256 100644
--- a/web/src/pages/Pricing/index.js
+++ b/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 = () => (
<>
diff --git a/web/src/pages/Redemption/EditRedemption.js b/web/src/pages/Redemption/EditRedemption.js
index 1529a1cd..e720f90c 100644
--- a/web/src/pages/Redemption/EditRedemption.js
+++ b/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,
diff --git a/web/src/pages/Redemption/index.js b/web/src/pages/Redemption/index.js
index f877b601..0154d979 100644
--- a/web/src/pages/Redemption/index.js
+++ b/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 (
diff --git a/web/src/pages/Setting/Operation/ModelRationNotSetEditor.js b/web/src/pages/Setting/Operation/ModelRationNotSetEditor.js
index 496208f4..d5d8d832 100644
--- a/web/src/pages/Setting/Operation/ModelRationNotSetEditor.js
+++ b/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) {
diff --git a/web/src/pages/Setting/Operation/ModelSettingsVisualEditor.js b/web/src/pages/Setting/Operation/ModelSettingsVisualEditor.js
index 409aa13f..85426a5e 100644
--- a/web/src/pages/Setting/Operation/ModelSettingsVisualEditor.js
+++ b/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) {
model.name === currentModel.name)
+ currentModel.name &&
+ models.some((model) => model.name === currentModel.name)
? t('编辑模型')
: t('添加模型')
}
diff --git a/web/src/pages/Setting/index.js b/web/src/pages/Setting/index.js
index 0619ab9a..056fc207 100644
--- a/web/src/pages/Setting/index.js
+++ b/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();
diff --git a/web/src/pages/Task/index.js b/web/src/pages/Task/index.js
index 47b541b6..05326719 100644
--- a/web/src/pages/Task/index.js
+++ b/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 = () => (
<>
diff --git a/web/src/pages/Token/EditToken.js b/web/src/pages/Token/EditToken.js
index ad8c11c8..946164da 100644
--- a/web/src/pages/Token/EditToken.js
+++ b/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,
diff --git a/web/src/pages/Token/index.js b/web/src/pages/Token/index.js
index eea68941..d63247a4 100644
--- a/web/src/pages/Token/index.js
+++ b/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 (
diff --git a/web/src/pages/TopUp/index.js b/web/src/pages/TopUp/index.js
index d2e16cfc..2f920647 100644
--- a/web/src/pages/TopUp/index.js
+++ b/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 (
-
+
{
return (
diff --git a/web/src/utils/apiUtils.js b/web/src/utils/apiUtils.js
deleted file mode 100644
index 77f775fd..00000000
--- a/web/src/utils/apiUtils.js
+++ /dev/null
@@ -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;
-};
\ No newline at end of file
diff --git a/web/src/utils/messageUtils.js b/web/src/utils/messageUtils.js
deleted file mode 100644
index 0ab23315..00000000
--- a/web/src/utils/messageUtils.js
+++ /dev/null
@@ -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('')) {
- 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('');
- if (lastOpenThinkIndex === -1) {
- return processThinkTags(content, reasoningContent);
- }
-
- const fragmentAfterLastOpen = content.substring(lastOpenThinkIndex);
- if (!fragmentAfterLastOpen.includes('')) {
- const unclosedThought = fragmentAfterLastOpen.substring(''.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;
-};
\ No newline at end of file
diff --git a/web/src/utils/rehypeSplitWordsIntoSpans.js b/web/src/utils/rehypeSplitWordsIntoSpans.js
deleted file mode 100644
index a9bb6db3..00000000
--- a/web/src/utils/rehypeSplitWordsIntoSpans.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import { visit } from 'unist-util-visit';
-
-/**
- * rehype 插件:将段落等文本节点拆分为逐词 ,并添加淡入动画 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;
- }
- });
- };
-}
\ No newline at end of file