Commit b5069c28 by Apple\Apple

feat: enhance chat playground with improved message handling and UI

- Add message operations (copy, retry, delete) with tooltips
- Improve thinking chain processing and display logic
- Enhance error handling and debug information presentation
- Optimize UI layout and responsive design
  - Fix height calculations for better viewport usage
  - Improve mobile view adaptability
- Add comprehensive error feedback for message operations
- Implement fallback clipboard functionality
- Refine debug panel with better error tracking
- Add transition animations for better UX
- Update styling with modern gradient backgrounds
parent 97cbe9b5
...@@ -84,17 +84,6 @@ body { ...@@ -84,17 +84,6 @@ body {
display: revert; display: revert;
} }
.semi-chat {
padding-top: 0 !important;
padding-bottom: 0 !important;
height: 100%;
}
.semi-chat-chatBox-content {
min-width: auto;
word-break: break-word;
}
.tableHiddle { .tableHiddle {
display: none !important; display: none !important;
} }
...@@ -144,4 +133,114 @@ code { ...@@ -144,4 +133,114 @@ code {
.semi-tabs-content { .semi-tabs-content {
padding: 0 !important; padding: 0 !important;
}
/* 聊天 */
.semi-chat {
padding-top: 0 !important;
padding-bottom: 0 !important;
height: 100%;
max-width: 100% !important;
width: 100% !important;
overflow: hidden !important;
}
.semi-chat-chatBox {
max-width: 100% !important;
overflow: hidden !important;
}
.semi-chat-chatBox-wrap {
max-width: 100% !important;
overflow: hidden !important;
}
.semi-chat-chatBox-content {
min-width: auto;
word-break: break-word;
max-width: 100% !important;
overflow-wrap: break-word !important;
}
.semi-chat-content {
max-width: 100% !important;
overflow: hidden !important;
}
.semi-chat-list {
max-width: 100% !important;
overflow-x: hidden !important;
}
.semi-chat-container {
overflow-x: hidden !important;
}
.semi-chat-container::-webkit-scrollbar {
width: 4px;
}
.semi-chat-container::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
}
.semi-chat-container::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.15);
}
.semi-chat-container::-webkit-scrollbar-track {
background: transparent;
}
/* 隐藏模型设置区域的滚动条 */
.model-settings-scroll::-webkit-scrollbar {
display: none;
}
.model-settings-scroll {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* 调试面板代码样式 */
.debug-code {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 11px;
line-height: 1.4;
}
/* 调试面板标签样式 */
.semi-tabs-content {
height: calc(100% - 40px) !important;
flex: 1 !important;
}
.semi-tabs-content .semi-tabs-pane {
height: 100% !important;
overflow: hidden !important;
}
.semi-tabs-content .semi-tabs-pane>div {
height: 100% !important;
}
/* 调试面板特定样式 */
.debug-panel .semi-tabs {
height: 100% !important;
display: flex !important;
flex-direction: column !important;
}
.debug-panel .semi-tabs-bar {
flex-shrink: 0 !important;
}
.debug-panel .semi-tabs-content {
flex: 1 !important;
overflow: hidden !important;
}
.semi-chat-chatBox-action {
column-gap: 0 !important;
} }
\ No newline at end of file
...@@ -19,12 +19,47 @@ import { ...@@ -19,12 +19,47 @@ import {
Button, Button,
MarkdownRender, MarkdownRender,
Tag, Tag,
Tabs,
TabPane,
Toast,
Tooltip,
Modal,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { SSE } from 'sse'; import { SSE } from 'sse';
import { IconSetting, IconSpin, IconChevronRight, IconChevronUp } from '@douyinfe/semi-icons'; 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 { StyleContext } from '../../context/Style/index.js';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { renderGroupOption, truncateText, stringToColor } from '../../helpers/render.js'; import { renderGroupOption, truncateText, stringToColor } from '../../helpers/render.js';
import { IconSend } from '@douyinfe/semi-icons';
let id = 4; let id = 4;
function getId() { function getId() {
...@@ -89,6 +124,19 @@ const Playground = () => { ...@@ -89,6 +124,19 @@ const Playground = () => {
group: '', group: '',
max_tokens: 0, max_tokens: 0,
temperature: 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,
}); });
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [status, setStatus] = useState({}); const [status, setStatus] = useState({});
...@@ -99,6 +147,13 @@ const Playground = () => { ...@@ -99,6 +147,13 @@ const Playground = () => {
const [models, setModels] = useState([]); const [models, setModels] = useState([]);
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [showSettings, setShowSettings] = useState(true); 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 [styleState, styleDispatch] = useContext(StyleContext);
const sseSourceRef = useRef(null); const sseSourceRef = useRef(null);
...@@ -106,6 +161,13 @@ const Playground = () => { ...@@ -106,6 +161,13 @@ const Playground = () => {
setInputs((inputs) => ({ ...inputs, [name]: value })); setInputs((inputs) => ({ ...inputs, [name]: value }));
}; };
const handleParameterToggle = (paramName) => {
setParameterEnabled(prev => ({
...prev,
[paramName]: !prev[paramName]
}));
};
useEffect(() => { useEffect(() => {
if (searchParams.get('expired')) { if (searchParams.get('expired')) {
showError(t('未登录或登录已过期,请重新登录!')); showError(t('未登录或登录已过期,请重新登录!'));
...@@ -184,12 +246,6 @@ const Playground = () => { ...@@ -184,12 +246,6 @@ const Playground = () => {
} }
}; };
const commonOuterStyle = {
border: '1px solid var(--semi-color-border)',
borderRadius: '16px',
margin: '0px 8px',
};
const getSystemMessage = () => { const getSystemMessage = () => {
if (systemPrompt !== '') { if (systemPrompt !== '') {
return { return {
...@@ -201,7 +257,157 @@ const Playground = () => { ...@@ -201,7 +257,157 @@ const Playground = () => {
} }
}; };
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()
};
// 记录HTTP错误到调试数据
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 || '';
// 处理 <think> 标签格式的思维链
if (content.includes('<think>')) {
const thinkTagRegex = /<think>([\s\S]*?)<\/think>/g;
let thoughts = [];
let replyParts = [];
let lastIndex = 0;
let match;
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('').trim();
if (thoughts.length > 0) {
reasoningContent = thoughts.join('\n\n---\n\n');
}
}
// 更新消息
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
};
// 如果是 fetch 错误,尝试获取更多信息
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;
});
}
};
let handleSSE = (payload) => { let handleSSE = (payload) => {
// 记录请求数据并自动切换到请求体标签
setDebugData(prev => ({
...prev,
request: payload,
timestamp: new Date().toISOString(),
response: null
}));
setActiveDebugTab('request');
let source = new SSE('/pg/chat/completions', { let source = new SSE('/pg/chat/completions', {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...@@ -214,16 +420,32 @@ const Playground = () => { ...@@ -214,16 +420,32 @@ const Playground = () => {
// 保存 source 引用以便后续停止生成 // 保存 source 引用以便后续停止生成
sseSourceRef.current = source; sseSourceRef.current = source;
let responseData = '';
let hasReceivedFirstResponse = false;
source.addEventListener('message', (e) => { source.addEventListener('message', (e) => {
if (e.data === '[DONE]') { if (e.data === '[DONE]') {
source.close(); source.close();
sseSourceRef.current = null; sseSourceRef.current = null;
// 记录完整响应
setDebugData(prev => ({
...prev,
response: responseData
}));
completeMessage(); completeMessage();
return; return;
} }
try { try {
let payload = JSON.parse(e.data); let payload = JSON.parse(e.data);
responseData += e.data + '\n';
// 收到第一个响应时自动切换到响应标签
if (!hasReceivedFirstResponse) {
setActiveDebugTab('response');
hasReceivedFirstResponse = true;
}
const delta = payload.choices?.[0]?.delta; const delta = payload.choices?.[0]?.delta;
if (delta) { if (delta) {
if (delta.reasoning_content) { if (delta.reasoning_content) {
...@@ -235,6 +457,15 @@ const Playground = () => { ...@@ -235,6 +457,15 @@ const Playground = () => {
} }
} catch (error) { } catch (error) {
console.error('Failed to parse SSE message:', 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'); streamMessageUpdate(t('解析响应数据时发生错误'), 'content');
completeMessage('error'); completeMessage('error');
} }
...@@ -243,6 +474,22 @@ const Playground = () => { ...@@ -243,6 +474,22 @@ const Playground = () => {
source.addEventListener('error', (e) => { source.addEventListener('error', (e) => {
console.error('SSE Error:', e); console.error('SSE Error:', e);
const errorMessage = e.data || t('请求发生错误'); 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'); streamMessageUpdate(errorMessage, 'content');
completeMessage('error'); completeMessage('error');
sseSourceRef.current = null; sseSourceRef.current = null;
...@@ -252,6 +499,20 @@ const Playground = () => { ...@@ -252,6 +499,20 @@ const Playground = () => {
source.addEventListener('readystatechange', (e) => { source.addEventListener('readystatechange', (e) => {
if (e.readyState >= 2) { if (e.readyState >= 2) {
if (source.status !== undefined && source.status !== 200) { 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');
source.close(); source.close();
streamMessageUpdate(t('连接已断开'), 'content'); streamMessageUpdate(t('连接已断开'), 'content');
completeMessage('error'); completeMessage('error');
...@@ -263,6 +524,19 @@ const Playground = () => { ...@@ -263,6 +524,19 @@ const Playground = () => {
source.stream(); source.stream();
} catch (error) { } catch (error) {
console.error('Failed to start SSE stream:', 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'); streamMessageUpdate(t('建立连接时发生错误'), 'content');
completeMessage('error'); completeMessage('error');
} }
...@@ -293,17 +567,44 @@ const Playground = () => { ...@@ -293,17 +567,44 @@ const Playground = () => {
if (systemMessage) { if (systemMessage) {
messages.unshift(systemMessage); messages.unshift(systemMessage);
} }
return { const payload = {
messages: messages, messages: messages,
stream: true, stream: inputs.stream,
model: inputs.model, model: inputs.model,
group: inputs.group, group: inputs.group,
max_tokens: parseInt(inputs.max_tokens),
temperature: inputs.temperature,
}; };
// 只添加启用的参数
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;
}; };
handleSSE(getPayload()); const payload = getPayload();
if (inputs.stream) {
handleSSE(payload);
} else {
handleNonStreamRequest(payload);
}
newMessage.push({ newMessage.push({
role: 'assistant', role: 'assistant',
content: '', content: '',
...@@ -347,10 +648,27 @@ const Playground = () => { ...@@ -347,10 +648,27 @@ const Playground = () => {
status: 'incomplete', status: 'incomplete',
}; };
} else if (type === 'content') { } else if (type === 'content') {
// 当开始接收 content 时,说明思考部分已经完成,应该折叠思考面板
const shouldCollapseReasoning = !lastMessage.content && lastMessage.reasoningContent && lastMessage.isReasoningExpanded;
const newContent = (lastMessage.content || '') + textChunk;
// 检测 </think> 标签的完成
let shouldCollapseFromThinkTag = false;
if (lastMessage.isReasoningExpanded && newContent.includes('</think>')) {
// 检查是否有完整的 <think>...</think> 对
const thinkMatches = newContent.match(/<think>/g);
const thinkCloseMatches = newContent.match(/<\/think>/g);
if (thinkMatches && thinkCloseMatches && thinkCloseMatches.length >= thinkMatches.length) {
shouldCollapseFromThinkTag = true;
}
}
newMessage = { newMessage = {
...newMessage, ...newMessage,
content: (lastMessage.content || '') + textChunk, content: newContent,
status: 'incomplete', status: 'incomplete',
isReasoningExpanded: (shouldCollapseReasoning || shouldCollapseFromThinkTag) ? false : lastMessage.isReasoningExpanded,
}; };
} }
} }
...@@ -358,6 +676,167 @@ const Playground = () => { ...@@ -358,6 +676,167 @@ const Playground = () => {
}); });
}, [setMessage]); }, [setMessage]);
// 处理消息复制
const handleMessageCopy = useCallback((message) => {
if (!message.content) return;
// 现代浏览器的 Clipboard API
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);
// 如果 Clipboard API 失败,尝试回退方案
fallbackCopyToClipboard(message.content);
});
} else {
// 回退方案:使用传统的 document.execCommand
fallbackCopyToClipboard(message.content);
}
}, [t]);
// 回退复制方案
const fallbackCopyToClipboard = useCallback((text) => {
try {
// 检查是否支持 execCommand
if (!document.execCommand) {
throw new Error('execCommand not supported');
}
// 创建一个临时的 textarea 元素
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);
}
// 使用 execCommand 复制
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(() => { const onStopGenerator = useCallback(() => {
if (sseSourceRef.current) { if (sseSourceRef.current) {
sseSourceRef.current.close(); sseSourceRef.current.close();
...@@ -403,11 +882,26 @@ const Playground = () => { ...@@ -403,11 +882,26 @@ const Playground = () => {
} }
}, [setMessage]); }, [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 = () => { const SettingsToggle = () => {
if (!styleState.isMobile) return null; if (!styleState.isMobile) return null;
return ( return (
<Button <Button
icon={<IconSetting />} icon={<Settings size={16} />}
style={{ style={{
position: 'absolute', position: 'absolute',
left: showSettings ? -10 : -20, left: showSettings ? -10 : -20,
...@@ -433,20 +927,24 @@ const Playground = () => { ...@@ -433,20 +927,24 @@ const Playground = () => {
detailProps; detailProps;
return ( return (
<div <div className="p-4">
style={{ <div
margin: '8px 16px', className="flex items-end gap-3 p-4 bg-gray-50 rounded-2xl shadow-sm hover:shadow-md transition-shadow"
display: 'flex', style={{ border: '1px solid var(--semi-color-border)' }}
flexDirection: 'row', onClick={onClick}
alignItems: 'flex-end', >
borderRadius: 16, <div className="flex-1">
padding: 10, {inputNode}
border: '1px solid var(--semi-color-border)', </div>
}} <Button
onClick={onClick} theme="solid"
> type="primary"
{inputNode} className="!rounded-lg !bg-purple-500 hover:!bg-purple-600 flex-shrink-0"
{sendNode} icon={<IconSend />}
>
{t('发送')}
</Button>
</div>
</div> </div>
); );
} }
...@@ -455,17 +953,71 @@ const Playground = () => { ...@@ -455,17 +953,71 @@ const Playground = () => {
return <CustomInputRender {...props} />; 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>
)}
{/* 删除按钮 - 只在消息完成或出错时显示,AI输出时隐藏 */}
{!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( const renderCustomChatContent = useCallback(
({ message, className }) => { ({ message, className }) => {
if (message.status === 'error') { if (message.status === 'error') {
return ( return (
<div className={className} style={{ <div className={`${className} flex items-center p-4 bg-red-50 rounded-xl`}>
display: 'flex', <Typography.Text type="danger" className="text-sm">
alignItems: 'center', {message.content || t('请求发生错误')}
padding: '12px', </Typography.Text>
color: 'var(--semi-color-danger)'
}}>
<Typography.Text type="danger">{message.content || t('请求发生错误')}</Typography.Text>
</div> </div>
); );
} }
...@@ -494,6 +1046,8 @@ const Playground = () => { ...@@ -494,6 +1046,8 @@ const Playground = () => {
let thoughts = []; let thoughts = [];
let replyParts = []; let replyParts = [];
let lastIndex = 0; let lastIndex = 0;
// 使用更安全的正则表达式,只匹配完整的 think 标签对
const thinkTagRegex = /<think>([\s\S]*?)<\/think>/g; const thinkTagRegex = /<think>([\s\S]*?)<\/think>/g;
let match; let match;
...@@ -505,35 +1059,54 @@ const Playground = () => { ...@@ -505,35 +1059,54 @@ const Playground = () => {
} }
replyParts.push(fullContent.substring(lastIndex)); replyParts.push(fullContent.substring(lastIndex));
currentDisplayableFinalContent = replyParts.join('').trim(); // 处理剩余的内容,移除未闭合的 think 标签
let finalContent = replyParts.join('');
if (thoughts.length > 0) { // 如果还有未闭合的 <think> 标签,将其内容提取到思考区域
currentExtractedThinkingContent = thoughts.join('\n\n---\n\n'); if (isThinkingStatus) {
thinkingSource = '<think> tags'; const lastOpenThinkIndex = finalContent.lastIndexOf('<think>');
}
if (isThinkingStatus && currentDisplayableFinalContent.includes('<think')) {
const lastOpenThinkIndex = currentDisplayableFinalContent.lastIndexOf('<think>');
if (lastOpenThinkIndex !== -1) { if (lastOpenThinkIndex !== -1) {
const fragmentAfterLastOpen = currentDisplayableFinalContent.substring(lastOpenThinkIndex); const fragmentAfterLastOpen = finalContent.substring(lastOpenThinkIndex);
if (!fragmentAfterLastOpen.substring("<think>".length).includes('</think>')) { // 检查是否有对应的闭合标签
const unclosedThought = fragmentAfterLastOpen.substring("<think>".length); if (!fragmentAfterLastOpen.includes('</think>')) {
if (currentExtractedThinkingContent) { // 提取未闭合的思考内容
currentExtractedThinkingContent += (currentExtractedThinkingContent ? '\n\n---\n\n' : '') + unclosedThought; const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length);
} else { if (unclosedThought.trim()) {
currentExtractedThinkingContent = unclosedThought; if (currentExtractedThinkingContent) {
currentExtractedThinkingContent += '\n\n---\n\n' + unclosedThought;
} else {
currentExtractedThinkingContent = unclosedThought;
}
if (!thinkingSource) thinkingSource = '<think> tags (streaming)';
} }
if (!thinkingSource && unclosedThought) thinkingSource = '<think> tags (streaming)'; // 移除未闭合的 think 标签部分
currentDisplayableFinalContent = currentDisplayableFinalContent.substring(0, lastOpenThinkIndex).trim(); finalContent = finalContent.substring(0, lastOpenThinkIndex);
} }
} }
} }
currentDisplayableFinalContent = finalContent.trim();
if (thoughts.length > 0) {
if (currentExtractedThinkingContent) {
currentExtractedThinkingContent = thoughts.join('\n\n---\n\n') + '\n\n---\n\n' + currentExtractedThinkingContent;
} else {
currentExtractedThinkingContent = thoughts.join('\n\n---\n\n');
}
thinkingSource = '<think> tags';
}
} }
if (typeof currentDisplayableFinalContent === 'string' && currentDisplayableFinalContent.trim().startsWith("<think>")) { // 清理任何剩余的不完整 think 标签
const startsWithCompleteThinkTagRegex = /^<think>[\s\S]*?<\/think>/; if (typeof currentDisplayableFinalContent === 'string') {
if (!startsWithCompleteThinkTagRegex.test(currentDisplayableFinalContent.trim())) { // 移除任何孤立的 <think> 开始标签
currentDisplayableFinalContent = ""; currentDisplayableFinalContent = currentDisplayableFinalContent.replace(/<think>\s*$/g, '');
// 如果内容以 <think> 开始但没有完整的标签对,清空内容
if (currentDisplayableFinalContent.trim().startsWith("<think>")) {
const startsWithCompleteThinkTagRegex = /^<think>[\s\S]*?<\/think>/;
if (!startsWithCompleteThinkTagRegex.test(currentDisplayableFinalContent.trim())) {
currentDisplayableFinalContent = "";
}
} }
} }
} }
...@@ -547,9 +1120,18 @@ const Playground = () => { ...@@ -547,9 +1120,18 @@ const Playground = () => {
!finalExtractedThinkingContent && !finalExtractedThinkingContent &&
(!finalDisplayableFinalContent || finalDisplayableFinalContent.trim() === '')) { (!finalDisplayableFinalContent || finalDisplayableFinalContent.trim() === '')) {
return ( return (
<div className={className} style={{ display: 'flex', alignItems: 'center', padding: '12px' }}> <div className={`${className} flex items-center gap-4 p-6 bg-gradient-to-r from-purple-50 to-indigo-50 rounded-2xl`}>
<IconSpin spin /> <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">
<Typography.Text type="secondary" style={{ marginLeft: '8px' }}>{t('正在思考...')}</Typography.Text> <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> </div>
); );
} }
...@@ -557,56 +1139,66 @@ const Playground = () => { ...@@ -557,56 +1139,66 @@ const Playground = () => {
return ( return (
<div className={className}> <div className={className}>
{message.role === 'assistant' && finalExtractedThinkingContent && ( {message.role === 'assistant' && finalExtractedThinkingContent && (
<div style={{ <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">
background: 'var(--semi-color-tertiary-light-hover)',
borderRadius: '16px',
marginBottom: '8px',
overflow: 'hidden',
}}>
<div <div
style={{ 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"
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
cursor: 'pointer',
height: 'auto',
}}
onClick={() => toggleReasoningExpansion(message.id)} onClick={() => toggleReasoningExpansion(message.id)}
> >
<div style={{ display: 'flex', alignItems: 'center' }}> <div className="flex items-center gap-4">
<Typography.Text strong={message.isReasoningExpanded} style={{ fontSize: '13px', color: 'var(--semi-color-text-1)' }}>{headerText}</Typography.Text> <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">
{thinkingSource && ( <Brain className="text-white" size={16} />
<Tag size="small" color='green' shape="circle" style={{ marginLeft: '8px' }}> </div>
{thinkingSource} <div className="flex flex-col">
</Tag> <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>
<div> <div className="flex items-center gap-3">
{isThinkingStatus && <IconSpin spin />} {isThinkingStatus && (
{!isThinkingStatus && (message.isReasoningExpanded ? <IconChevronUp size="small" /> : <IconChevronRight size="small" />)} <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> </div>
<div <div
style={{ className={`transition-all duration-500 ease-out ${message.isReasoningExpanded ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
maxHeight: message.isReasoningExpanded ? '160px' : '0px', } overflow-hidden`}
overflowY: message.isReasoningExpanded ? 'auto' : 'hidden',
overflowX: 'hidden',
transition: 'max-height 0.3s ease-in-out, padding 0.3s ease-in-out',
padding: message.isReasoningExpanded ? '0px 12px 12px 12px' : '0px 12px',
boxSizing: 'border-box',
}}
> >
<MarkdownRender raw={finalExtractedThinkingContent} /> {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>
</div> </div>
)} )}
{(finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') && ( {(finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') && (
<MarkdownRender raw={finalDisplayableFinalContent} /> <div className="prose prose-sm prose-gray max-w-none overflow-x-auto">
)} <MarkdownRender raw={finalDisplayableFinalContent} />
{!(finalExtractedThinkingContent) && !(finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') && message.role === 'assistant' && ( </div>
<div></div>
)} )}
</div> </div>
); );
...@@ -615,115 +1207,460 @@ const Playground = () => { ...@@ -615,115 +1207,460 @@ const Playground = () => {
); );
return ( return (
<Layout style={{ height: '100%' }}> <div className="min-h-screen bg-gray-50">
{(showSettings || !styleState.isMobile) && ( <Layout style={{ height: '100vh', background: 'transparent' }}>
<Layout.Sider {(showSettings || !styleState.isMobile) && (
style={{ display: styleState.isMobile ? 'block' : 'initial' }} <Layout.Sider
> style={{
<Card style={commonOuterStyle}> background: 'transparent',
<div style={{ marginTop: 10 }}> borderRight: 'none',
<Typography.Text strong>{t('分组')}</Typography.Text> flexShrink: 0,
</div> minWidth: 320,
<Select maxWidth: 320,
placeholder={t('请选择分组')} height: 'calc(100vh - 100px)',
name='group' }}
required width={320}
selection >
onChange={(value) => { <Card className="!rounded-2xl h-full flex flex-col" bodyStyle={{ padding: '24px', height: '100%', display: 'flex', flexDirection: 'column' }}>
handleInputChange('group', value); <div className="flex items-center justify-between mb-6 flex-shrink-0">
}} <div className="flex items-center">
value={inputs.group} <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">
autoComplete='new-password' <SlidersHorizontal size={20} className="text-white" />
optionList={groups} </div>
renderOptionItem={renderGroupOption} <Typography.Title heading={5} className="mb-0">
style={{ width: '100%' }} {t('模型设置')}
/> </Typography.Title>
<div style={{ marginTop: 10 }}> </div>
<Typography.Text strong>{t('模型')}</Typography.Text> <DebugToggle />
</div> </div>
<Select
placeholder={t('请选择模型')} <div className="space-y-6 overflow-y-auto flex-1 pr-2 model-settings-scroll">
name='model' {/* 分组选择 */}
required <div>
selection <div className="flex items-center gap-2 mb-2">
searchPosition='dropdown' <Users size={16} className="text-gray-500" />
filter <Typography.Text strong className="text-sm">
onChange={(value) => { {t('分组')}
handleInputChange('model', value); </Typography.Text>
}} </div>
value={inputs.model} <Select
autoComplete='new-password' placeholder={t('请选择分组')}
optionList={models} name='group'
/> required
<div style={{ marginTop: 10 }}> selection
<Typography.Text strong>Temperature</Typography.Text> onChange={(value) => handleInputChange('group', value)}
</div> value={inputs.group}
<Slider autoComplete='new-password'
step={0.1} optionList={groups}
min={0.1} renderOptionItem={renderGroupOption}
max={1} style={{ width: '100%' }}
value={inputs.temperature} className="!rounded-lg"
onChange={(value) => { />
handleInputChange('temperature', value); </div>
}}
/> {/* 模型选择 */}
<div style={{ marginTop: 10 }}> <div>
<Typography.Text strong>MaxTokens</Typography.Text> <div className="flex items-center gap-2 mb-2">
</div> <Sparkles size={16} className="text-gray-500" />
<Input <Typography.Text strong className="text-sm">
placeholder='MaxTokens' {t('模型')}
name='max_tokens' </Typography.Text>
required </div>
autoComplete='new-password' <Select
defaultValue={0} placeholder={t('请选择模型')}
value={inputs.max_tokens} name='model'
onChange={(value) => { required
handleInputChange('max_tokens', value); 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>
<div style={{ marginTop: 10 }}> {/* Presence Penalty */}
<Typography.Text strong>System</Typography.Text> <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> </div>
<TextArea
placeholder='System Prompt' {/* 调试面板 */}
name='system' {showDebugPanel && (
required <div className="w-96 flex-shrink-0">
autoComplete='new-password' <Card className="!rounded-2xl h-full flex flex-col" bodyStyle={{ padding: '24px', height: '100%', display: 'flex', flexDirection: 'column' }}>
autosize <div className="flex items-center mb-6 flex-shrink-0">
defaultValue={systemPrompt} <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">
onChange={(value) => { <Code size={20} className="text-white" />
setSystemPrompt(value); </div>
}} <Typography.Title heading={5} className="mb-0">
/> {t('调试信息')}
</Card> </Typography.Title>
</Layout.Sider> </div>
)}
<Layout.Content> <div className="flex-1 overflow-hidden debug-panel">
<div style={{ height: '100%', position: 'relative' }}> <Tabs
<SettingsToggle /> type="line"
<Chat className="h-full"
chatBoxRenderConfig={{ style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
renderChatBoxContent: renderCustomChatContent, activeKey={activeDebugTab}
renderChatBoxAction: () => { onChange={setActiveDebugTab}
return <div></div>; >
}, <TabPane tab={
}} <div className="flex items-center gap-2">
renderInputArea={renderInputArea} <FileText size={16} />
roleConfig={roleInfo} {t('请求体')}
style={commonOuterStyle} </div>
chats={message} } itemKey="request">
onMessageSend={onMessageSend} <div className="h-full overflow-y-auto bg-gray-50 rounded-lg p-4 model-settings-scroll">
showClearContext {debugData.request ? (
showStopGenerate <pre className="debug-code text-gray-700 whitespace-pre-wrap break-words">
onStopGenerator={onStopGenerator} {JSON.stringify(debugData.request, null, 2)}
onClear={() => { </pre>
setMessage([]); ) : (
}} <Typography.Text type="secondary" className="text-sm">
/> {t('暂无请求数据')}
</div> </Typography.Text>
</Layout.Content> )}
</Layout> </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>
); );
}; };
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment