Não pode escolher mais do que 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 

623 linhas
19 KiB

  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, { useContext, useEffect, useCallback, useRef } from 'react';
  16. import { useSearchParams } from 'react-router-dom';
  17. import { useTranslation } from 'react-i18next';
  18. import { Layout, Toast, Modal } from '@douyinfe/semi-ui';
  19. // Context
  20. import { UserContext } from '../../context/User';
  21. import { useIsMobile } from '../../hooks/common/useIsMobile';
  22. // hooks
  23. import { usePlaygroundState } from '../../hooks/playground/usePlaygroundState';
  24. import { useMessageActions } from '../../hooks/playground/useMessageActions';
  25. import { useApiRequest } from '../../hooks/playground/useApiRequest';
  26. import { useSyncMessageAndCustomBody } from '../../hooks/playground/useSyncMessageAndCustomBody';
  27. import { useMessageEdit } from '../../hooks/playground/useMessageEdit';
  28. import { useDataLoader } from '../../hooks/playground/useDataLoader';
  29. // Constants and utils
  30. import {
  31. MESSAGE_ROLES,
  32. ERROR_MESSAGES,
  33. } from '../../constants/playground.constants';
  34. import {
  35. getLogo,
  36. stringToColor,
  37. buildMessageContent,
  38. createMessage,
  39. createLoadingAssistantMessage,
  40. getTextContent,
  41. buildApiPayload,
  42. encodeToBase64,
  43. API,
  44. } from '../../helpers';
  45. // Components
  46. import {
  47. OptimizedSettingsPanel,
  48. OptimizedDebugPanel,
  49. OptimizedMessageContent,
  50. OptimizedMessageActions,
  51. } from '../../components/playground/OptimizedComponents';
  52. import ChatArea from '../../components/playground/ChatArea';
  53. import FloatingButtons from '../../components/playground/FloatingButtons';
  54. import { PlaygroundProvider } from '../../contexts/PlaygroundContext';
  55. // 生成头像
  56. const generateAvatarDataUrl = (username) => {
  57. if (!username) {
  58. return 'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/docs-icon.png';
  59. }
  60. const firstLetter = username[0].toUpperCase();
  61. const bgColor = stringToColor(username);
  62. const svg = `
  63. <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
  64. <circle cx="16" cy="16" r="16" fill="${bgColor}" />
  65. <text x="50%" y="50%" dominant-baseline="central" text-anchor="middle" font-size="16" fill="#ffffff" font-family="sans-serif">${firstLetter}</text>
  66. </svg>
  67. `;
  68. return `data:image/svg+xml;base64,${encodeToBase64(svg)}`;
  69. };
  70. const Playground = () => {
  71. const { t } = useTranslation();
  72. const [userState] = useContext(UserContext);
  73. const isMobile = useIsMobile();
  74. const styleState = { isMobile };
  75. const [searchParams] = useSearchParams();
  76. const state = usePlaygroundState();
  77. const {
  78. inputs,
  79. parameterEnabled,
  80. showDebugPanel,
  81. customRequestMode,
  82. customRequestBody,
  83. showSettings,
  84. models,
  85. groups,
  86. status,
  87. message,
  88. debugData,
  89. activeDebugTab,
  90. previewPayload,
  91. sseSourceRef,
  92. chatRef,
  93. handleInputChange,
  94. handleParameterToggle,
  95. isMutualExclusiveModel,
  96. isMutualExclusive,
  97. debouncedSaveConfig,
  98. saveMessagesImmediately,
  99. handleConfigImport,
  100. handleConfigReset,
  101. setShowSettings,
  102. setModels,
  103. setGroups,
  104. channels,
  105. setChannels,
  106. setStatus,
  107. setMessage,
  108. setDebugData,
  109. setActiveDebugTab,
  110. setPreviewPayload,
  111. setShowDebugPanel,
  112. setCustomRequestMode,
  113. setCustomRequestBody,
  114. } = state;
  115. // API 请求相关
  116. const { sendRequest, onStopGenerator } = useApiRequest(
  117. setMessage,
  118. setDebugData,
  119. setActiveDebugTab,
  120. sseSourceRef,
  121. saveMessagesImmediately,
  122. );
  123. // 数据加载
  124. useDataLoader(userState, inputs, handleInputChange, setModels, setGroups, searchParams);
  125. // Load channels for the selected model
  126. useEffect(() => {
  127. if (!inputs.model) {
  128. setChannels([]);
  129. return;
  130. }
  131. let cancelled = false;
  132. const loadChannels = async () => {
  133. try {
  134. const res = await API.get(
  135. `/api/user/model_channels?model=${encodeURIComponent(inputs.model)}`
  136. );
  137. if (cancelled) return;
  138. const { success, data } = res.data;
  139. if (success && data) {
  140. const channelList = data.channels || [];
  141. setChannels(channelList);
  142. const channelId = (data.default_channel_id && data.default_channel_id > 0)
  143. ? data.default_channel_id
  144. : (channelList.length > 0 ? channelList[0].id : 0);
  145. if (channelId !== inputs.channelId) {
  146. handleInputChange('channelId', channelId);
  147. }
  148. }
  149. } catch {
  150. setChannels([]);
  151. }
  152. };
  153. loadChannels();
  154. return () => { cancelled = true; };
  155. }, [inputs.model]);
  156. // 消息编辑
  157. const {
  158. editingMessageId,
  159. editValue,
  160. setEditValue,
  161. handleMessageEdit,
  162. handleEditSave,
  163. handleEditCancel,
  164. } = useMessageEdit(
  165. setMessage,
  166. inputs,
  167. parameterEnabled,
  168. sendRequest,
  169. saveMessagesImmediately,
  170. );
  171. // 消息和自定义请求体同步
  172. const { syncMessageToCustomBody, syncCustomBodyToMessage } =
  173. useSyncMessageAndCustomBody(
  174. customRequestMode,
  175. customRequestBody,
  176. message,
  177. inputs,
  178. setCustomRequestBody,
  179. setMessage,
  180. debouncedSaveConfig,
  181. );
  182. // 角色信息
  183. const roleInfo = {
  184. user: {
  185. name: userState?.user?.username || 'User',
  186. avatar: generateAvatarDataUrl(userState?.user?.username),
  187. },
  188. assistant: {
  189. name: 'Assistant',
  190. avatar: getLogo(),
  191. },
  192. system: {
  193. name: 'System',
  194. avatar: getLogo(),
  195. },
  196. };
  197. // 消息操作
  198. const messageActions = useMessageActions(
  199. message,
  200. setMessage,
  201. onMessageSend,
  202. saveMessagesImmediately,
  203. );
  204. // 构建预览请求体
  205. const constructPreviewPayload = useCallback(() => {
  206. try {
  207. // 如果是自定义请求体模式且有自定义内容,直接返回解析后的自定义请求体
  208. if (customRequestMode && customRequestBody && customRequestBody.trim()) {
  209. try {
  210. return JSON.parse(customRequestBody);
  211. } catch (parseError) {
  212. console.warn('自定义请求体JSON解析失败,回退到默认预览:', parseError);
  213. }
  214. }
  215. // 默认预览逻辑
  216. let messages = [...message];
  217. // 如果存在用户消息
  218. if (
  219. !(
  220. messages.length === 0 ||
  221. messages.every((msg) => msg.role !== MESSAGE_ROLES.USER)
  222. )
  223. ) {
  224. // 处理最后一个用户消息的图片
  225. for (let i = messages.length - 1; i >= 0; i--) {
  226. if (messages[i].role === MESSAGE_ROLES.USER) {
  227. if (inputs.imageEnabled && inputs.imageUrls) {
  228. const validImageUrls = inputs.imageUrls.filter(
  229. (url) => url.trim() !== '',
  230. );
  231. if (validImageUrls.length > 0) {
  232. const textContent = getTextContent(messages[i]) || t('示例消息');
  233. const content = buildMessageContent(
  234. textContent,
  235. validImageUrls,
  236. true,
  237. );
  238. messages[i] = { ...messages[i], content };
  239. }
  240. }
  241. break;
  242. }
  243. }
  244. }
  245. return buildApiPayload(messages, null, inputs, parameterEnabled);
  246. } catch (error) {
  247. console.error('构造预览请求体失败:', error);
  248. return null;
  249. }
  250. }, [inputs, parameterEnabled, message, customRequestMode, customRequestBody]);
  251. // 发送消息
  252. function onMessageSend(content, attachment) {
  253. console.log('attachment: ', attachment);
  254. // 创建用户消息和加载消息
  255. const userMessage = createMessage(MESSAGE_ROLES.USER, content);
  256. const loadingMessage = createLoadingAssistantMessage();
  257. // 如果是自定义请求体模式
  258. if (customRequestMode && customRequestBody) {
  259. try {
  260. const customPayload = JSON.parse(customRequestBody);
  261. setMessage((prevMessage) => {
  262. const newMessages = [...prevMessage, userMessage, loadingMessage];
  263. // 自定义请求体也通过 header 传递网关字段
  264. const customHeaders = {};
  265. if (inputs.channelId && inputs.channelId > 0) {
  266. customHeaders['X-Channel-Id'] = String(inputs.channelId);
  267. }
  268. if (customPayload.group) {
  269. customHeaders['X-Group'] = customPayload.group;
  270. delete customPayload.group;
  271. }
  272. delete customPayload.channel_id;
  273. sendRequest(customPayload, customPayload.stream !== false, customHeaders);
  274. // 发送消息后保存,传入新消息列表
  275. setTimeout(() => saveMessagesImmediately(newMessages), 0);
  276. return newMessages;
  277. });
  278. return;
  279. } catch (error) {
  280. console.error('自定义请求体JSON解析失败:', error);
  281. Toast.error(t(ERROR_MESSAGES.JSON_PARSE_ERROR));
  282. return;
  283. }
  284. }
  285. // 默认模式
  286. const validImageUrls = inputs.imageUrls.filter((url) => url.trim() !== '');
  287. const messageContent = buildMessageContent(
  288. content,
  289. validImageUrls,
  290. inputs.imageEnabled,
  291. );
  292. const userMessageWithImages = createMessage(
  293. MESSAGE_ROLES.USER,
  294. messageContent,
  295. );
  296. setMessage((prevMessage) => {
  297. const newMessages = [...prevMessage, userMessageWithImages];
  298. const payload = buildApiPayload(
  299. newMessages,
  300. null,
  301. inputs,
  302. parameterEnabled,
  303. );
  304. const extraHeaders = {};
  305. if (inputs.channelId && inputs.channelId > 0) {
  306. extraHeaders['X-Channel-Id'] = String(inputs.channelId);
  307. }
  308. if (payload.group) {
  309. extraHeaders['X-Group'] = payload.group;
  310. delete payload.group;
  311. }
  312. delete payload.channel_id;
  313. sendRequest(payload, inputs.stream, extraHeaders);
  314. // 禁用图片模式
  315. if (inputs.imageEnabled) {
  316. setTimeout(() => {
  317. handleInputChange('imageEnabled', false);
  318. }, 100);
  319. }
  320. // 发送消息后保存,传入新消息列表(包含用户消息和加载消息)
  321. const messagesWithLoading = [...newMessages, loadingMessage];
  322. setTimeout(() => saveMessagesImmediately(messagesWithLoading), 0);
  323. return messagesWithLoading;
  324. });
  325. }
  326. // 切换推理展开状态
  327. const toggleReasoningExpansion = useCallback(
  328. (messageId) => {
  329. setMessage((prevMessages) =>
  330. prevMessages.map((msg) =>
  331. msg.id === messageId && msg.role === MESSAGE_ROLES.ASSISTANT
  332. ? { ...msg, isReasoningExpanded: !msg.isReasoningExpanded }
  333. : msg,
  334. ),
  335. );
  336. },
  337. [setMessage],
  338. );
  339. // 渲染函数
  340. const renderCustomChatContent = useCallback(
  341. ({ message, className }) => {
  342. const isCurrentlyEditing = editingMessageId === message.id;
  343. return (
  344. <OptimizedMessageContent
  345. message={message}
  346. className={className}
  347. styleState={styleState}
  348. onToggleReasoningExpansion={toggleReasoningExpansion}
  349. isEditing={isCurrentlyEditing}
  350. onEditSave={handleEditSave}
  351. onEditCancel={handleEditCancel}
  352. editValue={editValue}
  353. onEditValueChange={setEditValue}
  354. />
  355. );
  356. },
  357. [
  358. styleState,
  359. editingMessageId,
  360. editValue,
  361. handleEditSave,
  362. handleEditCancel,
  363. setEditValue,
  364. toggleReasoningExpansion,
  365. ],
  366. );
  367. const renderChatBoxAction = useCallback(
  368. (props) => {
  369. const { message: currentMessage } = props;
  370. const isAnyMessageGenerating = message.some(
  371. (msg) => msg.status === 'loading' || msg.status === 'incomplete',
  372. );
  373. const isCurrentlyEditing = editingMessageId === currentMessage.id;
  374. return (
  375. <OptimizedMessageActions
  376. message={currentMessage}
  377. styleState={styleState}
  378. onMessageReset={messageActions.handleMessageReset}
  379. onMessageCopy={messageActions.handleMessageCopy}
  380. onMessageDelete={messageActions.handleMessageDelete}
  381. onRoleToggle={messageActions.handleRoleToggle}
  382. onMessageEdit={handleMessageEdit}
  383. isAnyMessageGenerating={isAnyMessageGenerating}
  384. isEditing={isCurrentlyEditing}
  385. />
  386. );
  387. },
  388. [messageActions, styleState, message, editingMessageId, handleMessageEdit],
  389. );
  390. // Effects
  391. // 同步消息和自定义请求体
  392. useEffect(() => {
  393. syncMessageToCustomBody();
  394. }, [message, syncMessageToCustomBody]);
  395. useEffect(() => {
  396. syncCustomBodyToMessage();
  397. }, [customRequestBody, syncCustomBodyToMessage]);
  398. // 处理URL参数
  399. useEffect(() => {
  400. if (searchParams.get('expired')) {
  401. Toast.warning(t('登录过期,请重新登录!'));
  402. }
  403. }, [searchParams, t]);
  404. // Playground 组件无需再监听窗口变化,isMobile 由 useIsMobile Hook 自动更新
  405. // 构建预览payload
  406. useEffect(() => {
  407. const timer = setTimeout(() => {
  408. const preview = constructPreviewPayload();
  409. setPreviewPayload(preview);
  410. setDebugData((prev) => ({
  411. ...prev,
  412. previewRequest: preview ? JSON.stringify(preview, null, 2) : null,
  413. previewTimestamp: preview ? new Date().toISOString() : null,
  414. }));
  415. }, 300);
  416. return () => clearTimeout(timer);
  417. }, [
  418. message,
  419. inputs,
  420. parameterEnabled,
  421. customRequestMode,
  422. customRequestBody,
  423. constructPreviewPayload,
  424. setPreviewPayload,
  425. setDebugData,
  426. ]);
  427. // 自动保存配置
  428. useEffect(() => {
  429. debouncedSaveConfig();
  430. }, [
  431. inputs,
  432. parameterEnabled,
  433. showDebugPanel,
  434. customRequestMode,
  435. customRequestBody,
  436. debouncedSaveConfig,
  437. ]);
  438. // 清空对话的处理函数
  439. const handleClearMessages = useCallback(() => {
  440. setMessage([]);
  441. // 清空对话后保存,传入空数组
  442. setTimeout(() => saveMessagesImmediately([]), 0);
  443. }, [setMessage, saveMessagesImmediately]);
  444. // 处理粘贴图片
  445. const handlePasteImage = useCallback(
  446. (base64Data) => {
  447. if (!inputs.imageEnabled) {
  448. return;
  449. }
  450. // 添加图片到 imageUrls 数组
  451. const newUrls = [...(inputs.imageUrls || []), base64Data];
  452. handleInputChange('imageUrls', newUrls);
  453. },
  454. [inputs.imageEnabled, inputs.imageUrls, handleInputChange],
  455. );
  456. // Playground Context 值
  457. const playgroundContextValue = {
  458. onPasteImage: handlePasteImage,
  459. imageUrls: inputs.imageUrls || [],
  460. imageEnabled: inputs.imageEnabled || false,
  461. };
  462. return (
  463. <PlaygroundProvider value={playgroundContextValue}>
  464. <div className='h-full'>
  465. <Layout className='h-full bg-transparent flex flex-col md:flex-row'>
  466. {(showSettings || !isMobile) && (
  467. <Layout.Sider
  468. className={`
  469. bg-transparent border-r-0 flex-shrink-0 overflow-auto mt-[60px]
  470. ${
  471. isMobile
  472. ? 'fixed top-0 left-0 right-0 bottom-0 z-[1000] w-full h-auto bg-white shadow-lg'
  473. : 'relative z-[1] w-80 h-[calc(100vh-66px)]'
  474. }
  475. `}
  476. width={isMobile ? '100%' : 320}
  477. >
  478. <OptimizedSettingsPanel
  479. inputs={inputs}
  480. parameterEnabled={parameterEnabled}
  481. models={models}
  482. groups={groups}
  483. channels={channels}
  484. styleState={styleState}
  485. showSettings={showSettings}
  486. showDebugPanel={showDebugPanel}
  487. customRequestMode={customRequestMode}
  488. customRequestBody={customRequestBody}
  489. mutualExclusive={isMutualExclusive}
  490. onInputChange={handleInputChange}
  491. onParameterToggle={handleParameterToggle}
  492. onCloseSettings={() => setShowSettings(false)}
  493. onConfigImport={handleConfigImport}
  494. onConfigReset={handleConfigReset}
  495. onCustomRequestModeChange={setCustomRequestMode}
  496. onCustomRequestBodyChange={setCustomRequestBody}
  497. previewPayload={previewPayload}
  498. messages={message}
  499. />
  500. </Layout.Sider>
  501. )}
  502. <Layout.Content className='relative flex-1 overflow-hidden'>
  503. <div className='overflow-hidden flex flex-col lg:flex-row h-[calc(100vh-66px)] mt-[60px]'>
  504. <div className='flex-1 flex flex-col'>
  505. <ChatArea
  506. chatRef={chatRef}
  507. message={message}
  508. inputs={inputs}
  509. styleState={styleState}
  510. showDebugPanel={showDebugPanel}
  511. roleInfo={roleInfo}
  512. onMessageSend={onMessageSend}
  513. onMessageCopy={messageActions.handleMessageCopy}
  514. onMessageReset={messageActions.handleMessageReset}
  515. onMessageDelete={messageActions.handleMessageDelete}
  516. onStopGenerator={onStopGenerator}
  517. onClearMessages={handleClearMessages}
  518. onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
  519. renderCustomChatContent={renderCustomChatContent}
  520. renderChatBoxAction={renderChatBoxAction}
  521. />
  522. </div>
  523. {/* 调试面板 - 桌面端 */}
  524. {showDebugPanel && !isMobile && (
  525. <div className='w-96 flex-shrink-0 h-full'>
  526. <OptimizedDebugPanel
  527. debugData={debugData}
  528. activeDebugTab={activeDebugTab}
  529. onActiveDebugTabChange={setActiveDebugTab}
  530. styleState={styleState}
  531. customRequestMode={customRequestMode}
  532. />
  533. </div>
  534. )}
  535. </div>
  536. {/* 调试面板 - 移动端覆盖层 */}
  537. {showDebugPanel && isMobile && (
  538. <div className='fixed top-0 left-0 right-0 bottom-0 z-[1000] bg-white overflow-auto shadow-lg'>
  539. <OptimizedDebugPanel
  540. debugData={debugData}
  541. activeDebugTab={activeDebugTab}
  542. onActiveDebugTabChange={setActiveDebugTab}
  543. styleState={styleState}
  544. showDebugPanel={showDebugPanel}
  545. onCloseDebugPanel={() => setShowDebugPanel(false)}
  546. customRequestMode={customRequestMode}
  547. />
  548. </div>
  549. )}
  550. {/* 浮动按钮 */}
  551. <FloatingButtons
  552. styleState={styleState}
  553. showSettings={showSettings}
  554. showDebugPanel={showDebugPanel}
  555. onToggleSettings={() => setShowSettings(!showSettings)}
  556. onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
  557. />
  558. </Layout.Content>
  559. </Layout>
  560. </div>
  561. </PlaygroundProvider>
  562. );
  563. };
  564. export default Playground;