Просмотр исходного кода

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

Apple\Apple 9 месяцев назад
Родитель
Сommit
39329fcd1c
1 измененных файлов с 222 добавлено и 50 удалено
  1. 222 50
      web/src/pages/Playground/Playground.js

+ 222 - 50
web/src/pages/Playground/Playground.js

@@ -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>;
               },