tokenFactory/web/src/pages/Playground/index.jsx

816 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
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,
useLayoutEffect,
} 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 LazyVisibleMessage from '../../components/playground/LazyVisibleMessage';
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 modeMessagesRef = useRef({
text: [],
image: [],
video: [],
});
const previousModeRef = useRef('text');
const currentMessagesRef = useRef([]);
const modeStoreInitializedRef = useRef(false);
const activeVideoPollTaskIdsRef = useRef(new Set());
const pendingPlaygroundChatScrollRef = useRef(false);
const getModeStorageKey = useCallback(
() => `playground_mode_messages_${userState?.user?.id || 'guest'}`,
[userState?.user?.id],
);
const state = usePlaygroundState(userState?.user?.id);
const {
inputs,
parameterEnabled,
showDebugPanel,
customRequestMode,
customRequestBody,
showSettings,
models,
modelTypes,
supplierOptions,
groups,
message,
debugData,
activeDebugTab,
previewPayload,
sseSourceRef,
chatRef,
handleInputChange,
handleParameterToggle,
debouncedSaveConfig,
saveMessagesImmediately,
handleConfigImport,
handleConfigReset,
setShowSettings,
setModels,
setModelTypes,
setSupplierOptions,
setGroups,
setStatus,
setMessage,
setDebugData,
setActiveDebugTab,
setPreviewPayload,
setShowDebugPanel,
setCustomRequestMode,
setCustomRequestBody,
} = state;
useLayoutEffect(() => {
if (!pendingPlaygroundChatScrollRef.current) return;
pendingPlaygroundChatScrollRef.current = false;
const scrollNow = () => {
try {
chatRef.current?.scrollToBottom?.(false);
} catch (_) {
// Semi Chat ref 在极少数情况下可能尚未就绪
}
};
scrollNow();
const timers = [80, 200, 450].map((ms) => setTimeout(scrollNow, ms));
return () => timers.forEach(clearTimeout);
}, [message]);
useEffect(() => {
currentMessagesRef.current = Array.isArray(message) ? message : [];
}, [message]);
// API 请求相关
const { sendRequest, onStopGenerator, startVideoTaskPolling } = useApiRequest(
setMessage,
setDebugData,
setActiveDebugTab,
sseSourceRef,
saveMessagesImmediately,
);
useEffect(() => {
const terminalStatuses = new Set([
'completed',
'succeeded',
'success',
'failed',
'error',
'cancelled',
'timeout',
]);
const shouldResumeStatuses = new Set([
'queued',
'processing',
'in_progress',
'running',
'pending',
'submitted',
'',
]);
const candidates = (Array.isArray(message) ? message : []).filter((msg) => {
if (msg?.role !== MESSAGE_ROLES.ASSISTANT) return false;
const taskId = msg?.videoTask?.taskId;
if (!taskId) return false;
if (msg?.videoTask?.playableUrl) return false;
const taskStatus = String(msg?.videoTask?.status || '').toLowerCase();
if (terminalStatuses.has(taskStatus)) return false;
return shouldResumeStatuses.has(taskStatus);
});
candidates.forEach((msg) => {
const taskId = msg?.videoTask?.taskId;
if (!taskId || activeVideoPollTaskIdsRef.current.has(taskId)) return;
activeVideoPollTaskIdsRef.current.add(taskId);
startVideoTaskPolling(taskId, (patch) => {
const nextTaskStatus = String(patch?.videoTask?.status || '').toLowerCase();
const nextMessageStatus = String(patch?.status || '').toLowerCase();
const isTerminal =
terminalStatuses.has(nextTaskStatus) ||
nextMessageStatus === 'complete' ||
nextMessageStatus === 'error';
if (isTerminal) {
activeVideoPollTaskIdsRef.current.delete(taskId);
}
setMessage((prevMessages) => {
const updated = prevMessages.map((item) => {
if (item?.id !== msg.id) return item;
return {
...item,
...(patch.content !== undefined ? { content: patch.content } : {}),
...(patch.status ? { status: patch.status } : {}),
...(patch.videoTask !== undefined
? { videoTask: patch.videoTask }
: {}),
};
});
setTimeout(() => saveMessagesImmediately(updated), 0);
return updated;
});
});
});
}, [message, setMessage, saveMessagesImmediately, startVideoTaskPolling]);
// 数据加载modelTypes 参与按类型筛选,与模型广场一致在客户端过滤)
useDataLoader(
userState,
inputs,
modelTypes,
handleInputChange,
setModels,
setModelTypes,
setSupplierOptions,
setGroups,
setStatus,
);
useEffect(() => {
const displayMode = inputs.display_mode || 'text';
if ((displayMode === 'image' || displayMode === 'video') && inputs.stream) {
handleInputChange('stream', false);
}
}, [inputs.display_mode, inputs.stream, handleInputChange]);
useEffect(() => {
if (modeStoreInitializedRef.current) return;
const currentMode = inputs.display_mode || 'text';
modeMessagesRef.current[currentMode] = Array.isArray(message) ? message : [];
previousModeRef.current = currentMode;
modeStoreInitializedRef.current = true;
}, [inputs.display_mode, message]);
// 恢复分模式消息(文本/图片/视频)持久化快照
useEffect(() => {
try {
const raw = localStorage.getItem(getModeStorageKey());
if (!raw) return;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return;
const restored = {
text: Array.isArray(parsed.text) ? parsed.text : [],
image: Array.isArray(parsed.image) ? parsed.image : [],
video: Array.isArray(parsed.video) ? parsed.video : [],
};
modeMessagesRef.current = restored;
const currentMode = inputs.display_mode || 'text';
const currentModeMessages = restored[currentMode] || [];
if (Array.isArray(currentModeMessages)) {
pendingPlaygroundChatScrollRef.current = true;
setMessage(currentModeMessages);
}
previousModeRef.current = currentMode;
modeStoreInitializedRef.current = true;
} catch (err) {
console.warn('恢复分模式消息失败:', err);
}
}, [getModeStorageKey, inputs.display_mode, setMessage]);
useEffect(() => {
const currentMode = inputs.display_mode || 'text';
modeMessagesRef.current[currentMode] = Array.isArray(message) ? message : [];
}, [inputs.display_mode, message]);
useEffect(() => {
if (!modeStoreInitializedRef.current) return;
const nextMode = inputs.display_mode || 'text';
const prevMode = previousModeRef.current || nextMode;
if (nextMode === prevMode) return;
modeMessagesRef.current[prevMode] = currentMessagesRef.current || [];
const nextMessages = modeMessagesRef.current[nextMode];
pendingPlaygroundChatScrollRef.current = true;
setMessage(Array.isArray(nextMessages) ? nextMessages : []);
previousModeRef.current = nextMode;
}, [inputs.display_mode, setMessage]);
// 消息编辑
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) {
const mode = inputs.display_mode || 'text';
const allowMedia = mode === 'image' || mode === 'video';
if (allowMedia && 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 mode = inputs.display_mode || 'text';
const allowMedia = mode === 'image' || mode === 'video';
const validImageUrls = allowMedia
? inputs.imageUrls.filter((url) => url.trim() !== '')
: [];
const messageContent = buildMessageContent(
content,
validImageUrls,
allowMedia,
);
const userMessageWithImages = createMessage(
MESSAGE_ROLES.USER,
messageContent,
);
const newMessages = [...message, userMessageWithImages];
const payload = buildApiPayload(
newMessages,
null,
inputs,
parameterEnabled,
);
setMessage((prevMessage) => {
const isChatEndpoint = payload?.__endpoint === 'chat';
sendRequest(payload, isChatEndpoint ? inputs.stream : false);
// 发送消息后保存,传入新消息列表(包含用户消息和加载消息)
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: msg, className }) => {
const isCurrentlyEditing = editingMessageId === msg.id;
const displayMode = inputs.display_mode || 'text';
const isMediaMode = displayMode === 'image' || displayMode === 'video';
const isLastInThread =
Array.isArray(message) &&
message.length > 0 &&
msg?.id === message[message.length - 1]?.id;
const skipLazy =
msg?.status === 'loading' ||
msg?.status === 'incomplete' ||
msg?.status === 'error' ||
(!isMediaMode && isLastInThread);
const body = (
<OptimizedMessageContent
message={msg}
className={skipLazy ? className : undefined}
styleState={styleState}
onToggleReasoningExpansion={toggleReasoningExpansion}
isEditing={isCurrentlyEditing}
onEditSave={handleEditSave}
onEditCancel={handleEditCancel}
editValue={editValue}
onEditValueChange={setEditValue}
/>
);
if (skipLazy) {
return body;
}
return (
<LazyVisibleMessage
messageId={msg.id}
className={className}
variant={isMediaMode ? 'media' : 'default'}
>
{body}
</LazyVisibleMessage>
);
},
[
styleState,
editingMessageId,
editValue,
handleEditSave,
handleEditCancel,
setEditValue,
toggleReasoningExpansion,
message,
inputs.display_mode,
],
);
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('登录过期,请重新登录!'));
}
const modelParam = searchParams.get('model');
const modelTypeParam = searchParams.get('model_type');
if (modelTypeParam) {
const v = Number(modelTypeParam);
if (!Number.isNaN(v)) {
handleInputChange('model_type', v);
}
}
if (modelParam) {
handleInputChange('model', modelParam);
}
}, [searchParams, t, handleInputChange]);
// 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,
]);
// 兜底持久化:任何消息变更(含视频轮询进度与完成态)都同步落盘,
// 避免刷新后丢失 videoTask.playableUrl 导致播放器消失。
useEffect(() => {
const timer = setTimeout(() => {
saveMessagesImmediately(message);
try {
localStorage.setItem(
getModeStorageKey(),
JSON.stringify(modeMessagesRef.current),
);
} catch (err) {
console.warn('保存分模式消息失败:', err);
}
}, 120);
return () => clearTimeout(timer);
}, [message, saveMessagesImmediately, getModeStorageKey]);
// 清空对话的处理函数
const handleClearMessages = useCallback(() => {
Modal.confirm({
title: t('确认清空当前对话?'),
content: t('此操作将清空当前展示模式下的全部消息,且不可撤销。'),
okText: t('确认清空'),
cancelText: t('取消'),
okButtonProps: { type: 'danger' },
onOk: () => {
const currentMode = inputs.display_mode || 'text';
modeMessagesRef.current[currentMode] = [];
currentMessagesRef.current = [];
try {
localStorage.setItem(
getModeStorageKey(),
JSON.stringify(modeMessagesRef.current),
);
} catch (err) {
console.warn('保存分模式消息失败:', err);
}
setMessage([]);
// 清空对话后保存,传入空数组
setTimeout(() => saveMessagesImmediately([]), 0);
},
});
}, [inputs.display_mode, saveMessagesImmediately, setMessage, t, getModeStorageKey]);
// 处理粘贴图片
const handlePasteImage = useCallback(
(base64Data) => {
const mode = inputs.display_mode || 'text';
if (mode !== 'image' && mode !== 'video') {
return;
}
// 添加图片到 imageUrls 数组
const newUrls = [...(inputs.imageUrls || []), base64Data];
handleInputChange('imageUrls', newUrls);
},
[inputs.display_mode, inputs.imageUrls, handleInputChange],
);
// Playground Context 值
const playgroundContextValue = {
onPasteImage: handlePasteImage,
imageUrls: inputs.imageUrls || [],
imageEnabled: ['image', 'video'].includes(inputs.display_mode || 'text'),
};
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 playground-shell-h'
}
`}
width={isMobile ? '100%' : 320}
>
<OptimizedSettingsPanel
inputs={inputs}
parameterEnabled={parameterEnabled}
models={models}
modelTypes={modelTypes}
supplierOptions={supplierOptions}
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}
userId={userState?.user?.id}
/>
</Layout.Sider>
)}
<Layout.Content className='relative flex-1 overflow-hidden'>
<div className='playground-shell-h mt-[60px] flex min-h-0 flex-col overflow-hidden lg:flex-row'>
<div className='flex min-h-0 flex-1 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;