new-api/web/src/pages/Playground/Playground.js

1626 lines
58 KiB
JavaScript
Raw Normal View History

import React, { useCallback, useContext, useEffect, useState, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { UserContext } from '../../context/User/index.js';
2025-04-04 12:00:38 +08:00
import {
API,
getUserIdFromLocalStorage,
showError,
getLogo,
2025-04-04 12:00:38 +08:00
} from '../../helpers/index.js';
import {
Card,
Chat,
Input,
Layout,
Select,
Slider,
TextArea,
Typography,
Button,
MarkdownRender,
Tag,
Tabs,
TabPane,
Toast,
Tooltip,
Modal,
2025-04-04 12:00:38 +08:00
} from '@douyinfe/semi-ui';
2024-09-26 00:59:09 +08:00
import { SSE } from 'sse';
import {
Settings,
Sparkles,
ChevronRight,
ChevronUp,
Brain,
Zap,
MessageSquare,
SlidersHorizontal,
Hash,
Thermometer,
Type,
Users,
Loader2,
Target,
Repeat,
Ban,
Shuffle,
ToggleLeft,
Code,
Eye,
EyeOff,
FileText,
Clock,
Check,
X,
Copy,
RefreshCw,
Trash2,
} from 'lucide-react';
import { StyleContext } from '../../context/Style/index.js';
import { useTranslation } from 'react-i18next';
import { renderGroupOption, truncateText, stringToColor } from '../../helpers/render.js';
import { IconSend } from '@douyinfe/semi-icons';
2024-12-09 15:03:04 +08:00
2024-09-26 00:59:09 +08:00
let id = 4;
function getId() {
2025-04-04 12:00:38 +08:00
return `${id++}`;
2024-09-26 00:59:09 +08:00
}
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,${btoa(svg)}`;
};
2024-09-26 00:59:09 +08:00
const Playground = () => {
const { t } = useTranslation();
const [userState, userDispatch] = useContext(UserContext);
const roleInfo = {
user: {
name: userState?.user?.username || 'User',
avatar: generateAvatarDataUrl(userState?.user?.username),
},
assistant: {
name: 'Assistant',
avatar: getLogo(),
},
system: {
name: 'System',
avatar:
'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/other/logo.png',
},
};
2025-04-04 12:00:38 +08:00
const defaultMessage = [
{
role: 'user',
id: '2',
createAt: 1715676751919,
content: t('你好'),
},
{
role: 'assistant',
id: '3',
createAt: 1715676751919,
content: t('你好,请问有什么可以帮助您的吗?'),
reasoningContent: '',
isReasoningExpanded: false,
2025-04-04 12:00:38 +08:00
},
];
const defaultModel = 'deepseek-r1';
2024-09-26 00:59:09 +08:00
const [inputs, setInputs] = useState({
model: defaultModel,
2024-09-26 00:59:09 +08:00
group: '',
max_tokens: 0,
temperature: 0,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
seed: null,
stream: true,
});
const [parameterEnabled, setParameterEnabled] = useState({
max_tokens: true,
temperature: true,
top_p: false,
frequency_penalty: false,
presence_penalty: false,
seed: false,
2024-09-26 00:59:09 +08:00
});
const [searchParams, setSearchParams] = useSearchParams();
const [status, setStatus] = useState({});
2025-04-04 12:00:38 +08:00
const [systemPrompt, setSystemPrompt] = useState(
'You are a helpful assistant. You can help me by answering my questions. You can also ask me questions.',
);
2024-09-26 00:59:09 +08:00
const [message, setMessage] = useState(defaultMessage);
const [models, setModels] = useState([]);
const [groups, setGroups] = useState([]);
const [showSettings, setShowSettings] = useState(true);
const [showDebugPanel, setShowDebugPanel] = useState(true);
const [debugData, setDebugData] = useState({
request: null,
response: null,
timestamp: null
});
const [activeDebugTab, setActiveDebugTab] = useState('request');
const [styleState, styleDispatch] = useContext(StyleContext);
const sseSourceRef = useRef(null);
2024-09-26 00:59:09 +08:00
const handleInputChange = (name, value) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const handleParameterToggle = (paramName) => {
setParameterEnabled(prev => ({
...prev,
[paramName]: !prev[paramName]
}));
};
2024-09-26 00:59:09 +08:00
useEffect(() => {
if (searchParams.get('expired')) {
showError(t('未登录或登录已过期,请重新登录!'));
2024-09-26 00:59:09 +08:00
}
let status = localStorage.getItem('status');
if (status) {
status = JSON.parse(status);
setStatus(status);
}
loadModels();
loadGroups();
}, [searchParams, t]);
2024-09-26 00:59:09 +08:00
const loadModels = async () => {
let res = await API.get(`/api/user/models`);
const { success, message, data } = res.data;
if (success) {
let localModelOptions = data.map((model) => ({
label: model,
value: model,
}));
setModels(localModelOptions);
const hasDefault = localModelOptions.some(option => option.value === defaultModel);
if (!hasDefault && localModelOptions.length > 0) {
setInputs((inputs) => ({ ...inputs, model: localModelOptions[0].value }));
}
2024-09-26 00:59:09 +08:00
} else {
showError(t(message));
2024-09-26 00:59:09 +08:00
}
};
const loadGroups = async () => {
let res = await API.get(`/api/user/self/groups`);
2024-09-26 00:59:09 +08:00
const { success, message, data } = res.data;
if (success) {
let localGroupOptions = Object.entries(data).map(([group, info]) => ({
2025-04-04 12:00:38 +08:00
label: truncateText(info.desc, '50%'),
2024-09-26 00:59:09 +08:00
value: group,
ratio: info.ratio,
fullLabel: info.desc,
2024-09-26 00:59:09 +08:00
}));
if (localGroupOptions.length === 0) {
2025-04-04 12:00:38 +08:00
localGroupOptions = [
{
label: t('用户分组'),
value: '',
ratio: 1,
},
];
} else {
const localUser = JSON.parse(localStorage.getItem('user'));
2025-04-04 12:00:38 +08:00
const userGroup =
(userState.user && userState.user.group) ||
(localUser && localUser.group);
if (userGroup) {
2025-04-04 12:00:38 +08:00
const userGroupIndex = localGroupOptions.findIndex(
(g) => g.value === userGroup,
);
if (userGroupIndex > -1) {
2025-04-04 12:00:38 +08:00
const userGroupOption = localGroupOptions.splice(
userGroupIndex,
1,
)[0];
localGroupOptions.unshift(userGroupOption);
}
}
2024-09-26 00:59:09 +08:00
}
2024-09-26 00:59:09 +08:00
setGroups(localGroupOptions);
handleInputChange('group', localGroupOptions[0].value);
} else {
showError(t(message));
2024-09-26 00:59:09 +08:00
}
};
const getSystemMessage = () => {
if (systemPrompt !== '') {
return {
role: 'system',
id: '1',
createAt: 1715676751919,
content: systemPrompt,
2025-04-04 12:00:38 +08:00
};
2024-09-26 00:59:09 +08:00
}
2025-04-04 12:00:38 +08:00
};
2024-09-26 00:59:09 +08:00
let handleNonStreamRequest = async (payload) => {
setDebugData(prev => ({
...prev,
request: payload,
timestamp: new Date().toISOString(),
response: null
}));
setActiveDebugTab('request');
try {
const response = await fetch('/pg/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'New-Api-User': getUserIdFromLocalStorage(),
},
body: JSON.stringify(payload),
});
if (!response.ok) {
let errorBody = '';
try {
errorBody = await response.text();
} catch (e) {
errorBody = '无法读取错误响应体';
}
const errorInfo = {
error: 'HTTP错误',
status: response.status,
statusText: response.statusText,
body: errorBody,
timestamp: new Date().toISOString()
};
setDebugData(prev => ({
...prev,
response: JSON.stringify(errorInfo, null, 2)
}));
setActiveDebugTab('response');
throw new Error(`HTTP error! status: ${response.status}, body: ${errorBody}`);
}
const data = await response.json();
setDebugData(prev => ({
...prev,
response: JSON.stringify(data, null, 2)
}));
setActiveDebugTab('response');
if (data.choices && data.choices[0]) {
const choice = data.choices[0];
let content = choice.message?.content || '';
let reasoningContent = choice.message?.reasoning_content || '';
if (content.includes('<think>')) {
const thinkTagRegex = /<think>([\s\S]*?)<\/think>/g;
let thoughts = [];
let replyParts = [];
let lastIndex = 0;
let match;
thinkTagRegex.lastIndex = 0;
while ((match = thinkTagRegex.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));
content = replyParts.join('');
if (thoughts.length > 0) {
if (reasoningContent) {
reasoningContent += '\n\n---\n\n' + thoughts.join('\n\n---\n\n');
} else {
reasoningContent = thoughts.join('\n\n---\n\n');
}
}
}
content = content.replace(/<\/?think>/g, '').trim();
setMessage((prevMessage) => {
const newMessages = [...prevMessage];
const lastMessage = newMessages[newMessages.length - 1];
if (lastMessage && lastMessage.status === 'loading') {
newMessages[newMessages.length - 1] = {
...lastMessage,
content: content,
reasoningContent: reasoningContent,
status: 'complete',
isReasoningExpanded: false
};
}
return newMessages;
});
}
} catch (error) {
console.error('Non-stream request error:', error);
const errorInfo = {
error: '非流式请求错误',
message: error.message,
timestamp: new Date().toISOString(),
stack: error.stack
};
if (error.message.includes('HTTP error')) {
errorInfo.details = '服务器返回了错误状态码';
} else if (error.message.includes('Failed to fetch')) {
errorInfo.details = '网络连接失败或服务器无响应';
}
setDebugData(prev => ({
...prev,
response: JSON.stringify(errorInfo, null, 2)
}));
setActiveDebugTab('response');
setMessage((prevMessage) => {
const newMessages = [...prevMessage];
const lastMessage = newMessages[newMessages.length - 1];
if (lastMessage && lastMessage.status === 'loading') {
newMessages[newMessages.length - 1] = {
...lastMessage,
content: t('请求发生错误: ') + error.message,
status: 'error',
isReasoningExpanded: false
};
}
return newMessages;
});
}
};
2024-09-26 00:59:09 +08:00
let handleSSE = (payload) => {
setDebugData(prev => ({
...prev,
request: payload,
timestamp: new Date().toISOString(),
response: null
}));
setActiveDebugTab('request');
2024-09-26 00:59:09 +08:00
let source = new SSE('/pg/chat/completions', {
headers: {
2025-04-04 12:00:38 +08:00
'Content-Type': 'application/json',
'New-Api-User': getUserIdFromLocalStorage(),
2024-09-26 00:59:09 +08:00
},
2025-04-04 12:00:38 +08:00
method: 'POST',
2024-09-26 00:59:09 +08:00
payload: JSON.stringify(payload),
});
sseSourceRef.current = source;
let responseData = '';
let hasReceivedFirstResponse = false;
2025-04-04 12:00:38 +08:00
source.addEventListener('message', (e) => {
if (e.data === '[DONE]') {
source.close();
sseSourceRef.current = null;
setDebugData(prev => ({
...prev,
response: responseData
}));
2024-09-26 00:59:09 +08:00
completeMessage();
return;
}
try {
let payload = JSON.parse(e.data);
responseData += e.data + '\n';
if (!hasReceivedFirstResponse) {
setActiveDebugTab('response');
hasReceivedFirstResponse = true;
}
const delta = payload.choices?.[0]?.delta;
if (delta) {
if (delta.reasoning_content) {
streamMessageUpdate(delta.reasoning_content, 'reasoning');
}
if (delta.content) {
streamMessageUpdate(delta.content, 'content');
}
}
} catch (error) {
console.error('Failed to parse SSE message:', error);
const errorInfo = `解析错误: ${error.message}`;
setDebugData(prev => ({
...prev,
response: responseData + `\n\nError: ${errorInfo}`
}));
setActiveDebugTab('response');
streamMessageUpdate(t('解析响应数据时发生错误'), 'content');
completeMessage('error');
2024-09-26 00:59:09 +08:00
}
});
2025-04-04 12:00:38 +08:00
source.addEventListener('error', (e) => {
console.error('SSE Error:', e);
const errorMessage = e.data || t('请求发生错误');
const errorInfo = {
error: 'SSE连接错误',
message: errorMessage,
status: source.status,
readyState: source.readyState,
timestamp: new Date().toISOString()
};
setDebugData(prev => ({
...prev,
response: responseData + '\n\nSSE Error:\n' + JSON.stringify(errorInfo, null, 2)
}));
setActiveDebugTab('response');
streamMessageUpdate(errorMessage, 'content');
2025-04-04 12:00:38 +08:00
completeMessage('error');
sseSourceRef.current = null;
source.close();
2024-09-26 00:59:09 +08:00
});
2025-04-04 12:00:38 +08:00
source.addEventListener('readystatechange', (e) => {
2024-09-26 00:59:09 +08:00
if (e.readyState >= 2) {
if (source.status !== undefined && source.status !== 200) {
const errorInfo = {
error: 'HTTP状态错误',
status: source.status,
readyState: source.readyState,
timestamp: new Date().toISOString()
};
setDebugData(prev => ({
...prev,
response: responseData + '\n\nHTTP Error:\n' + JSON.stringify(errorInfo, null, 2)
}));
setActiveDebugTab('response');
2024-09-26 00:59:09 +08:00
source.close();
streamMessageUpdate(t('连接已断开'), 'content');
completeMessage('error');
2024-09-26 00:59:09 +08:00
}
}
});
try {
source.stream();
} catch (error) {
console.error('Failed to start SSE stream:', error);
const errorInfo = {
error: '启动SSE流失败',
message: error.message,
timestamp: new Date().toISOString()
};
setDebugData(prev => ({
...prev,
response: 'Stream启动失败:\n' + JSON.stringify(errorInfo, null, 2)
}));
setActiveDebugTab('response');
streamMessageUpdate(t('建立连接时发生错误'), 'content');
completeMessage('error');
}
2025-04-04 12:00:38 +08:00
};
2024-09-26 00:59:09 +08:00
2025-04-04 12:00:38 +08:00
const onMessageSend = useCallback(
(content, attachment) => {
console.log('attachment: ', attachment);
setMessage((prevMessage) => {
const newMessage = [
...prevMessage,
{
role: 'user',
content: content,
createAt: Date.now(),
id: getId(),
},
];
2024-09-26 00:59:09 +08:00
2025-04-04 12:00:38 +08:00
const getPayload = () => {
let systemMessage = getSystemMessage();
let messages = newMessage.map((item) => {
return {
role: item.role,
content: item.content,
};
});
if (systemMessage) {
messages.unshift(systemMessage);
2024-09-26 00:59:09 +08:00
}
const payload = {
2025-04-04 12:00:38 +08:00
messages: messages,
stream: inputs.stream,
2025-04-04 12:00:38 +08:00
model: inputs.model,
group: inputs.group,
};
if (parameterEnabled.max_tokens && inputs.max_tokens > 0) {
payload.max_tokens = parseInt(inputs.max_tokens);
}
if (parameterEnabled.temperature) {
payload.temperature = inputs.temperature;
}
if (parameterEnabled.top_p) {
payload.top_p = inputs.top_p;
}
if (parameterEnabled.frequency_penalty) {
payload.frequency_penalty = inputs.frequency_penalty;
}
if (parameterEnabled.presence_penalty) {
payload.presence_penalty = inputs.presence_penalty;
}
if (parameterEnabled.seed && inputs.seed !== null && inputs.seed !== '') {
payload.seed = parseInt(inputs.seed);
}
return payload;
2024-09-26 00:59:09 +08:00
};
const payload = getPayload();
if (inputs.stream) {
handleSSE(payload);
} else {
handleNonStreamRequest(payload);
}
2025-04-04 12:00:38 +08:00
newMessage.push({
role: 'assistant',
content: '',
reasoningContent: '',
isReasoningExpanded: true,
2025-04-04 12:00:38 +08:00
createAt: Date.now(),
id: getId(),
status: 'loading',
});
return newMessage;
2024-09-26 00:59:09 +08:00
});
2025-04-04 12:00:38 +08:00
},
[getSystemMessage, inputs, setMessage],
2025-04-04 12:00:38 +08:00
);
2024-09-26 00:59:09 +08:00
2024-09-27 20:48:55 +08:00
const completeMessage = useCallback((status = 'complete') => {
2024-09-26 00:59:09 +08:00
setMessage((prevMessage) => {
const lastMessage = prevMessage[prevMessage.length - 1];
2024-09-27 20:48:55 +08:00
if (lastMessage.status === 'complete' || lastMessage.status === 'error') {
return prevMessage;
}
return [...prevMessage.slice(0, -1), { ...lastMessage, status: status, isReasoningExpanded: false }];
2024-09-26 00:59:09 +08:00
});
}, [setMessage]);
2024-09-26 00:59:09 +08:00
const streamMessageUpdate = useCallback((textChunk, type) => {
setMessage((prevMessage) => {
const lastMessage = prevMessage[prevMessage.length - 1];
2025-04-04 12:00:38 +08:00
let newMessage = { ...lastMessage };
if (lastMessage.status === 'error') {
return prevMessage;
}
if (lastMessage.status === 'loading' || lastMessage.status === 'incomplete') {
if (type === 'reasoning') {
newMessage = {
...newMessage,
reasoningContent: (lastMessage.reasoningContent || '') + textChunk,
status: 'incomplete',
};
} else if (type === 'content') {
const shouldCollapseReasoning = !lastMessage.content && lastMessage.reasoningContent;
const newContent = (lastMessage.content || '') + textChunk;
let shouldCollapseFromThinkTag = false;
if (lastMessage.isReasoningExpanded && newContent.includes('</think>')) {
const thinkMatches = newContent.match(/<think>/g);
const thinkCloseMatches = newContent.match(/<\/think>/g);
if (thinkMatches && thinkCloseMatches && thinkCloseMatches.length >= thinkMatches.length) {
shouldCollapseFromThinkTag = true;
}
}
newMessage = {
...newMessage,
content: newContent,
status: 'incomplete',
isReasoningExpanded: (shouldCollapseReasoning || shouldCollapseFromThinkTag) ? false : lastMessage.isReasoningExpanded,
};
}
2024-09-26 00:59:09 +08:00
}
return [...prevMessage.slice(0, -1), newMessage];
2025-04-04 12:00:38 +08:00
});
}, [setMessage]);
2024-09-26 00:59:09 +08:00
const handleMessageCopy = useCallback((message) => {
if (!message.content) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(message.content).then(() => {
Toast.success({
content: t('消息已复制到剪贴板'),
duration: 2,
});
}).catch(err => {
console.error('Clipboard API 复制失败:', err);
fallbackCopyToClipboard(message.content);
});
} else {
fallbackCopyToClipboard(message.content);
}
}, [t]);
const fallbackCopyToClipboard = useCallback((text) => {
try {
if (!document.execCommand) {
throw new Error('execCommand not supported');
}
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.top = '-9999px';
textArea.style.left = '-9999px';
textArea.style.opacity = '0';
textArea.style.pointerEvents = 'none';
textArea.style.zIndex = '-1';
textArea.setAttribute('readonly', '');
document.body.appendChild(textArea);
if (textArea.select) {
textArea.select();
}
if (textArea.setSelectionRange) {
textArea.setSelectionRange(0, text.length);
}
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
Toast.success({
content: t('消息已复制到剪贴板'),
duration: 2,
});
} else {
throw new Error('execCommand copy failed');
}
} catch (err) {
console.error('回退复制方案也失败:', err);
let errorMessage = t('复制失败,请手动选择文本复制');
if (window.location.protocol === 'http:' && window.location.hostname !== 'localhost') {
errorMessage = t('复制功能需要 HTTPS 环境,请手动复制');
} else if (!navigator.clipboard && !document.execCommand) {
errorMessage = t('浏览器不支持复制功能,请手动复制');
}
Toast.error({
content: errorMessage,
duration: 4,
});
}
}, [t]);
const handleMessageReset = useCallback((targetMessage) => {
setMessage(prevMessages => {
const messageIndex = prevMessages.findIndex(msg => msg.id === targetMessage.id);
if (messageIndex === -1) return prevMessages;
if (targetMessage.role === 'user') {
const newMessages = prevMessages.slice(0, messageIndex);
setTimeout(() => {
onMessageSend(targetMessage.content);
}, 100);
return newMessages;
} else if (targetMessage.role === 'assistant') {
let userMessageIndex = messageIndex - 1;
while (userMessageIndex >= 0 && prevMessages[userMessageIndex].role !== 'user') {
userMessageIndex--;
}
if (userMessageIndex >= 0) {
const userMessage = prevMessages[userMessageIndex];
const newMessages = prevMessages.slice(0, userMessageIndex);
setTimeout(() => {
onMessageSend(userMessage.content);
}, 100);
return newMessages;
}
}
return prevMessages;
});
}, [onMessageSend]);
const handleMessageDelete = useCallback((targetMessage) => {
Modal.confirm({
title: t('确认删除'),
content: t('确定要删除这条消息吗?'),
okText: t('确定'),
cancelText: t('取消'),
okButtonProps: {
type: 'danger',
},
onOk: () => {
setMessage(prevMessages => {
const messageIndex = prevMessages.findIndex(msg => msg.id === targetMessage.id);
if (messageIndex === -1) return prevMessages;
if (targetMessage.role === 'user' && messageIndex < prevMessages.length - 1) {
const nextMessage = prevMessages[messageIndex + 1];
if (nextMessage.role === 'assistant') {
Toast.success({
content: t('已删除消息及其回复'),
duration: 2,
});
return prevMessages.filter((_, index) => index !== messageIndex && index !== messageIndex + 1);
}
}
Toast.success({
content: t('消息已删除'),
duration: 2,
});
return prevMessages.filter(msg => msg.id !== targetMessage.id);
});
},
});
}, [setMessage, t]);
const onStopGenerator = useCallback(() => {
if (sseSourceRef.current) {
sseSourceRef.current.close();
sseSourceRef.current = null;
setMessage((prevMessage) => {
const lastMessage = prevMessage[prevMessage.length - 1];
if (lastMessage.status === 'loading' || lastMessage.status === 'incomplete') {
let currentContent = lastMessage.content || '';
let currentReasoningContent = lastMessage.reasoningContent || '';
if (currentContent.includes('<think>')) {
const thinkTagRegex = /<think>([\s\S]*?)<\/think>/g;
let match;
let thoughtsFromPairedTags = [];
let replyParts = [];
let lastIndex = 0;
while ((match = thinkTagRegex.exec(currentContent)) !== null) {
replyParts.push(currentContent.substring(lastIndex, match.index));
thoughtsFromPairedTags.push(match[1]);
lastIndex = match.index + match[0].length;
}
replyParts.push(currentContent.substring(lastIndex));
if (thoughtsFromPairedTags.length > 0) {
const pairedThoughtsStr = thoughtsFromPairedTags.join('\n\n---\n\n');
if (currentReasoningContent) {
currentReasoningContent += '\n\n---\n\n' + pairedThoughtsStr;
} else {
currentReasoningContent = pairedThoughtsStr;
}
}
currentContent = replyParts.join('');
}
const lastOpenThinkIndex = currentContent.lastIndexOf('<think>');
if (lastOpenThinkIndex !== -1) {
const fragmentAfterLastOpen = currentContent.substring(lastOpenThinkIndex);
if (!fragmentAfterLastOpen.includes('</think>')) {
const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim();
if (unclosedThought) {
if (currentReasoningContent) {
currentReasoningContent += '\n\n---\n\n' + unclosedThought;
} else {
currentReasoningContent = unclosedThought;
}
}
currentContent = currentContent.substring(0, lastOpenThinkIndex);
}
}
currentContent = currentContent.replace(/<\/?think>/g, '').trim();
return [...prevMessage.slice(0, -1), {
...lastMessage,
status: 'complete',
reasoningContent: currentReasoningContent || null,
content: currentContent,
isReasoningExpanded: false
}];
}
return prevMessage;
});
}
}, [setMessage]);
const DebugToggle = () => {
return (
<Button
icon={showDebugPanel ? <EyeOff size={14} /> : <Eye size={14} />}
onClick={() => setShowDebugPanel(!showDebugPanel)}
theme="borderless"
type="tertiary"
size="small"
className="!rounded-lg !text-gray-600 hover:!text-purple-600 hover:!bg-purple-50"
>
{showDebugPanel ? t('隐藏调试') : t('显示调试')}
</Button>
);
};
const SettingsToggle = () => {
if (!styleState.isMobile) return null;
return (
<Button
icon={<Settings size={16} />}
style={{
position: 'absolute',
left: showSettings ? -10 : -20,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 1000,
width: 40,
height: 40,
borderRadius: '0 20px 20px 0',
padding: 0,
boxShadow: '2px 0 8px rgba(0, 0, 0, 0.15)',
}}
onClick={() => setShowSettings(!showSettings)}
2025-04-04 12:00:38 +08:00
theme='solid'
type='primary'
/>
);
};
function CustomInputRender(props) {
const { detailProps } = props;
2025-04-04 12:00:38 +08:00
const { clearContextNode, uploadNode, inputNode, sendNode, onClick } =
detailProps;
2025-04-04 12:00:38 +08:00
return (
<div className="p-4">
<div
className="flex items-end gap-3 p-4 bg-gray-50 rounded-2xl shadow-sm hover:shadow-md transition-shadow"
style={{ border: '1px solid var(--semi-color-border)' }}
onClick={onClick}
>
<div className="flex-1">
{inputNode}
</div>
<Button
theme="solid"
type="primary"
className="!rounded-lg !bg-purple-500 hover:!bg-purple-600 flex-shrink-0"
icon={<IconSend />}
>
{t('发送')}
</Button>
</div>
2025-04-04 12:00:38 +08:00
</div>
);
}
const renderInputArea = useCallback((props) => {
2025-04-04 12:00:38 +08:00
return <CustomInputRender {...props} />;
}, []);
const renderChatBoxAction = useCallback((props) => {
const { message } = props;
const isLoading = message.status === 'loading' || message.status === 'incomplete';
return (
<div className="flex items-center gap-0.5">
{!isLoading && (
<Tooltip content={t('重试')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<RefreshCw size={14} />}
onClick={() => handleMessageReset(message)}
className="!rounded-md !text-gray-400 hover:!text-blue-600 hover:!bg-blue-50 !w-7 !h-7 !p-0 transition-all"
aria-label={t('重试')}
/>
</Tooltip>
)}
{message.content && (
<Tooltip content={t('复制')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<Copy size={14} />}
onClick={() => handleMessageCopy(message)}
className="!rounded-md !text-gray-400 hover:!text-green-600 hover:!bg-green-50 !w-7 !h-7 !p-0 transition-all"
aria-label={t('复制')}
/>
</Tooltip>
)}
{!isLoading && (
<Tooltip content={t('删除')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<Trash2 size={14} />}
onClick={() => handleMessageDelete(message)}
className="!rounded-md !text-gray-400 hover:!text-red-600 hover:!bg-red-50 !w-7 !h-7 !p-0 transition-all"
aria-label={t('删除')}
/>
</Tooltip>
)}
</div>
);
}, [handleMessageReset, handleMessageCopy, handleMessageDelete, t]);
const renderCustomChatContent = useCallback(
({ message, className }) => {
if (message.status === 'error') {
return (
<div className={`${className} flex items-center p-4 bg-red-50 rounded-xl`}>
<Typography.Text type="danger" className="text-sm">
{message.content || t('请求发生错误')}
</Typography.Text>
</div>
);
}
const toggleReasoningExpansion = (messageId) => {
setMessage(prevMessages =>
prevMessages.map(msg =>
msg.id === messageId && msg.role === 'assistant'
? { ...msg, isReasoningExpanded: !msg.isReasoningExpanded }
: msg
)
);
};
const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete';
let currentExtractedThinkingContent = null;
let currentDisplayableFinalContent = message.content || "";
let thinkingSource = null;
if (message.role === 'assistant') {
let baseContentForDisplay = message.content || "";
let combinedThinkingContent = "";
if (message.reasoningContent) {
combinedThinkingContent = message.reasoningContent;
thinkingSource = 'reasoningContent';
}
if (baseContentForDisplay.includes('<think>')) {
const thinkTagRegex = /<think>([\s\S]*?)<\/think>/g;
let match;
let thoughtsFromPairedTags = [];
let replyParts = [];
let lastIndex = 0;
while ((match = thinkTagRegex.exec(baseContentForDisplay)) !== null) {
replyParts.push(baseContentForDisplay.substring(lastIndex, match.index));
thoughtsFromPairedTags.push(match[1]);
lastIndex = match.index + match[0].length;
}
replyParts.push(baseContentForDisplay.substring(lastIndex));
if (thoughtsFromPairedTags.length > 0) {
const pairedThoughtsStr = thoughtsFromPairedTags.join('\n\n---\n\n');
if (combinedThinkingContent) {
combinedThinkingContent += '\n\n---\n\n' + pairedThoughtsStr;
} else {
combinedThinkingContent = pairedThoughtsStr;
}
thinkingSource = thinkingSource ? thinkingSource + ' & <think> tags' : '<think> tags';
}
baseContentForDisplay = replyParts.join('');
}
if (isThinkingStatus) {
const lastOpenThinkIndex = baseContentForDisplay.lastIndexOf('<think>');
if (lastOpenThinkIndex !== -1) {
const fragmentAfterLastOpen = baseContentForDisplay.substring(lastOpenThinkIndex);
if (!fragmentAfterLastOpen.includes('</think>')) {
const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim();
if (unclosedThought) {
if (combinedThinkingContent) {
combinedThinkingContent += '\n\n---\n\n' + unclosedThought;
} else {
combinedThinkingContent = unclosedThought;
}
thinkingSource = thinkingSource ? thinkingSource + ' + streaming <think>' : 'streaming <think>';
}
baseContentForDisplay = baseContentForDisplay.substring(0, lastOpenThinkIndex);
}
}
}
currentExtractedThinkingContent = combinedThinkingContent || null;
currentDisplayableFinalContent = baseContentForDisplay.replace(/<\/?think>/g, '').trim();
}
const headerText = isThinkingStatus ? t('思考中...') : t('思考过程');
const finalExtractedThinkingContent = currentExtractedThinkingContent;
const finalDisplayableFinalContent = currentDisplayableFinalContent;
if (message.role === 'assistant' &&
isThinkingStatus &&
!finalExtractedThinkingContent &&
(!finalDisplayableFinalContent || finalDisplayableFinalContent.trim() === '')) {
return (
<div className={`${className} flex items-center gap-4 p-6 bg-gradient-to-r from-purple-50 to-indigo-50 rounded-2xl`}>
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg">
<Loader2 className="animate-spin text-white" size={20} />
</div>
<div className="flex flex-col">
<Typography.Text strong className="text-gray-800 text-base">
{t('正在思考...')}
</Typography.Text>
<Typography.Text className="text-gray-500 text-sm">
AI 正在分析您的问题
</Typography.Text>
</div>
</div>
);
}
return (
<div className={className}>
{message.role === 'assistant' && finalExtractedThinkingContent && (
<div className="bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50 rounded-2xl mb-4 overflow-hidden shadow-sm backdrop-blur-sm">
<div
className="flex items-center justify-between p-5 cursor-pointer hover:bg-gradient-to-r hover:from-white/40 hover:to-purple-50/60 transition-all"
onClick={() => toggleReasoningExpansion(message.id)}
>
<div className="flex items-center gap-4">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg">
<Brain className="text-white" size={16} />
</div>
<div className="flex flex-col">
<Typography.Text strong className="text-gray-800 text-base">
{headerText}
</Typography.Text>
{thinkingSource && (
<Typography.Text className="text-gray-500 text-xs mt-0.5">
来源: {thinkingSource}
</Typography.Text>
)}
</div>
</div>
<div className="flex items-center gap-3">
{isThinkingStatus && (
<div className="flex items-center gap-2">
<Loader2 className="animate-spin text-purple-500" size={18} />
<Typography.Text className="text-purple-600 text-sm font-medium">
思考中
</Typography.Text>
</div>
)}
{!isThinkingStatus && (
<div className="w-6 h-6 rounded-full bg-purple-100 flex items-center justify-center">
{message.isReasoningExpanded ?
<ChevronUp size={16} className="text-purple-600" /> :
<ChevronRight size={16} className="text-purple-600" />
}
</div>
)}
</div>
</div>
<div
className={`transition-all duration-500 ease-out ${message.isReasoningExpanded ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
} overflow-hidden`}
>
{message.isReasoningExpanded && (
<div className="p-5 pt-4">
<div className="bg-white/70 backdrop-blur-sm rounded-xl p-4 shadow-inner overflow-x-auto max-h-50 overflow-y-auto">
<div className="prose prose-sm prose-purple max-w-none">
<MarkdownRender raw={finalExtractedThinkingContent} />
</div>
</div>
</div>
)}
</div>
</div>
)}
{(finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') && (
<div className="prose prose-sm prose-gray max-w-none overflow-x-auto">
<MarkdownRender raw={finalDisplayableFinalContent} />
</div>
)}
</div>
);
},
[t, setMessage],
);
2024-09-26 00:59:09 +08:00
return (
<div className="min-h-screen bg-gray-50">
<Layout style={{ height: '100vh', background: 'transparent' }}>
{(showSettings || !styleState.isMobile) && (
<Layout.Sider
style={{
background: 'transparent',
borderRight: 'none',
flexShrink: 0,
minWidth: 320,
maxWidth: 320,
height: 'calc(100vh - 100px)',
}}
width={320}
>
<Card className="!rounded-2xl h-full flex flex-col" bodyStyle={{ padding: '24px', height: '100%', display: 'flex', flexDirection: 'column' }}>
<div className="flex items-center justify-between mb-6 flex-shrink-0">
<div className="flex items-center">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-purple-500 to-blue-500 flex items-center justify-center mr-3">
<SlidersHorizontal size={20} className="text-white" />
</div>
<Typography.Title heading={5} className="mb-0">
{t('模型设置')}
</Typography.Title>
</div>
<DebugToggle />
</div>
<div className="space-y-6 overflow-y-auto flex-1 pr-2 model-settings-scroll">
{/* 分组选择 */}
<div>
<div className="flex items-center gap-2 mb-2">
<Users size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
{t('分组')}
</Typography.Text>
</div>
<Select
placeholder={t('请选择分组')}
name='group'
required
selection
onChange={(value) => handleInputChange('group', value)}
value={inputs.group}
autoComplete='new-password'
optionList={groups}
renderOptionItem={renderGroupOption}
style={{ width: '100%' }}
className="!rounded-lg"
/>
</div>
{/* 模型选择 */}
<div>
<div className="flex items-center gap-2 mb-2">
<Sparkles size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
{t('模型')}
</Typography.Text>
</div>
<Select
placeholder={t('请选择模型')}
name='model'
required
selection
searchPosition='dropdown'
filter
onChange={(value) => handleInputChange('model', value)}
value={inputs.model}
autoComplete='new-password'
optionList={models}
className="!rounded-lg"
/>
</div>
{/* Temperature */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.temperature ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Thermometer size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Temperature
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.temperature}
</Tag>
</div>
<Button
theme={parameterEnabled.temperature ? 'solid' : 'borderless'}
type={parameterEnabled.temperature ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.temperature ? <Check size={10} /> : <X size={10} />}
onClick={() => handleParameterToggle('temperature')}
className="!rounded-full !w-6 !h-6 !p-0 !min-w-0"
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
控制输出的随机性和创造性
</Typography.Text>
<Slider
step={0.1}
min={0.1}
max={1}
value={inputs.temperature}
onChange={(value) => handleInputChange('temperature', value)}
className="mt-2"
disabled={!parameterEnabled.temperature}
/>
</div>
{/* Top P */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.top_p ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Target size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Top P
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.top_p}
</Tag>
</div>
<Button
theme={parameterEnabled.top_p ? 'solid' : 'borderless'}
type={parameterEnabled.top_p ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.top_p ? <Check size={10} /> : <X size={10} />}
onClick={() => handleParameterToggle('top_p')}
className="!rounded-full !w-6 !h-6 !p-0 !min-w-0"
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
核采样控制词汇选择的多样性
</Typography.Text>
<Slider
step={0.1}
min={0.1}
max={1}
value={inputs.top_p}
onChange={(value) => handleInputChange('top_p', value)}
className="mt-2"
disabled={!parameterEnabled.top_p}
/>
</div>
{/* Frequency Penalty */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.frequency_penalty ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Repeat size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Frequency Penalty
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.frequency_penalty}
</Tag>
</div>
<Button
theme={parameterEnabled.frequency_penalty ? 'solid' : 'borderless'}
type={parameterEnabled.frequency_penalty ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.frequency_penalty ? <Check size={10} /> : <X size={10} />}
onClick={() => handleParameterToggle('frequency_penalty')}
className="!rounded-full !w-6 !h-6 !p-0 !min-w-0"
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
频率惩罚减少重复词汇的出现
</Typography.Text>
<Slider
step={0.1}
min={-2}
max={2}
value={inputs.frequency_penalty}
onChange={(value) => handleInputChange('frequency_penalty', value)}
className="mt-2"
disabled={!parameterEnabled.frequency_penalty}
/>
</div>
2024-09-26 00:59:09 +08:00
{/* Presence Penalty */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.presence_penalty ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Ban size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Presence Penalty
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.presence_penalty}
</Tag>
</div>
<Button
theme={parameterEnabled.presence_penalty ? 'solid' : 'borderless'}
type={parameterEnabled.presence_penalty ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.presence_penalty ? <Check size={10} /> : <X size={10} />}
onClick={() => handleParameterToggle('presence_penalty')}
className="!rounded-full !w-6 !h-6 !p-0 !min-w-0"
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
存在惩罚鼓励讨论新话题
</Typography.Text>
<Slider
step={0.1}
min={-2}
max={2}
value={inputs.presence_penalty}
onChange={(value) => handleInputChange('presence_penalty', value)}
className="mt-2"
disabled={!parameterEnabled.presence_penalty}
/>
</div>
{/* MaxTokens */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.max_tokens ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Hash size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Max Tokens
</Typography.Text>
</div>
<Button
theme={parameterEnabled.max_tokens ? 'solid' : 'borderless'}
type={parameterEnabled.max_tokens ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.max_tokens ? <Check size={10} /> : <X size={10} />}
onClick={() => handleParameterToggle('max_tokens')}
className="!rounded-full !w-6 !h-6 !p-0 !min-w-0"
/>
</div>
<Input
placeholder='MaxTokens'
name='max_tokens'
required
autoComplete='new-password'
defaultValue={0}
value={inputs.max_tokens}
onChange={(value) => handleInputChange('max_tokens', value)}
className="!rounded-lg"
disabled={!parameterEnabled.max_tokens}
/>
</div>
{/* Seed */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.seed ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Shuffle size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Seed
</Typography.Text>
<Typography.Text className="text-xs text-gray-400">
(可选用于复现结果)
</Typography.Text>
</div>
<Button
theme={parameterEnabled.seed ? 'solid' : 'borderless'}
type={parameterEnabled.seed ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.seed ? <Check size={10} /> : <X size={10} />}
onClick={() => handleParameterToggle('seed')}
className="!rounded-full !w-6 !h-6 !p-0 !min-w-0"
/>
</div>
<Input
placeholder='随机种子 (留空为随机)'
name='seed'
autoComplete='new-password'
value={inputs.seed || ''}
onChange={(value) => handleInputChange('seed', value === '' ? null : value)}
className="!rounded-lg"
disabled={!parameterEnabled.seed}
/>
</div>
{/* Stream Toggle */}
<div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ToggleLeft size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
流式输出
</Typography.Text>
</div>
<Button
theme={inputs.stream ? 'solid' : 'borderless'}
type={inputs.stream ? 'primary' : 'tertiary'}
size="small"
onClick={() => handleInputChange('stream', !inputs.stream)}
className="!rounded-full"
>
{inputs.stream ? '开启' : '关闭'}
</Button>
</div>
</div>
{/* System Prompt */}
<div>
<div className="flex items-center gap-2 mb-2">
<Type size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
System Prompt
</Typography.Text>
</div>
<TextArea
placeholder='System Prompt'
name='system'
required
autoComplete='new-password'
autosize
defaultValue={systemPrompt}
onChange={(value) => setSystemPrompt(value)}
className="!rounded-lg"
maxHeight={200}
/>
</div>
</div>
</Card>
</Layout.Sider>
)}
<Layout.Content className="relative flex-1 overflow-hidden">
<div className="px-4 overflow-hidden flex gap-4" style={{ height: 'calc(100vh - 100px)' }}>
<div className="flex-1 flex flex-col">
<SettingsToggle />
<Card
className="!rounded-2xl h-full"
bodyStyle={{ padding: 0, height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
>
{/* 聊天头部 */}
<div className="px-6 py-4 bg-gradient-to-r from-purple-500 to-blue-500 rounded-t-2xl">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur flex items-center justify-center">
<MessageSquare size={20} className="text-white" />
</div>
<div>
<Typography.Title heading={5} className="!text-white mb-0">
{t('AI 对话')}
</Typography.Title>
<Typography.Text className="!text-white/80 text-sm">
{inputs.model || t('选择模型开始对话')}
</Typography.Text>
</div>
</div>
</div>
{/* 聊天内容区域 */}
<div className="flex-1 overflow-hidden">
<Chat
chatBoxRenderConfig={{
renderChatBoxContent: renderCustomChatContent,
renderChatBoxAction: renderChatBoxAction,
}}
renderInputArea={renderInputArea}
roleConfig={roleInfo}
style={{
height: '100%',
maxWidth: '100%',
overflow: 'hidden'
}}
chats={message}
onMessageSend={onMessageSend}
onMessageCopy={handleMessageCopy}
onMessageReset={handleMessageReset}
onMessageDelete={handleMessageDelete}
showClearContext
showStopGenerate
onStopGenerator={onStopGenerator}
onClear={() => setMessage([])}
className="h-full"
placeholder={t('请输入您的问题...')}
/>
</div>
</Card>
</div>
{/* 调试面板 */}
{showDebugPanel && (
<div className="w-96 flex-shrink-0">
<Card className="!rounded-2xl h-full flex flex-col" bodyStyle={{ padding: '24px', height: '100%', display: 'flex', flexDirection: 'column' }}>
<div className="flex items-center mb-6 flex-shrink-0">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-green-500 to-blue-500 flex items-center justify-center mr-3">
<Code size={20} className="text-white" />
</div>
<Typography.Title heading={5} className="mb-0">
{t('调试信息')}
</Typography.Title>
</div>
<div className="flex-1 overflow-hidden debug-panel">
<Tabs
type="line"
className="h-full"
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
activeKey={activeDebugTab}
onChange={setActiveDebugTab}
>
<TabPane tab={
<div className="flex items-center gap-2">
<FileText size={16} />
{t('请求体')}
</div>
} itemKey="request">
<div className="h-full overflow-y-auto bg-gray-50 rounded-lg p-4 model-settings-scroll">
{debugData.request ? (
<pre className="debug-code text-gray-700 whitespace-pre-wrap break-words">
{JSON.stringify(debugData.request, null, 2)}
</pre>
) : (
<Typography.Text type="secondary" className="text-sm">
{t('暂无请求数据')}
</Typography.Text>
)}
</div>
</TabPane>
<TabPane tab={
<div className="flex items-center gap-2">
<Zap size={16} />
{t('响应内容')}
</div>
} itemKey="response">
<div className="h-full overflow-y-auto bg-gray-50 rounded-lg p-4 model-settings-scroll">
{debugData.response ? (
<pre className="debug-code text-gray-700 whitespace-pre-wrap break-words">
{debugData.response}
</pre>
) : (
<Typography.Text type="secondary" className="text-sm">
{t('暂无响应数据')}
</Typography.Text>
)}
</div>
</TabPane>
</Tabs>
</div>
{debugData.timestamp && (
<div className="flex items-center gap-2 mt-4 pt-4 flex-shrink-0">
<Clock size={14} className="text-gray-500" />
<Typography.Text className="text-xs text-gray-500">
{t('最后更新')}: {new Date(debugData.timestamp).toLocaleString()}
</Typography.Text>
</div>
)}
</Card>
</div>
)}
</div>
</Layout.Content>
</Layout>
</div>
2024-09-26 00:59:09 +08:00
);
};
export default Playground;