🌞feat(playground): To enable chain-of-thought (CoT) rendering for inference models in the playground, support reasoningContent and the <think> tag

This commit is contained in:
Apple\Apple
2025-05-26 14:35:35 +08:00
parent 96709dd9f3
commit 39329fcd1c

View File

@@ -1,10 +1,11 @@
import React, { useCallback, useContext, useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom';
import { UserContext } from '../../context/User/index.js';
import {
API,
getUserIdFromLocalStorage,
showError,
getLogo,
} from '../../helpers/index.js';
import {
Card,
@@ -16,38 +17,54 @@ import {
TextArea,
Typography,
Button,
Highlight,
MarkdownRender,
Tag,
} from '@douyinfe/semi-ui';
import { SSE } from 'sse';
import { IconSetting } from '@douyinfe/semi-icons';
import { IconSetting, IconSpin, IconChevronRight, IconChevronUp } from '@douyinfe/semi-icons';
import { StyleContext } from '../../context/Style/index.js';
import { useTranslation } from 'react-i18next';
import { renderGroupOption, truncateText } from '../../helpers/render.js';
const roleInfo = {
user: {
name: 'User',
avatar:
'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/docs-icon.png',
},
assistant: {
name: 'Assistant',
avatar: 'logo.png',
},
system: {
name: 'System',
avatar:
'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/other/logo.png',
},
};
import { renderGroupOption, truncateText, stringToColor } from '../../helpers/render.js';
let id = 4;
function getId() {
return `${id++}`;
}
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)}`;
};
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',
},
};
const defaultMessage = [
{
@@ -61,17 +78,18 @@ const Playground = () => {
id: '3',
createAt: 1715676751919,
content: t('你好,请问有什么可以帮助您的吗?'),
reasoningContent: '',
isReasoningExpanded: false,
},
];
const [inputs, setInputs] = useState({
model: 'gpt-4o-mini',
model: 'deepseek-r1',
group: '',
max_tokens: 0,
temperature: 0,
});
const [searchParams, setSearchParams] = useSearchParams();
const [userState, userDispatch] = useContext(UserContext);
const [status, setStatus] = useState({});
const [systemPrompt, setSystemPrompt] = useState(
'You are a helpful assistant. You can help me by answering my questions. You can also ask me questions.',
@@ -97,7 +115,7 @@ const Playground = () => {
}
loadModels();
loadGroups();
}, []);
}, [searchParams, t]);
const loadModels = async () => {
let res = await API.get(`/api/user/models`);
@@ -121,7 +139,7 @@ const Playground = () => {
label: truncateText(info.desc, '50%'),
value: group,
ratio: info.ratio,
fullLabel: info.desc, // 保存完整文本用于tooltip
fullLabel: info.desc,
}));
if (localGroupOptions.length === 0) {
@@ -186,7 +204,6 @@ const Playground = () => {
payload: JSON.stringify(payload),
});
source.addEventListener('message', (e) => {
// 只有收到 [DONE] 时才结束
if (e.data === '[DONE]') {
source.close();
completeMessage();
@@ -194,14 +211,19 @@ const Playground = () => {
}
let payload = JSON.parse(e.data);
// 检查是否有 delta content
if (payload.choices?.[0]?.delta?.content) {
generateMockResponse(payload.choices[0].delta.content);
const delta = payload.choices?.[0]?.delta;
if (delta) {
if (delta.reasoning_content) {
streamMessageUpdate(delta.reasoning_content, 'reasoning');
}
if (delta.content) {
streamMessageUpdate(delta.content, 'content');
}
}
});
source.addEventListener('error', (e) => {
generateMockResponse(e.data);
streamMessageUpdate(e.data, 'content');
completeMessage('error');
});
@@ -230,7 +252,6 @@ const Playground = () => {
},
];
// 将 getPayload 移到这里
const getPayload = () => {
let systemMessage = getSystemMessage();
let messages = newMessage.map((item) => {
@@ -252,11 +273,12 @@ const Playground = () => {
};
};
// 使用更新后的消息状态调用 handleSSE
handleSSE(getPayload());
newMessage.push({
role: 'assistant',
content: '',
reasoningContent: '',
isReasoningExpanded: true,
createAt: Date.now(),
id: getId(),
status: 'loading',
@@ -264,39 +286,44 @@ const Playground = () => {
return newMessage;
});
},
[getSystemMessage],
[getSystemMessage, inputs, setMessage],
);
const completeMessage = useCallback((status = 'complete') => {
// console.log("Complete Message: ", status)
setMessage((prevMessage) => {
const lastMessage = prevMessage[prevMessage.length - 1];
// only change the status if the last message is not complete and not error
if (lastMessage.status === 'complete' || lastMessage.status === 'error') {
return prevMessage;
}
return [...prevMessage.slice(0, -1), { ...lastMessage, status: status }];
return [...prevMessage.slice(0, -1), { ...lastMessage, status: status, isReasoningExpanded: false }];
});
}, []);
}, [setMessage]);
const generateMockResponse = useCallback((content) => {
// console.log("Generate Mock Response: ", content);
setMessage((message) => {
const lastMessage = message[message.length - 1];
const streamMessageUpdate = useCallback((textChunk, type) => {
setMessage((prevMessage) => {
const lastMessage = prevMessage[prevMessage.length - 1];
let newMessage = { ...lastMessage };
if (
lastMessage.status === 'loading' ||
lastMessage.status === 'incomplete'
) {
newMessage = {
...newMessage,
content: (lastMessage.content || '') + content,
status: 'incomplete',
};
if (type === 'reasoning') {
newMessage = {
...newMessage,
reasoningContent: (lastMessage.reasoningContent || '') + textChunk,
status: 'incomplete',
};
} else if (type === 'content') {
newMessage = {
...newMessage,
content: (lastMessage.content || '') + textChunk,
status: 'incomplete',
};
}
}
return [...message.slice(0, -1), newMessage];
return [...prevMessage.slice(0, -1), newMessage];
});
}, []);
}, [setMessage]);
const SettingsToggle = () => {
if (!styleState.isMobile) return null;
@@ -340,7 +367,6 @@ const Playground = () => {
}}
onClick={onClick}
>
{/*{uploadNode}*/}
{inputNode}
{sendNode}
</div>
@@ -351,6 +377,152 @@ const Playground = () => {
return <CustomInputRender {...props} />;
}, []);
const renderCustomChatContent = useCallback(
({ message, className }) => {
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') {
if (message.reasoningContent) {
currentExtractedThinkingContent = message.reasoningContent;
thinkingSource = 'reasoningContent';
} else if (message.content && message.content.includes('<think')) {
const fullContent = message.content;
let thoughts = [];
let replyParts = [];
let lastIndex = 0;
const thinkTagRegex = /<think>([\s\S]*?)<\/think>/g;
let match;
thinkTagRegex.lastIndex = 0;
while ((match = thinkTagRegex.exec(fullContent)) !== null) {
replyParts.push(fullContent.substring(lastIndex, match.index));
thoughts.push(match[1]);
lastIndex = match.index + match[0].length;
}
replyParts.push(fullContent.substring(lastIndex));
currentDisplayableFinalContent = replyParts.join('').trim();
if (thoughts.length > 0) {
currentExtractedThinkingContent = thoughts.join('\n\n---\n\n');
thinkingSource = '<think> tags';
}
if (isThinkingStatus && currentDisplayableFinalContent.includes('<think')) {
const lastOpenThinkIndex = currentDisplayableFinalContent.lastIndexOf('<think>');
if (lastOpenThinkIndex !== -1) {
const fragmentAfterLastOpen = currentDisplayableFinalContent.substring(lastOpenThinkIndex);
if (!fragmentAfterLastOpen.substring("<think>".length).includes('</think>')) {
const unclosedThought = fragmentAfterLastOpen.substring("<think>".length);
if (currentExtractedThinkingContent) {
currentExtractedThinkingContent += (currentExtractedThinkingContent ? '\n\n---\n\n' : '') + unclosedThought;
} else {
currentExtractedThinkingContent = unclosedThought;
}
if (!thinkingSource && unclosedThought) thinkingSource = '<think> tags (streaming)';
currentDisplayableFinalContent = currentDisplayableFinalContent.substring(0, lastOpenThinkIndex).trim();
}
}
}
}
if (typeof currentDisplayableFinalContent === 'string' && currentDisplayableFinalContent.trim().startsWith("<think>")) {
const startsWithCompleteThinkTagRegex = /^<think>[\s\S]*?<\/think>/;
if (!startsWithCompleteThinkTagRegex.test(currentDisplayableFinalContent.trim())) {
currentDisplayableFinalContent = "";
}
}
}
const headerText = isThinkingStatus ? t('思考中...') : t('思考过程');
const finalExtractedThinkingContent = currentExtractedThinkingContent;
const finalDisplayableFinalContent = currentDisplayableFinalContent;
if (message.role === 'assistant' &&
isThinkingStatus &&
!finalExtractedThinkingContent &&
(!finalDisplayableFinalContent || finalDisplayableFinalContent.trim() === '')) {
return (
<div className={className} style={{ display: 'flex', alignItems: 'center', padding: '12px' }}>
<IconSpin spin />
<Typography.Text type="secondary" style={{ marginLeft: '8px' }}>{t('正在思考...')}</Typography.Text>
</div>
);
}
return (
<div className={className}>
{message.role === 'assistant' && finalExtractedThinkingContent && (
<div style={{
background: 'var(--semi-color-tertiary-light-hover)',
borderRadius: '16px',
marginBottom: '8px',
overflow: 'hidden',
}}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
cursor: 'pointer',
height: 'auto',
}}
onClick={() => toggleReasoningExpansion(message.id)}
>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Typography.Text strong={message.isReasoningExpanded} style={{ fontSize: '13px', color: 'var(--semi-color-text-1)' }}>{headerText}</Typography.Text>
{thinkingSource && (
<Tag size="small" color='green' shape="circle" style={{ marginLeft: '8px' }}>
{thinkingSource}
</Tag>
)}
</div>
<div>
{isThinkingStatus && <IconSpin spin />}
{!isThinkingStatus && (message.isReasoningExpanded ? <IconChevronUp size="small" /> : <IconChevronRight size="small" />)}
</div>
</div>
<div
style={{
maxHeight: message.isReasoningExpanded ? '160px' : '0px',
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} />
</div>
</div>
)}
{(finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') && (
<MarkdownRender raw={finalDisplayableFinalContent} />
)}
{!(finalExtractedThinkingContent) && !(finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') && message.role === 'assistant' && (
<div></div>
)}
</div>
);
},
[t, setMessage],
);
return (
<Layout style={{ height: '100%' }}>
{(showSettings || !styleState.isMobile) && (
@@ -429,7 +601,6 @@ const Playground = () => {
autoComplete='new-password'
autosize
defaultValue={systemPrompt}
// value={systemPrompt}
onChange={(value) => {
setSystemPrompt(value);
}}
@@ -442,6 +613,7 @@ const Playground = () => {
<SettingsToggle />
<Chat
chatBoxRenderConfig={{
renderChatBoxContent: renderCustomChatContent,
renderChatBoxAction: () => {
return <div></div>;
},