|
- /*
- 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,
- API,
- } 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,
- isMutualExclusiveModel,
- isMutualExclusive,
- debouncedSaveConfig,
- saveMessagesImmediately,
- handleConfigImport,
- handleConfigReset,
- setShowSettings,
- setModels,
- setGroups,
- channels,
- setChannels,
- 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, searchParams);
-
- // Load channels for the selected model
- useEffect(() => {
- if (!inputs.model) {
- setChannels([]);
- return;
- }
- let cancelled = false;
- const loadChannels = async () => {
- try {
- const res = await API.get(
- `/api/user/model_channels?model=${encodeURIComponent(inputs.model)}`
- );
- if (cancelled) return;
- const { success, data } = res.data;
- if (success && data) {
- const channelList = data.channels || [];
- setChannels(channelList);
- const channelId = (data.default_channel_id && data.default_channel_id > 0)
- ? data.default_channel_id
- : (channelList.length > 0 ? channelList[0].id : 0);
- if (channelId !== inputs.channelId) {
- handleInputChange('channelId', channelId);
- }
- }
- } catch {
- setChannels([]);
- }
- };
- loadChannels();
- return () => { cancelled = true; };
- }, [inputs.model]);
-
- // 消息编辑
- 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]) || t('示例消息');
- 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];
-
- // 自定义请求体也通过 header 传递网关字段
- const customHeaders = {};
- if (inputs.channelId && inputs.channelId > 0) {
- customHeaders['X-Channel-Id'] = String(inputs.channelId);
- }
- if (customPayload.group) {
- customHeaders['X-Group'] = customPayload.group;
- delete customPayload.group;
- }
- delete customPayload.channel_id;
- sendRequest(customPayload, customPayload.stream !== false, customHeaders);
-
- // 发送消息后保存,传入新消息列表
- setTimeout(() => saveMessagesImmediately(newMessages), 0);
-
- return newMessages;
- });
- return;
- } catch (error) {
- console.error('自定义请求体JSON解析失败:', error);
- Toast.error(t(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,
- );
- const extraHeaders = {};
- if (inputs.channelId && inputs.channelId > 0) {
- extraHeaders['X-Channel-Id'] = String(inputs.channelId);
- }
- if (payload.group) {
- extraHeaders['X-Group'] = payload.group;
- delete payload.group;
- }
- delete payload.channel_id;
- sendRequest(payload, inputs.stream, extraHeaders);
-
- // 禁用图片模式
- 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}
- channels={channels}
- styleState={styleState}
- showSettings={showSettings}
- showDebugPanel={showDebugPanel}
- customRequestMode={customRequestMode}
- customRequestBody={customRequestBody}
- mutualExclusive={isMutualExclusive}
- 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;
|