Playground.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. import React, { useCallback, useContext, useEffect, useState } from 'react';
  2. import { useNavigate, useSearchParams } from 'react-router-dom';
  3. import { UserContext } from '../../context/User/index.js';
  4. import {
  5. API,
  6. getUserIdFromLocalStorage,
  7. showError,
  8. } from '../../helpers/index.js';
  9. import {
  10. Card,
  11. Chat,
  12. Input,
  13. Layout,
  14. Select,
  15. Slider,
  16. TextArea,
  17. Typography,
  18. Button,
  19. Highlight,
  20. } from '@douyinfe/semi-ui';
  21. import { SSE } from 'sse';
  22. import { IconSetting } from '@douyinfe/semi-icons';
  23. import { StyleContext } from '../../context/Style/index.js';
  24. import { useTranslation } from 'react-i18next';
  25. import { renderGroupOption, truncateText } from '../../helpers/render.js';
  26. const roleInfo = {
  27. user: {
  28. name: 'User',
  29. avatar:
  30. 'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/docs-icon.png',
  31. },
  32. assistant: {
  33. name: 'Assistant',
  34. avatar: 'logo.png',
  35. },
  36. system: {
  37. name: 'System',
  38. avatar:
  39. 'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/other/logo.png',
  40. },
  41. };
  42. let id = 4;
  43. function getId() {
  44. return `${id++}`;
  45. }
  46. const Playground = () => {
  47. const { t } = useTranslation();
  48. const defaultMessage = [
  49. {
  50. role: 'user',
  51. id: '2',
  52. createAt: 1715676751919,
  53. content: t('你好'),
  54. },
  55. {
  56. role: 'assistant',
  57. id: '3',
  58. createAt: 1715676751919,
  59. content: t('你好,请问有什么可以帮助您的吗?'),
  60. },
  61. ];
  62. const [inputs, setInputs] = useState({
  63. model: 'gpt-4o-mini',
  64. group: '',
  65. max_tokens: 0,
  66. temperature: 0,
  67. });
  68. const [searchParams, setSearchParams] = useSearchParams();
  69. const [userState, userDispatch] = useContext(UserContext);
  70. const [status, setStatus] = useState({});
  71. const [systemPrompt, setSystemPrompt] = useState(
  72. 'You are a helpful assistant. You can help me by answering my questions. You can also ask me questions.',
  73. );
  74. const [message, setMessage] = useState(defaultMessage);
  75. const [models, setModels] = useState([]);
  76. const [groups, setGroups] = useState([]);
  77. const [showSettings, setShowSettings] = useState(true);
  78. const [styleState, styleDispatch] = useContext(StyleContext);
  79. const handleInputChange = (name, value) => {
  80. setInputs((inputs) => ({ ...inputs, [name]: value }));
  81. };
  82. useEffect(() => {
  83. if (searchParams.get('expired')) {
  84. showError(t('未登录或登录已过期,请重新登录!'));
  85. }
  86. let status = localStorage.getItem('status');
  87. if (status) {
  88. status = JSON.parse(status);
  89. setStatus(status);
  90. }
  91. loadModels();
  92. loadGroups();
  93. }, []);
  94. const loadModels = async () => {
  95. let res = await API.get(`/api/user/models`);
  96. const { success, message, data } = res.data;
  97. if (success) {
  98. let localModelOptions = data.map((model) => ({
  99. label: model,
  100. value: model,
  101. }));
  102. setModels(localModelOptions);
  103. } else {
  104. showError(t(message));
  105. }
  106. };
  107. const loadGroups = async () => {
  108. let res = await API.get(`/api/user/self/groups`);
  109. const { success, message, data } = res.data;
  110. if (success) {
  111. let localGroupOptions = Object.entries(data).map(([group, info]) => ({
  112. label: truncateText(info.desc, '50%'),
  113. value: group,
  114. ratio: info.ratio,
  115. fullLabel: info.desc, // 保存完整文本用于tooltip
  116. }));
  117. if (localGroupOptions.length === 0) {
  118. localGroupOptions = [
  119. {
  120. label: t('用户分组'),
  121. value: '',
  122. ratio: 1,
  123. },
  124. ];
  125. } else {
  126. const localUser = JSON.parse(localStorage.getItem('user'));
  127. const userGroup =
  128. (userState.user && userState.user.group) ||
  129. (localUser && localUser.group);
  130. if (userGroup) {
  131. const userGroupIndex = localGroupOptions.findIndex(
  132. (g) => g.value === userGroup,
  133. );
  134. if (userGroupIndex > -1) {
  135. const userGroupOption = localGroupOptions.splice(
  136. userGroupIndex,
  137. 1,
  138. )[0];
  139. localGroupOptions.unshift(userGroupOption);
  140. }
  141. }
  142. }
  143. setGroups(localGroupOptions);
  144. handleInputChange('group', localGroupOptions[0].value);
  145. } else {
  146. showError(t(message));
  147. }
  148. };
  149. const commonOuterStyle = {
  150. border: '1px solid var(--semi-color-border)',
  151. borderRadius: '16px',
  152. margin: '0px 8px',
  153. };
  154. const getSystemMessage = () => {
  155. if (systemPrompt !== '') {
  156. return {
  157. role: 'system',
  158. id: '1',
  159. createAt: 1715676751919,
  160. content: systemPrompt,
  161. };
  162. }
  163. };
  164. let handleSSE = (payload) => {
  165. let source = new SSE('/pg/chat/completions', {
  166. headers: {
  167. 'Content-Type': 'application/json',
  168. 'New-Api-User': getUserIdFromLocalStorage(),
  169. },
  170. method: 'POST',
  171. payload: JSON.stringify(payload),
  172. });
  173. source.addEventListener('message', (e) => {
  174. // 只有收到 [DONE] 时才结束
  175. if (e.data === '[DONE]') {
  176. source.close();
  177. completeMessage();
  178. return;
  179. }
  180. let payload = JSON.parse(e.data);
  181. // 检查是否有 delta content
  182. if (payload.choices?.[0]?.delta?.content) {
  183. generateMockResponse(payload.choices[0].delta.content);
  184. }
  185. });
  186. source.addEventListener('error', (e) => {
  187. generateMockResponse(e.data);
  188. completeMessage('error');
  189. });
  190. source.addEventListener('readystatechange', (e) => {
  191. if (e.readyState >= 2) {
  192. if (source.status === undefined) {
  193. source.close();
  194. completeMessage();
  195. }
  196. }
  197. });
  198. source.stream();
  199. };
  200. const onMessageSend = useCallback(
  201. (content, attachment) => {
  202. console.log('attachment: ', attachment);
  203. setMessage((prevMessage) => {
  204. const newMessage = [
  205. ...prevMessage,
  206. {
  207. role: 'user',
  208. content: content,
  209. createAt: Date.now(),
  210. id: getId(),
  211. },
  212. ];
  213. // 将 getPayload 移到这里
  214. const getPayload = () => {
  215. let systemMessage = getSystemMessage();
  216. let messages = newMessage.map((item) => {
  217. return {
  218. role: item.role,
  219. content: item.content,
  220. };
  221. });
  222. if (systemMessage) {
  223. messages.unshift(systemMessage);
  224. }
  225. return {
  226. messages: messages,
  227. stream: true,
  228. model: inputs.model,
  229. group: inputs.group,
  230. max_tokens: parseInt(inputs.max_tokens),
  231. temperature: inputs.temperature,
  232. };
  233. };
  234. // 使用更新后的消息状态调用 handleSSE
  235. handleSSE(getPayload());
  236. newMessage.push({
  237. role: 'assistant',
  238. content: '',
  239. createAt: Date.now(),
  240. id: getId(),
  241. status: 'loading',
  242. });
  243. return newMessage;
  244. });
  245. },
  246. [getSystemMessage],
  247. );
  248. const completeMessage = useCallback((status = 'complete') => {
  249. // console.log("Complete Message: ", status)
  250. setMessage((prevMessage) => {
  251. const lastMessage = prevMessage[prevMessage.length - 1];
  252. // only change the status if the last message is not complete and not error
  253. if (lastMessage.status === 'complete' || lastMessage.status === 'error') {
  254. return prevMessage;
  255. }
  256. return [...prevMessage.slice(0, -1), { ...lastMessage, status: status }];
  257. });
  258. }, []);
  259. const generateMockResponse = useCallback((content) => {
  260. // console.log("Generate Mock Response: ", content);
  261. setMessage((message) => {
  262. const lastMessage = message[message.length - 1];
  263. let newMessage = { ...lastMessage };
  264. if (
  265. lastMessage.status === 'loading' ||
  266. lastMessage.status === 'incomplete'
  267. ) {
  268. newMessage = {
  269. ...newMessage,
  270. content: (lastMessage.content || '') + content,
  271. status: 'incomplete',
  272. };
  273. }
  274. return [...message.slice(0, -1), newMessage];
  275. });
  276. }, []);
  277. const SettingsToggle = () => {
  278. if (!styleState.isMobile) return null;
  279. return (
  280. <Button
  281. icon={<IconSetting />}
  282. style={{
  283. position: 'absolute',
  284. left: showSettings ? -10 : -20,
  285. top: '50%',
  286. transform: 'translateY(-50%)',
  287. zIndex: 1000,
  288. width: 40,
  289. height: 40,
  290. borderRadius: '0 20px 20px 0',
  291. padding: 0,
  292. boxShadow: '2px 0 8px rgba(0, 0, 0, 0.15)',
  293. }}
  294. onClick={() => setShowSettings(!showSettings)}
  295. theme='solid'
  296. type='primary'
  297. />
  298. );
  299. };
  300. function CustomInputRender(props) {
  301. const { detailProps } = props;
  302. const { clearContextNode, uploadNode, inputNode, sendNode, onClick } =
  303. detailProps;
  304. return (
  305. <div
  306. style={{
  307. margin: '8px 16px',
  308. display: 'flex',
  309. flexDirection: 'row',
  310. alignItems: 'flex-end',
  311. borderRadius: 16,
  312. padding: 10,
  313. border: '1px solid var(--semi-color-border)',
  314. }}
  315. onClick={onClick}
  316. >
  317. {/*{uploadNode}*/}
  318. {inputNode}
  319. {sendNode}
  320. </div>
  321. );
  322. }
  323. const renderInputArea = useCallback((props) => {
  324. return <CustomInputRender {...props} />;
  325. }, []);
  326. return (
  327. <Layout style={{ height: '100%' }}>
  328. {(showSettings || !styleState.isMobile) && (
  329. <Layout.Sider
  330. style={{ display: styleState.isMobile ? 'block' : 'initial' }}
  331. >
  332. <Card style={commonOuterStyle}>
  333. <div style={{ marginTop: 10 }}>
  334. <Typography.Text strong>{t('分组')}:</Typography.Text>
  335. </div>
  336. <Select
  337. placeholder={t('请选择分组')}
  338. name='group'
  339. required
  340. selection
  341. onChange={(value) => {
  342. handleInputChange('group', value);
  343. }}
  344. value={inputs.group}
  345. autoComplete='new-password'
  346. optionList={groups}
  347. renderOptionItem={renderGroupOption}
  348. style={{ width: '100%' }}
  349. />
  350. <div style={{ marginTop: 10 }}>
  351. <Typography.Text strong>{t('模型')}:</Typography.Text>
  352. </div>
  353. <Select
  354. placeholder={t('请选择模型')}
  355. name='model'
  356. required
  357. selection
  358. searchPosition='dropdown'
  359. filter
  360. onChange={(value) => {
  361. handleInputChange('model', value);
  362. }}
  363. value={inputs.model}
  364. autoComplete='new-password'
  365. optionList={models}
  366. />
  367. <div style={{ marginTop: 10 }}>
  368. <Typography.Text strong>Temperature:</Typography.Text>
  369. </div>
  370. <Slider
  371. step={0.1}
  372. min={0.1}
  373. max={1}
  374. value={inputs.temperature}
  375. onChange={(value) => {
  376. handleInputChange('temperature', value);
  377. }}
  378. />
  379. <div style={{ marginTop: 10 }}>
  380. <Typography.Text strong>MaxTokens:</Typography.Text>
  381. </div>
  382. <Input
  383. placeholder='MaxTokens'
  384. name='max_tokens'
  385. required
  386. autoComplete='new-password'
  387. defaultValue={0}
  388. value={inputs.max_tokens}
  389. onChange={(value) => {
  390. handleInputChange('max_tokens', value);
  391. }}
  392. />
  393. <div style={{ marginTop: 10 }}>
  394. <Typography.Text strong>System:</Typography.Text>
  395. </div>
  396. <TextArea
  397. placeholder='System Prompt'
  398. name='system'
  399. required
  400. autoComplete='new-password'
  401. autosize
  402. defaultValue={systemPrompt}
  403. // value={systemPrompt}
  404. onChange={(value) => {
  405. setSystemPrompt(value);
  406. }}
  407. />
  408. </Card>
  409. </Layout.Sider>
  410. )}
  411. <Layout.Content>
  412. <div style={{ height: '100%', position: 'relative' }}>
  413. <SettingsToggle />
  414. <Chat
  415. chatBoxRenderConfig={{
  416. renderChatBoxAction: () => {
  417. return <div></div>;
  418. },
  419. }}
  420. renderInputArea={renderInputArea}
  421. roleConfig={roleInfo}
  422. style={commonOuterStyle}
  423. chats={message}
  424. onMessageSend={onMessageSend}
  425. showClearContext
  426. onClear={() => {
  427. setMessage([]);
  428. }}
  429. />
  430. </div>
  431. </Layout.Content>
  432. </Layout>
  433. );
  434. };
  435. export default Playground;