Playground.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673
  1. import React, { useCallback, useContext, useEffect, useState } from 'react';
  2. import { useSearchParams } from 'react-router-dom';
  3. import { UserContext } from '../../context/User/index.js';
  4. import {
  5. API,
  6. getUserIdFromLocalStorage,
  7. showError,
  8. getLogo,
  9. } from '../../helpers/index.js';
  10. import {
  11. Card,
  12. Chat,
  13. Input,
  14. Layout,
  15. Select,
  16. Slider,
  17. TextArea,
  18. Typography,
  19. Button,
  20. MarkdownRender,
  21. Tag,
  22. } from '@douyinfe/semi-ui';
  23. import { SSE } from 'sse';
  24. import { IconSetting, IconSpin, IconChevronRight, IconChevronUp } from '@douyinfe/semi-icons';
  25. import { StyleContext } from '../../context/Style/index.js';
  26. import { useTranslation } from 'react-i18next';
  27. import { renderGroupOption, truncateText, stringToColor } from '../../helpers/render.js';
  28. let id = 4;
  29. function getId() {
  30. return `${id++}`;
  31. }
  32. const generateAvatarDataUrl = (username) => {
  33. if (!username) {
  34. return 'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/docs-icon.png';
  35. }
  36. const firstLetter = username[0].toUpperCase();
  37. const bgColor = stringToColor(username);
  38. const svg = `
  39. <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
  40. <circle cx="16" cy="16" r="16" fill="${bgColor}" />
  41. <text x="50%" y="50%" dominant-baseline="central" text-anchor="middle" font-size="16" fill="#ffffff" font-family="sans-serif">${firstLetter}</text>
  42. </svg>
  43. `;
  44. return `data:image/svg+xml;base64,${btoa(svg)}`;
  45. };
  46. const Playground = () => {
  47. const { t } = useTranslation();
  48. const [userState, userDispatch] = useContext(UserContext);
  49. const roleInfo = {
  50. user: {
  51. name: userState?.user?.username || 'User',
  52. avatar: generateAvatarDataUrl(userState?.user?.username),
  53. },
  54. assistant: {
  55. name: 'Assistant',
  56. avatar: getLogo(),
  57. },
  58. system: {
  59. name: 'System',
  60. avatar:
  61. 'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/other/logo.png',
  62. },
  63. };
  64. const defaultMessage = [
  65. {
  66. role: 'user',
  67. id: '2',
  68. createAt: 1715676751919,
  69. content: t('你好'),
  70. },
  71. {
  72. role: 'assistant',
  73. id: '3',
  74. createAt: 1715676751919,
  75. content: t('你好,请问有什么可以帮助您的吗?'),
  76. reasoningContent: '',
  77. isReasoningExpanded: false,
  78. },
  79. ];
  80. const [inputs, setInputs] = useState({
  81. model: 'deepseek-r1',
  82. group: '',
  83. max_tokens: 0,
  84. temperature: 0,
  85. });
  86. const [searchParams, setSearchParams] = useSearchParams();
  87. const [status, setStatus] = useState({});
  88. const [systemPrompt, setSystemPrompt] = useState(
  89. 'You are a helpful assistant. You can help me by answering my questions. You can also ask me questions.',
  90. );
  91. const [message, setMessage] = useState(defaultMessage);
  92. const [models, setModels] = useState([]);
  93. const [groups, setGroups] = useState([]);
  94. const [showSettings, setShowSettings] = useState(true);
  95. const [styleState, styleDispatch] = useContext(StyleContext);
  96. const handleInputChange = (name, value) => {
  97. setInputs((inputs) => ({ ...inputs, [name]: value }));
  98. };
  99. useEffect(() => {
  100. if (searchParams.get('expired')) {
  101. showError(t('未登录或登录已过期,请重新登录!'));
  102. }
  103. let status = localStorage.getItem('status');
  104. if (status) {
  105. status = JSON.parse(status);
  106. setStatus(status);
  107. }
  108. loadModels();
  109. loadGroups();
  110. }, [searchParams, t]);
  111. const loadModels = async () => {
  112. let res = await API.get(`/api/user/models`);
  113. const { success, message, data } = res.data;
  114. if (success) {
  115. let localModelOptions = data.map((model) => ({
  116. label: model,
  117. value: model,
  118. }));
  119. setModels(localModelOptions);
  120. } else {
  121. showError(t(message));
  122. }
  123. };
  124. const loadGroups = async () => {
  125. let res = await API.get(`/api/user/self/groups`);
  126. const { success, message, data } = res.data;
  127. if (success) {
  128. let localGroupOptions = Object.entries(data).map(([group, info]) => ({
  129. label: truncateText(info.desc, '50%'),
  130. value: group,
  131. ratio: info.ratio,
  132. fullLabel: info.desc,
  133. }));
  134. if (localGroupOptions.length === 0) {
  135. localGroupOptions = [
  136. {
  137. label: t('用户分组'),
  138. value: '',
  139. ratio: 1,
  140. },
  141. ];
  142. } else {
  143. const localUser = JSON.parse(localStorage.getItem('user'));
  144. const userGroup =
  145. (userState.user && userState.user.group) ||
  146. (localUser && localUser.group);
  147. if (userGroup) {
  148. const userGroupIndex = localGroupOptions.findIndex(
  149. (g) => g.value === userGroup,
  150. );
  151. if (userGroupIndex > -1) {
  152. const userGroupOption = localGroupOptions.splice(
  153. userGroupIndex,
  154. 1,
  155. )[0];
  156. localGroupOptions.unshift(userGroupOption);
  157. }
  158. }
  159. }
  160. setGroups(localGroupOptions);
  161. handleInputChange('group', localGroupOptions[0].value);
  162. } else {
  163. showError(t(message));
  164. }
  165. };
  166. const commonOuterStyle = {
  167. border: '1px solid var(--semi-color-border)',
  168. borderRadius: '16px',
  169. margin: '0px 8px',
  170. };
  171. const getSystemMessage = () => {
  172. if (systemPrompt !== '') {
  173. return {
  174. role: 'system',
  175. id: '1',
  176. createAt: 1715676751919,
  177. content: systemPrompt,
  178. };
  179. }
  180. };
  181. let handleSSE = (payload) => {
  182. let source = new SSE('/pg/chat/completions', {
  183. headers: {
  184. 'Content-Type': 'application/json',
  185. 'New-Api-User': getUserIdFromLocalStorage(),
  186. },
  187. method: 'POST',
  188. payload: JSON.stringify(payload),
  189. });
  190. source.addEventListener('message', (e) => {
  191. if (e.data === '[DONE]') {
  192. source.close();
  193. completeMessage();
  194. return;
  195. }
  196. try {
  197. let payload = JSON.parse(e.data);
  198. const delta = payload.choices?.[0]?.delta;
  199. if (delta) {
  200. if (delta.reasoning_content) {
  201. streamMessageUpdate(delta.reasoning_content, 'reasoning');
  202. }
  203. if (delta.content) {
  204. streamMessageUpdate(delta.content, 'content');
  205. }
  206. }
  207. } catch (error) {
  208. console.error('Failed to parse SSE message:', error);
  209. streamMessageUpdate(t('解析响应数据时发生错误'), 'content');
  210. completeMessage('error');
  211. }
  212. });
  213. source.addEventListener('error', (e) => {
  214. console.error('SSE Error:', e);
  215. const errorMessage = e.data || t('请求发生错误');
  216. streamMessageUpdate(errorMessage, 'content');
  217. completeMessage('error');
  218. source.close();
  219. });
  220. source.addEventListener('readystatechange', (e) => {
  221. if (e.readyState >= 2) {
  222. if (source.status === undefined || source.status !== 200) {
  223. source.close();
  224. streamMessageUpdate(t('连接已断开'), 'content');
  225. completeMessage('error');
  226. } else if (source.status === 200) {
  227. // 正常状态,不需要特殊处理
  228. }
  229. }
  230. });
  231. try {
  232. source.stream();
  233. } catch (error) {
  234. console.error('Failed to start SSE stream:', error);
  235. streamMessageUpdate(t('建立连接时发生错误'), 'content');
  236. completeMessage('error');
  237. }
  238. };
  239. const onMessageSend = useCallback(
  240. (content, attachment) => {
  241. console.log('attachment: ', attachment);
  242. setMessage((prevMessage) => {
  243. const newMessage = [
  244. ...prevMessage,
  245. {
  246. role: 'user',
  247. content: content,
  248. createAt: Date.now(),
  249. id: getId(),
  250. },
  251. ];
  252. const getPayload = () => {
  253. let systemMessage = getSystemMessage();
  254. let messages = newMessage.map((item) => {
  255. return {
  256. role: item.role,
  257. content: item.content,
  258. };
  259. });
  260. if (systemMessage) {
  261. messages.unshift(systemMessage);
  262. }
  263. return {
  264. messages: messages,
  265. stream: true,
  266. model: inputs.model,
  267. group: inputs.group,
  268. max_tokens: parseInt(inputs.max_tokens),
  269. temperature: inputs.temperature,
  270. };
  271. };
  272. handleSSE(getPayload());
  273. newMessage.push({
  274. role: 'assistant',
  275. content: '',
  276. reasoningContent: '',
  277. isReasoningExpanded: true,
  278. createAt: Date.now(),
  279. id: getId(),
  280. status: 'loading',
  281. });
  282. return newMessage;
  283. });
  284. },
  285. [getSystemMessage, inputs, setMessage],
  286. );
  287. const completeMessage = useCallback((status = 'complete') => {
  288. setMessage((prevMessage) => {
  289. const lastMessage = prevMessage[prevMessage.length - 1];
  290. if (lastMessage.status === 'complete' || lastMessage.status === 'error') {
  291. return prevMessage;
  292. }
  293. return [...prevMessage.slice(0, -1), { ...lastMessage, status: status, isReasoningExpanded: false }];
  294. });
  295. }, [setMessage]);
  296. const streamMessageUpdate = useCallback((textChunk, type) => {
  297. setMessage((prevMessage) => {
  298. const lastMessage = prevMessage[prevMessage.length - 1];
  299. let newMessage = { ...lastMessage };
  300. // 如果消息已经是错误状态,保持错误状态
  301. if (lastMessage.status === 'error') {
  302. return prevMessage;
  303. }
  304. if (lastMessage.status === 'loading' || lastMessage.status === 'incomplete') {
  305. if (type === 'reasoning') {
  306. newMessage = {
  307. ...newMessage,
  308. reasoningContent: (lastMessage.reasoningContent || '') + textChunk,
  309. status: 'incomplete',
  310. };
  311. } else if (type === 'content') {
  312. newMessage = {
  313. ...newMessage,
  314. content: (lastMessage.content || '') + textChunk,
  315. status: 'incomplete',
  316. };
  317. }
  318. }
  319. return [...prevMessage.slice(0, -1), newMessage];
  320. });
  321. }, [setMessage]);
  322. const SettingsToggle = () => {
  323. if (!styleState.isMobile) return null;
  324. return (
  325. <Button
  326. icon={<IconSetting />}
  327. style={{
  328. position: 'absolute',
  329. left: showSettings ? -10 : -20,
  330. top: '50%',
  331. transform: 'translateY(-50%)',
  332. zIndex: 1000,
  333. width: 40,
  334. height: 40,
  335. borderRadius: '0 20px 20px 0',
  336. padding: 0,
  337. boxShadow: '2px 0 8px rgba(0, 0, 0, 0.15)',
  338. }}
  339. onClick={() => setShowSettings(!showSettings)}
  340. theme='solid'
  341. type='primary'
  342. />
  343. );
  344. };
  345. function CustomInputRender(props) {
  346. const { detailProps } = props;
  347. const { clearContextNode, uploadNode, inputNode, sendNode, onClick } =
  348. detailProps;
  349. return (
  350. <div
  351. style={{
  352. margin: '8px 16px',
  353. display: 'flex',
  354. flexDirection: 'row',
  355. alignItems: 'flex-end',
  356. borderRadius: 16,
  357. padding: 10,
  358. border: '1px solid var(--semi-color-border)',
  359. }}
  360. onClick={onClick}
  361. >
  362. {inputNode}
  363. {sendNode}
  364. </div>
  365. );
  366. }
  367. const renderInputArea = useCallback((props) => {
  368. return <CustomInputRender {...props} />;
  369. }, []);
  370. const renderCustomChatContent = useCallback(
  371. ({ message, className }) => {
  372. if (message.status === 'error') {
  373. return (
  374. <div className={className} style={{
  375. display: 'flex',
  376. alignItems: 'center',
  377. padding: '12px',
  378. color: 'var(--semi-color-danger)'
  379. }}>
  380. <Typography.Text type="danger">{message.content || t('请求发生错误')}</Typography.Text>
  381. </div>
  382. );
  383. }
  384. const toggleReasoningExpansion = (messageId) => {
  385. setMessage(prevMessages =>
  386. prevMessages.map(msg =>
  387. msg.id === messageId && msg.role === 'assistant'
  388. ? { ...msg, isReasoningExpanded: !msg.isReasoningExpanded }
  389. : msg
  390. )
  391. );
  392. };
  393. const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete';
  394. let currentExtractedThinkingContent = null;
  395. let currentDisplayableFinalContent = message.content || "";
  396. let thinkingSource = null;
  397. if (message.role === 'assistant') {
  398. if (message.reasoningContent) {
  399. currentExtractedThinkingContent = message.reasoningContent;
  400. thinkingSource = 'reasoningContent';
  401. } else if (message.content && message.content.includes('<think')) {
  402. const fullContent = message.content;
  403. let thoughts = [];
  404. let replyParts = [];
  405. let lastIndex = 0;
  406. const thinkTagRegex = /<think>([\s\S]*?)<\/think>/g;
  407. let match;
  408. thinkTagRegex.lastIndex = 0;
  409. while ((match = thinkTagRegex.exec(fullContent)) !== null) {
  410. replyParts.push(fullContent.substring(lastIndex, match.index));
  411. thoughts.push(match[1]);
  412. lastIndex = match.index + match[0].length;
  413. }
  414. replyParts.push(fullContent.substring(lastIndex));
  415. currentDisplayableFinalContent = replyParts.join('').trim();
  416. if (thoughts.length > 0) {
  417. currentExtractedThinkingContent = thoughts.join('\n\n---\n\n');
  418. thinkingSource = '<think> tags';
  419. }
  420. if (isThinkingStatus && currentDisplayableFinalContent.includes('<think')) {
  421. const lastOpenThinkIndex = currentDisplayableFinalContent.lastIndexOf('<think>');
  422. if (lastOpenThinkIndex !== -1) {
  423. const fragmentAfterLastOpen = currentDisplayableFinalContent.substring(lastOpenThinkIndex);
  424. if (!fragmentAfterLastOpen.substring("<think>".length).includes('</think>')) {
  425. const unclosedThought = fragmentAfterLastOpen.substring("<think>".length);
  426. if (currentExtractedThinkingContent) {
  427. currentExtractedThinkingContent += (currentExtractedThinkingContent ? '\n\n---\n\n' : '') + unclosedThought;
  428. } else {
  429. currentExtractedThinkingContent = unclosedThought;
  430. }
  431. if (!thinkingSource && unclosedThought) thinkingSource = '<think> tags (streaming)';
  432. currentDisplayableFinalContent = currentDisplayableFinalContent.substring(0, lastOpenThinkIndex).trim();
  433. }
  434. }
  435. }
  436. }
  437. if (typeof currentDisplayableFinalContent === 'string' && currentDisplayableFinalContent.trim().startsWith("<think>")) {
  438. const startsWithCompleteThinkTagRegex = /^<think>[\s\S]*?<\/think>/;
  439. if (!startsWithCompleteThinkTagRegex.test(currentDisplayableFinalContent.trim())) {
  440. currentDisplayableFinalContent = "";
  441. }
  442. }
  443. }
  444. const headerText = isThinkingStatus ? t('思考中...') : t('思考过程');
  445. const finalExtractedThinkingContent = currentExtractedThinkingContent;
  446. const finalDisplayableFinalContent = currentDisplayableFinalContent;
  447. if (message.role === 'assistant' &&
  448. isThinkingStatus &&
  449. !finalExtractedThinkingContent &&
  450. (!finalDisplayableFinalContent || finalDisplayableFinalContent.trim() === '')) {
  451. return (
  452. <div className={className} style={{ display: 'flex', alignItems: 'center', padding: '12px' }}>
  453. <IconSpin spin />
  454. <Typography.Text type="secondary" style={{ marginLeft: '8px' }}>{t('正在思考...')}</Typography.Text>
  455. </div>
  456. );
  457. }
  458. return (
  459. <div className={className}>
  460. {message.role === 'assistant' && finalExtractedThinkingContent && (
  461. <div style={{
  462. background: 'var(--semi-color-tertiary-light-hover)',
  463. borderRadius: '16px',
  464. marginBottom: '8px',
  465. overflow: 'hidden',
  466. }}>
  467. <div
  468. style={{
  469. display: 'flex',
  470. alignItems: 'center',
  471. justifyContent: 'space-between',
  472. padding: '8px 12px',
  473. cursor: 'pointer',
  474. height: 'auto',
  475. }}
  476. onClick={() => toggleReasoningExpansion(message.id)}
  477. >
  478. <div style={{ display: 'flex', alignItems: 'center' }}>
  479. <Typography.Text strong={message.isReasoningExpanded} style={{ fontSize: '13px', color: 'var(--semi-color-text-1)' }}>{headerText}</Typography.Text>
  480. {thinkingSource && (
  481. <Tag size="small" color='green' shape="circle" style={{ marginLeft: '8px' }}>
  482. {thinkingSource}
  483. </Tag>
  484. )}
  485. </div>
  486. <div>
  487. {isThinkingStatus && <IconSpin spin />}
  488. {!isThinkingStatus && (message.isReasoningExpanded ? <IconChevronUp size="small" /> : <IconChevronRight size="small" />)}
  489. </div>
  490. </div>
  491. <div
  492. style={{
  493. maxHeight: message.isReasoningExpanded ? '160px' : '0px',
  494. overflowY: message.isReasoningExpanded ? 'auto' : 'hidden',
  495. overflowX: 'hidden',
  496. transition: 'max-height 0.3s ease-in-out, padding 0.3s ease-in-out',
  497. padding: message.isReasoningExpanded ? '0px 12px 12px 12px' : '0px 12px',
  498. boxSizing: 'border-box',
  499. }}
  500. >
  501. <MarkdownRender raw={finalExtractedThinkingContent} />
  502. </div>
  503. </div>
  504. )}
  505. {(finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') && (
  506. <MarkdownRender raw={finalDisplayableFinalContent} />
  507. )}
  508. {!(finalExtractedThinkingContent) && !(finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') && message.role === 'assistant' && (
  509. <div></div>
  510. )}
  511. </div>
  512. );
  513. },
  514. [t, setMessage],
  515. );
  516. return (
  517. <Layout style={{ height: '100%' }}>
  518. {(showSettings || !styleState.isMobile) && (
  519. <Layout.Sider
  520. style={{ display: styleState.isMobile ? 'block' : 'initial' }}
  521. >
  522. <Card style={commonOuterStyle}>
  523. <div style={{ marginTop: 10 }}>
  524. <Typography.Text strong>{t('分组')}:</Typography.Text>
  525. </div>
  526. <Select
  527. placeholder={t('请选择分组')}
  528. name='group'
  529. required
  530. selection
  531. onChange={(value) => {
  532. handleInputChange('group', value);
  533. }}
  534. value={inputs.group}
  535. autoComplete='new-password'
  536. optionList={groups}
  537. renderOptionItem={renderGroupOption}
  538. style={{ width: '100%' }}
  539. />
  540. <div style={{ marginTop: 10 }}>
  541. <Typography.Text strong>{t('模型')}:</Typography.Text>
  542. </div>
  543. <Select
  544. placeholder={t('请选择模型')}
  545. name='model'
  546. required
  547. selection
  548. searchPosition='dropdown'
  549. filter
  550. onChange={(value) => {
  551. handleInputChange('model', value);
  552. }}
  553. value={inputs.model}
  554. autoComplete='new-password'
  555. optionList={models}
  556. />
  557. <div style={{ marginTop: 10 }}>
  558. <Typography.Text strong>Temperature:</Typography.Text>
  559. </div>
  560. <Slider
  561. step={0.1}
  562. min={0.1}
  563. max={1}
  564. value={inputs.temperature}
  565. onChange={(value) => {
  566. handleInputChange('temperature', value);
  567. }}
  568. />
  569. <div style={{ marginTop: 10 }}>
  570. <Typography.Text strong>MaxTokens:</Typography.Text>
  571. </div>
  572. <Input
  573. placeholder='MaxTokens'
  574. name='max_tokens'
  575. required
  576. autoComplete='new-password'
  577. defaultValue={0}
  578. value={inputs.max_tokens}
  579. onChange={(value) => {
  580. handleInputChange('max_tokens', value);
  581. }}
  582. />
  583. <div style={{ marginTop: 10 }}>
  584. <Typography.Text strong>System:</Typography.Text>
  585. </div>
  586. <TextArea
  587. placeholder='System Prompt'
  588. name='system'
  589. required
  590. autoComplete='new-password'
  591. autosize
  592. defaultValue={systemPrompt}
  593. onChange={(value) => {
  594. setSystemPrompt(value);
  595. }}
  596. />
  597. </Card>
  598. </Layout.Sider>
  599. )}
  600. <Layout.Content>
  601. <div style={{ height: '100%', position: 'relative' }}>
  602. <SettingsToggle />
  603. <Chat
  604. chatBoxRenderConfig={{
  605. renderChatBoxContent: renderCustomChatContent,
  606. renderChatBoxAction: () => {
  607. return <div></div>;
  608. },
  609. }}
  610. renderInputArea={renderInputArea}
  611. roleConfig={roleInfo}
  612. style={commonOuterStyle}
  613. chats={message}
  614. onMessageSend={onMessageSend}
  615. showClearContext
  616. onClear={() => {
  617. setMessage([]);
  618. }}
  619. />
  620. </div>
  621. </Layout.Content>
  622. </Layout>
  623. );
  624. };
  625. export default Playground;