mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-19 06:07:28 +00:00
- Add SSEViewer component for interactive SSE message inspection * Display SSE data stream with collapsible panels * Show parsed JSON with syntax highlighting * Display key information badges (content, tokens, finish reason) * Support copy individual or all SSE messages * Show error messages with detailed information - Support Ctrl+V to paste images in chat input * Enable image paste in CustomInputRender component * Auto-detect and add pasted images to image list * Show toast notifications for paste results - Add complete i18n support for 6 languages * Chinese (zh): Complete translations * English (en): Complete translations * Japanese (ja): Add 28 new translations * French (fr): Add 28 new translations * Russian (ru): Add 28 new translations * Vietnamese (vi): Add 32 new translations - Update .gitignore to exclude data directory
563 lines
17 KiB
JavaScript
563 lines
17 KiB
JavaScript
/*
|
||
Copyright (C) 2025 QuantumNous
|
||
|
||
This program is free software: you can redistribute it and/or modify
|
||
it under the terms of the GNU Affero General Public License as
|
||
published by the Free Software Foundation, either version 3 of the
|
||
License, or (at your option) any later version.
|
||
|
||
This program is distributed in the hope that it will be useful,
|
||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
GNU Affero General Public License for more details.
|
||
|
||
You should have received a copy of the GNU Affero General Public License
|
||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
|
||
For commercial licensing, please contact support@quantumnous.com
|
||
*/
|
||
|
||
import React, { useContext, useEffect, useCallback, useRef } from 'react';
|
||
import { useSearchParams } from 'react-router-dom';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { Layout, Toast, Modal } from '@douyinfe/semi-ui';
|
||
|
||
// Context
|
||
import { UserContext } from '../../context/User';
|
||
import { useIsMobile } from '../../hooks/common/useIsMobile';
|
||
|
||
// hooks
|
||
import { usePlaygroundState } from '../../hooks/playground/usePlaygroundState';
|
||
import { useMessageActions } from '../../hooks/playground/useMessageActions';
|
||
import { useApiRequest } from '../../hooks/playground/useApiRequest';
|
||
import { useSyncMessageAndCustomBody } from '../../hooks/playground/useSyncMessageAndCustomBody';
|
||
import { useMessageEdit } from '../../hooks/playground/useMessageEdit';
|
||
import { useDataLoader } from '../../hooks/playground/useDataLoader';
|
||
|
||
// Constants and utils
|
||
import {
|
||
MESSAGE_ROLES,
|
||
ERROR_MESSAGES,
|
||
} from '../../constants/playground.constants';
|
||
import {
|
||
getLogo,
|
||
stringToColor,
|
||
buildMessageContent,
|
||
createMessage,
|
||
createLoadingAssistantMessage,
|
||
getTextContent,
|
||
buildApiPayload,
|
||
encodeToBase64,
|
||
} from '../../helpers';
|
||
|
||
// Components
|
||
import {
|
||
OptimizedSettingsPanel,
|
||
OptimizedDebugPanel,
|
||
OptimizedMessageContent,
|
||
OptimizedMessageActions,
|
||
} from '../../components/playground/OptimizedComponents';
|
||
import ChatArea from '../../components/playground/ChatArea';
|
||
import FloatingButtons from '../../components/playground/FloatingButtons';
|
||
import { PlaygroundProvider } from '../../contexts/PlaygroundContext';
|
||
|
||
// 生成头像
|
||
const generateAvatarDataUrl = (username) => {
|
||
if (!username) {
|
||
return 'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/docs-icon.png';
|
||
}
|
||
const firstLetter = username[0].toUpperCase();
|
||
const bgColor = stringToColor(username);
|
||
const svg = `
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||
<circle cx="16" cy="16" r="16" fill="${bgColor}" />
|
||
<text x="50%" y="50%" dominant-baseline="central" text-anchor="middle" font-size="16" fill="#ffffff" font-family="sans-serif">${firstLetter}</text>
|
||
</svg>
|
||
`;
|
||
return `data:image/svg+xml;base64,${encodeToBase64(svg)}`;
|
||
};
|
||
|
||
const Playground = () => {
|
||
const { t } = useTranslation();
|
||
const [userState] = useContext(UserContext);
|
||
const isMobile = useIsMobile();
|
||
const styleState = { isMobile };
|
||
const [searchParams] = useSearchParams();
|
||
|
||
const state = usePlaygroundState();
|
||
const {
|
||
inputs,
|
||
parameterEnabled,
|
||
showDebugPanel,
|
||
customRequestMode,
|
||
customRequestBody,
|
||
showSettings,
|
||
models,
|
||
groups,
|
||
status,
|
||
message,
|
||
debugData,
|
||
activeDebugTab,
|
||
previewPayload,
|
||
sseSourceRef,
|
||
chatRef,
|
||
handleInputChange,
|
||
handleParameterToggle,
|
||
debouncedSaveConfig,
|
||
saveMessagesImmediately,
|
||
handleConfigImport,
|
||
handleConfigReset,
|
||
setShowSettings,
|
||
setModels,
|
||
setGroups,
|
||
setStatus,
|
||
setMessage,
|
||
setDebugData,
|
||
setActiveDebugTab,
|
||
setPreviewPayload,
|
||
setShowDebugPanel,
|
||
setCustomRequestMode,
|
||
setCustomRequestBody,
|
||
} = state;
|
||
|
||
// API 请求相关
|
||
const { sendRequest, onStopGenerator } = useApiRequest(
|
||
setMessage,
|
||
setDebugData,
|
||
setActiveDebugTab,
|
||
sseSourceRef,
|
||
saveMessagesImmediately,
|
||
);
|
||
|
||
// 数据加载
|
||
useDataLoader(userState, inputs, handleInputChange, setModels, setGroups);
|
||
|
||
// 消息编辑
|
||
const {
|
||
editingMessageId,
|
||
editValue,
|
||
setEditValue,
|
||
handleMessageEdit,
|
||
handleEditSave,
|
||
handleEditCancel,
|
||
} = useMessageEdit(
|
||
setMessage,
|
||
inputs,
|
||
parameterEnabled,
|
||
sendRequest,
|
||
saveMessagesImmediately,
|
||
);
|
||
|
||
// 消息和自定义请求体同步
|
||
const { syncMessageToCustomBody, syncCustomBodyToMessage } =
|
||
useSyncMessageAndCustomBody(
|
||
customRequestMode,
|
||
customRequestBody,
|
||
message,
|
||
inputs,
|
||
setCustomRequestBody,
|
||
setMessage,
|
||
debouncedSaveConfig,
|
||
);
|
||
|
||
// 角色信息
|
||
const roleInfo = {
|
||
user: {
|
||
name: userState?.user?.username || 'User',
|
||
avatar: generateAvatarDataUrl(userState?.user?.username),
|
||
},
|
||
assistant: {
|
||
name: 'Assistant',
|
||
avatar: getLogo(),
|
||
},
|
||
system: {
|
||
name: 'System',
|
||
avatar: getLogo(),
|
||
},
|
||
};
|
||
|
||
// 消息操作
|
||
const messageActions = useMessageActions(
|
||
message,
|
||
setMessage,
|
||
onMessageSend,
|
||
saveMessagesImmediately,
|
||
);
|
||
|
||
// 构建预览请求体
|
||
const constructPreviewPayload = useCallback(() => {
|
||
try {
|
||
// 如果是自定义请求体模式且有自定义内容,直接返回解析后的自定义请求体
|
||
if (customRequestMode && customRequestBody && customRequestBody.trim()) {
|
||
try {
|
||
return JSON.parse(customRequestBody);
|
||
} catch (parseError) {
|
||
console.warn('自定义请求体JSON解析失败,回退到默认预览:', parseError);
|
||
}
|
||
}
|
||
|
||
// 默认预览逻辑
|
||
let messages = [...message];
|
||
|
||
// 如果存在用户消息
|
||
if (
|
||
!(
|
||
messages.length === 0 ||
|
||
messages.every((msg) => msg.role !== MESSAGE_ROLES.USER)
|
||
)
|
||
) {
|
||
// 处理最后一个用户消息的图片
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
if (messages[i].role === MESSAGE_ROLES.USER) {
|
||
if (inputs.imageEnabled && inputs.imageUrls) {
|
||
const validImageUrls = inputs.imageUrls.filter(
|
||
(url) => url.trim() !== '',
|
||
);
|
||
if (validImageUrls.length > 0) {
|
||
const textContent = getTextContent(messages[i]) || '示例消息';
|
||
const content = buildMessageContent(
|
||
textContent,
|
||
validImageUrls,
|
||
true,
|
||
);
|
||
messages[i] = { ...messages[i], content };
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
return buildApiPayload(messages, null, inputs, parameterEnabled);
|
||
} catch (error) {
|
||
console.error('构造预览请求体失败:', error);
|
||
return null;
|
||
}
|
||
}, [inputs, parameterEnabled, message, customRequestMode, customRequestBody]);
|
||
|
||
// 发送消息
|
||
function onMessageSend(content, attachment) {
|
||
console.log('attachment: ', attachment);
|
||
|
||
// 创建用户消息和加载消息
|
||
const userMessage = createMessage(MESSAGE_ROLES.USER, content);
|
||
const loadingMessage = createLoadingAssistantMessage();
|
||
|
||
// 如果是自定义请求体模式
|
||
if (customRequestMode && customRequestBody) {
|
||
try {
|
||
const customPayload = JSON.parse(customRequestBody);
|
||
|
||
setMessage((prevMessage) => {
|
||
const newMessages = [...prevMessage, userMessage, loadingMessage];
|
||
|
||
// 发送自定义请求体
|
||
sendRequest(customPayload, customPayload.stream !== false);
|
||
|
||
// 发送消息后保存,传入新消息列表
|
||
setTimeout(() => saveMessagesImmediately(newMessages), 0);
|
||
|
||
return newMessages;
|
||
});
|
||
return;
|
||
} catch (error) {
|
||
console.error('自定义请求体JSON解析失败:', error);
|
||
Toast.error(ERROR_MESSAGES.JSON_PARSE_ERROR);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 默认模式
|
||
const validImageUrls = inputs.imageUrls.filter((url) => url.trim() !== '');
|
||
const messageContent = buildMessageContent(
|
||
content,
|
||
validImageUrls,
|
||
inputs.imageEnabled,
|
||
);
|
||
const userMessageWithImages = createMessage(
|
||
MESSAGE_ROLES.USER,
|
||
messageContent,
|
||
);
|
||
|
||
setMessage((prevMessage) => {
|
||
const newMessages = [...prevMessage, userMessageWithImages];
|
||
|
||
const payload = buildApiPayload(
|
||
newMessages,
|
||
null,
|
||
inputs,
|
||
parameterEnabled,
|
||
);
|
||
sendRequest(payload, inputs.stream);
|
||
|
||
// 禁用图片模式
|
||
if (inputs.imageEnabled) {
|
||
setTimeout(() => {
|
||
handleInputChange('imageEnabled', false);
|
||
}, 100);
|
||
}
|
||
|
||
// 发送消息后保存,传入新消息列表(包含用户消息和加载消息)
|
||
const messagesWithLoading = [...newMessages, loadingMessage];
|
||
setTimeout(() => saveMessagesImmediately(messagesWithLoading), 0);
|
||
|
||
return messagesWithLoading;
|
||
});
|
||
}
|
||
|
||
// 切换推理展开状态
|
||
const toggleReasoningExpansion = useCallback(
|
||
(messageId) => {
|
||
setMessage((prevMessages) =>
|
||
prevMessages.map((msg) =>
|
||
msg.id === messageId && msg.role === MESSAGE_ROLES.ASSISTANT
|
||
? { ...msg, isReasoningExpanded: !msg.isReasoningExpanded }
|
||
: msg,
|
||
),
|
||
);
|
||
},
|
||
[setMessage],
|
||
);
|
||
|
||
// 渲染函数
|
||
const renderCustomChatContent = useCallback(
|
||
({ message, className }) => {
|
||
const isCurrentlyEditing = editingMessageId === message.id;
|
||
|
||
return (
|
||
<OptimizedMessageContent
|
||
message={message}
|
||
className={className}
|
||
styleState={styleState}
|
||
onToggleReasoningExpansion={toggleReasoningExpansion}
|
||
isEditing={isCurrentlyEditing}
|
||
onEditSave={handleEditSave}
|
||
onEditCancel={handleEditCancel}
|
||
editValue={editValue}
|
||
onEditValueChange={setEditValue}
|
||
/>
|
||
);
|
||
},
|
||
[
|
||
styleState,
|
||
editingMessageId,
|
||
editValue,
|
||
handleEditSave,
|
||
handleEditCancel,
|
||
setEditValue,
|
||
toggleReasoningExpansion,
|
||
],
|
||
);
|
||
|
||
const renderChatBoxAction = useCallback(
|
||
(props) => {
|
||
const { message: currentMessage } = props;
|
||
const isAnyMessageGenerating = message.some(
|
||
(msg) => msg.status === 'loading' || msg.status === 'incomplete',
|
||
);
|
||
const isCurrentlyEditing = editingMessageId === currentMessage.id;
|
||
|
||
return (
|
||
<OptimizedMessageActions
|
||
message={currentMessage}
|
||
styleState={styleState}
|
||
onMessageReset={messageActions.handleMessageReset}
|
||
onMessageCopy={messageActions.handleMessageCopy}
|
||
onMessageDelete={messageActions.handleMessageDelete}
|
||
onRoleToggle={messageActions.handleRoleToggle}
|
||
onMessageEdit={handleMessageEdit}
|
||
isAnyMessageGenerating={isAnyMessageGenerating}
|
||
isEditing={isCurrentlyEditing}
|
||
/>
|
||
);
|
||
},
|
||
[messageActions, styleState, message, editingMessageId, handleMessageEdit],
|
||
);
|
||
|
||
// Effects
|
||
|
||
// 同步消息和自定义请求体
|
||
useEffect(() => {
|
||
syncMessageToCustomBody();
|
||
}, [message, syncMessageToCustomBody]);
|
||
|
||
useEffect(() => {
|
||
syncCustomBodyToMessage();
|
||
}, [customRequestBody, syncCustomBodyToMessage]);
|
||
|
||
// 处理URL参数
|
||
useEffect(() => {
|
||
if (searchParams.get('expired')) {
|
||
Toast.warning(t('登录过期,请重新登录!'));
|
||
}
|
||
}, [searchParams, t]);
|
||
|
||
// Playground 组件无需再监听窗口变化,isMobile 由 useIsMobile Hook 自动更新
|
||
|
||
// 构建预览payload
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => {
|
||
const preview = constructPreviewPayload();
|
||
setPreviewPayload(preview);
|
||
setDebugData((prev) => ({
|
||
...prev,
|
||
previewRequest: preview ? JSON.stringify(preview, null, 2) : null,
|
||
previewTimestamp: preview ? new Date().toISOString() : null,
|
||
}));
|
||
}, 300);
|
||
|
||
return () => clearTimeout(timer);
|
||
}, [
|
||
message,
|
||
inputs,
|
||
parameterEnabled,
|
||
customRequestMode,
|
||
customRequestBody,
|
||
constructPreviewPayload,
|
||
setPreviewPayload,
|
||
setDebugData,
|
||
]);
|
||
|
||
// 自动保存配置
|
||
useEffect(() => {
|
||
debouncedSaveConfig();
|
||
}, [
|
||
inputs,
|
||
parameterEnabled,
|
||
showDebugPanel,
|
||
customRequestMode,
|
||
customRequestBody,
|
||
debouncedSaveConfig,
|
||
]);
|
||
|
||
// 清空对话的处理函数
|
||
const handleClearMessages = useCallback(() => {
|
||
setMessage([]);
|
||
// 清空对话后保存,传入空数组
|
||
setTimeout(() => saveMessagesImmediately([]), 0);
|
||
}, [setMessage, saveMessagesImmediately]);
|
||
|
||
// 处理粘贴图片
|
||
const handlePasteImage = useCallback((base64Data) => {
|
||
if (!inputs.imageEnabled) {
|
||
return;
|
||
}
|
||
// 添加图片到 imageUrls 数组
|
||
const newUrls = [...(inputs.imageUrls || []), base64Data];
|
||
handleInputChange('imageUrls', newUrls);
|
||
}, [inputs.imageEnabled, inputs.imageUrls, handleInputChange]);
|
||
|
||
// Playground Context 值
|
||
const playgroundContextValue = {
|
||
onPasteImage: handlePasteImage,
|
||
imageUrls: inputs.imageUrls || [],
|
||
imageEnabled: inputs.imageEnabled || false,
|
||
};
|
||
|
||
return (
|
||
<PlaygroundProvider value={playgroundContextValue}>
|
||
<div className='h-full'>
|
||
<Layout className='h-full bg-transparent flex flex-col md:flex-row'>
|
||
{(showSettings || !isMobile) && (
|
||
<Layout.Sider
|
||
className={`
|
||
bg-transparent border-r-0 flex-shrink-0 overflow-auto mt-[60px]
|
||
${
|
||
isMobile
|
||
? 'fixed top-0 left-0 right-0 bottom-0 z-[1000] w-full h-auto bg-white shadow-lg'
|
||
: 'relative z-[1] w-80 h-[calc(100vh-66px)]'
|
||
}
|
||
`}
|
||
width={isMobile ? '100%' : 320}
|
||
>
|
||
<OptimizedSettingsPanel
|
||
inputs={inputs}
|
||
parameterEnabled={parameterEnabled}
|
||
models={models}
|
||
groups={groups}
|
||
styleState={styleState}
|
||
showSettings={showSettings}
|
||
showDebugPanel={showDebugPanel}
|
||
customRequestMode={customRequestMode}
|
||
customRequestBody={customRequestBody}
|
||
onInputChange={handleInputChange}
|
||
onParameterToggle={handleParameterToggle}
|
||
onCloseSettings={() => setShowSettings(false)}
|
||
onConfigImport={handleConfigImport}
|
||
onConfigReset={handleConfigReset}
|
||
onCustomRequestModeChange={setCustomRequestMode}
|
||
onCustomRequestBodyChange={setCustomRequestBody}
|
||
previewPayload={previewPayload}
|
||
messages={message}
|
||
/>
|
||
</Layout.Sider>
|
||
)}
|
||
|
||
<Layout.Content className='relative flex-1 overflow-hidden'>
|
||
<div className='overflow-hidden flex flex-col lg:flex-row h-[calc(100vh-66px)] mt-[60px]'>
|
||
<div className='flex-1 flex flex-col'>
|
||
<ChatArea
|
||
chatRef={chatRef}
|
||
message={message}
|
||
inputs={inputs}
|
||
styleState={styleState}
|
||
showDebugPanel={showDebugPanel}
|
||
roleInfo={roleInfo}
|
||
onMessageSend={onMessageSend}
|
||
onMessageCopy={messageActions.handleMessageCopy}
|
||
onMessageReset={messageActions.handleMessageReset}
|
||
onMessageDelete={messageActions.handleMessageDelete}
|
||
onStopGenerator={onStopGenerator}
|
||
onClearMessages={handleClearMessages}
|
||
onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
|
||
renderCustomChatContent={renderCustomChatContent}
|
||
renderChatBoxAction={renderChatBoxAction}
|
||
/>
|
||
</div>
|
||
|
||
{/* 调试面板 - 桌面端 */}
|
||
{showDebugPanel && !isMobile && (
|
||
<div className='w-96 flex-shrink-0 h-full'>
|
||
<OptimizedDebugPanel
|
||
debugData={debugData}
|
||
activeDebugTab={activeDebugTab}
|
||
onActiveDebugTabChange={setActiveDebugTab}
|
||
styleState={styleState}
|
||
customRequestMode={customRequestMode}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 调试面板 - 移动端覆盖层 */}
|
||
{showDebugPanel && isMobile && (
|
||
<div className='fixed top-0 left-0 right-0 bottom-0 z-[1000] bg-white overflow-auto shadow-lg'>
|
||
<OptimizedDebugPanel
|
||
debugData={debugData}
|
||
activeDebugTab={activeDebugTab}
|
||
onActiveDebugTabChange={setActiveDebugTab}
|
||
styleState={styleState}
|
||
showDebugPanel={showDebugPanel}
|
||
onCloseDebugPanel={() => setShowDebugPanel(false)}
|
||
customRequestMode={customRequestMode}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 浮动按钮 */}
|
||
<FloatingButtons
|
||
styleState={styleState}
|
||
showSettings={showSettings}
|
||
showDebugPanel={showDebugPanel}
|
||
onToggleSettings={() => setShowSettings(!showSettings)}
|
||
onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
|
||
/>
|
||
</Layout.Content>
|
||
</Layout>
|
||
</div>
|
||
</PlaygroundProvider>
|
||
);
|
||
};
|
||
|
||
export default Playground;
|