index.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
  1. import React, { useEffect, useRef, useState } from 'react';
  2. import { Button, GetProp, GetRef, Popover, Space, Spin, message } from 'antd';
  3. import {
  4. CloseOutlined,
  5. CloudUploadOutlined,
  6. CommentOutlined,
  7. OpenAIFilled,
  8. PlusOutlined,
  9. } from '@ant-design/icons';
  10. import {
  11. Attachments,
  12. type AttachmentsProps,
  13. Bubble,
  14. Conversations,
  15. Sender,
  16. Suggestion,
  17. Welcome,
  18. useXAgent,
  19. useXChat,
  20. } from '@ant-design/x';
  21. import type { Conversation } from '@ant-design/x/es/conversations';
  22. import { createStyles } from 'antd-style';
  23. import dayjs from 'dayjs';
  24. import ReactMarkdown from 'react-markdown';
  25. type BubbleDataType = {
  26. role: string;
  27. content: string;
  28. };
  29. const MOCK_SESSION_LIST = [
  30. {
  31. key: '5',
  32. label: 'New session',
  33. group: 'Today',
  34. },
  35. {
  36. key: '4',
  37. label: 'What has Ant Design X upgraded?',
  38. group: 'Today',
  39. },
  40. {
  41. key: '3',
  42. label: 'New AGI Hybrid Interface',
  43. group: 'Today',
  44. },
  45. {
  46. key: '2',
  47. label: 'How to quickly install and import components?',
  48. group: 'Yesterday',
  49. },
  50. {
  51. key: '1',
  52. label: 'What is Ant Design X?',
  53. group: 'Yesterday',
  54. },
  55. ];
  56. const MOCK_SUGGESTIONS = [
  57. { label: 'Write a report', value: 'report' },
  58. { label: 'Draw a picture', value: 'draw' },
  59. {
  60. label: 'Check some knowledge',
  61. value: 'knowledge',
  62. icon: <OpenAIFilled />,
  63. children: [
  64. { label: 'About React', value: 'react' },
  65. { label: 'About Ant Design', value: 'antd' },
  66. ],
  67. },
  68. ];
  69. const AGENT_PLACEHOLDER = 'Generating content, please wait...';
  70. const useCopilotStyle = createStyles(({ token, css }) => {
  71. return {
  72. copilotChat: css`
  73. display: flex;
  74. flex-direction: column;
  75. background: ${token.colorBgContainer};
  76. color: ${token.colorText};
  77. `,
  78. // chatHeader 样式
  79. chatHeader: css`
  80. height: 52px;
  81. box-sizing: border-box;
  82. border-bottom: 1px solid ${token.colorBorder};
  83. display: flex;
  84. align-items: center;
  85. justify-content: space-between;
  86. padding: 0 10px 0 16px;
  87. `,
  88. headerTitle: css`
  89. font-weight: 600;
  90. font-size: 15px;
  91. `,
  92. headerButton: css`
  93. font-size: 18px;
  94. `,
  95. conversations: css`
  96. width: 300px;
  97. .ant-conversations-list {
  98. padding-inline-start: 0;
  99. }
  100. `,
  101. // chatList 样式
  102. chatList: css`
  103. overflow: auto;
  104. padding-block: 16px;
  105. flex: 1;
  106. `,
  107. chatWelcome: css`
  108. margin-inline: 16px;
  109. padding: 12px 16px;
  110. border-radius: 2px 12px 12px 12px;
  111. background: ${token.colorBgTextHover};
  112. margin-bottom: 16px;
  113. `,
  114. loadingMessage: css`
  115. background-image: linear-gradient(90deg, #ff6b23 0%, #af3cb8 31%, #53b6ff 89%);
  116. background-size: 100% 2px;
  117. background-repeat: no-repeat;
  118. background-position: bottom;
  119. `,
  120. // chatSend 样式
  121. chatSend: css`
  122. padding: 12px;
  123. `,
  124. sendAction: css`
  125. display: flex;
  126. align-items: center;
  127. margin-bottom: 12px;
  128. gap: 8px;
  129. `,
  130. speechButton: css`
  131. font-size: 18px;
  132. color: ${token.colorText} !important;
  133. `,
  134. };
  135. });
  136. interface CopilotProps {
  137. copilotOpen: boolean;
  138. setCopilotOpen: (open: boolean) => void;
  139. }
  140. const Copilot = (props: CopilotProps) => {
  141. const { copilotOpen, setCopilotOpen } = props;
  142. const { styles } = useCopilotStyle();
  143. const attachmentsRef = useRef<GetRef<typeof Attachments>>(null);
  144. const abortController = useRef<AbortController>(null);
  145. // ==================== State ====================
  146. const [messageHistory, setMessageHistory] = useState<Record<string, any>>({});
  147. const [sessionList, setSessionList] = useState<Conversation[]>(MOCK_SESSION_LIST);
  148. const [curSession, setCurSession] = useState(sessionList[0].key);
  149. const [attachmentsOpen, setAttachmentsOpen] = useState(false);
  150. const [files, setFiles] = useState<GetProp<AttachmentsProps, 'items'>>([]);
  151. const [inputValue, setInputValue] = useState('');
  152. // 在这里切换请求
  153. const [agent] = useXAgent<BubbleDataType>({
  154. baseURL: '/api/deepseek/api/chat',
  155. });
  156. const loading = agent.isRequesting();
  157. const { messages, onRequest, setMessages } = useXChat({
  158. agent,
  159. requestFallback: (_, { error }) => {
  160. if (error.name === 'AbortError') {
  161. return {
  162. content: 'Request is aborted',
  163. role: 'assistant',
  164. };
  165. }
  166. return {
  167. content: 'Request failed, please try again!',
  168. role: 'assistant',
  169. };
  170. },
  171. transformMessage: (info) => {
  172. const { originMessage, chunk } = info || {};
  173. let currentContent = '';
  174. let currentThink = '';
  175. try {
  176. if (chunk?.data && !chunk?.data.includes('DONE')) {
  177. const message = JSON.parse(chunk?.data);
  178. currentThink = message?.choices?.[0]?.delta?.reasoning_content || '';
  179. currentContent = message?.choices?.[0]?.delta?.content || '';
  180. }
  181. } catch (error) {
  182. console.error(error);
  183. }
  184. let content = '';
  185. if (!originMessage?.content && currentThink) {
  186. content = `<think>${currentThink}`;
  187. } else if (
  188. originMessage?.content?.includes('<think>') &&
  189. !originMessage?.content.includes('</think>') &&
  190. currentContent
  191. ) {
  192. content = `${originMessage?.content}</think>${currentContent}`;
  193. } else {
  194. content = `${originMessage?.content || ''}${currentThink}${currentContent}`;
  195. }
  196. return {
  197. content: content,
  198. role: 'assistant',
  199. };
  200. },
  201. resolveAbortController: (controller) => {
  202. abortController.current = controller;
  203. },
  204. });
  205. // ==================== Event ====================
  206. const handleUserSubmit = (val: string) => {
  207. onRequest({
  208. message: { content: val, role: 'user' },
  209. appId: '2942534530212696064',
  210. // 进阶配置
  211. request_id: undefined,
  212. returnType: undefined,
  213. knowledge_ids: undefined,
  214. document_ids: undefined,
  215. });
  216. // session title mock
  217. if (sessionList.find((i) => i.key === curSession)?.label === 'New session') {
  218. setSessionList(
  219. sessionList.map((i) => (i.key !== curSession ? i : { ...i, label: val?.slice(0, 20) })),
  220. );
  221. }
  222. };
  223. const onPasteFile = (_: File, files: FileList) => {
  224. for (const file of files) {
  225. attachmentsRef.current?.upload(file);
  226. }
  227. setAttachmentsOpen(true);
  228. };
  229. // ==================== Nodes ====================
  230. const chatHeader = (
  231. <div className={styles.chatHeader}>
  232. <div className={styles.headerTitle}>✨ AI Copilot</div>
  233. <Space size={0}>
  234. <Button
  235. type="text"
  236. icon={<PlusOutlined />}
  237. onClick={() => {
  238. if (agent.isRequesting()) {
  239. message.error(
  240. 'Message is Requesting, you can create a new conversation after request done or abort it right now...',
  241. );
  242. return;
  243. }
  244. if (messages?.length) {
  245. const timeNow = dayjs().valueOf().toString();
  246. abortController.current?.abort();
  247. // The abort execution will trigger an asynchronous requestFallback, which may lead to timing issues.
  248. // In future versions, the sessionId capability will be added to resolve this problem.
  249. setTimeout(() => {
  250. setSessionList([
  251. { key: timeNow, label: 'New session', group: 'Today' },
  252. ...sessionList,
  253. ]);
  254. setCurSession(timeNow);
  255. setMessages([]);
  256. }, 100);
  257. } else {
  258. message.error('It is now a new conversation.');
  259. }
  260. }}
  261. className={styles.headerButton}
  262. />
  263. <Popover
  264. placement="bottom"
  265. styles={{ body: { padding: 0, maxHeight: 600 } }}
  266. content={
  267. <Conversations
  268. items={sessionList?.map((i) =>
  269. i.key === curSession ? { ...i, label: `[current] ${i.label}` } : i,
  270. )}
  271. activeKey={curSession}
  272. groupable
  273. onActiveChange={async (val) => {
  274. abortController.current?.abort();
  275. // The abort execution will trigger an asynchronous requestFallback, which may lead to timing issues.
  276. // In future versions, the sessionId capability will be added to resolve this problem.
  277. setTimeout(() => {
  278. setCurSession(val);
  279. setMessages(messageHistory?.[val] || []);
  280. }, 100);
  281. }}
  282. styles={{ item: { padding: '0 8px' } }}
  283. className={styles.conversations}
  284. />
  285. }
  286. >
  287. <Button type="text" icon={<CommentOutlined />} className={styles.headerButton} />
  288. </Popover>
  289. <Button
  290. type="text"
  291. icon={<CloseOutlined />}
  292. onClick={() => setCopilotOpen(false)}
  293. className={styles.headerButton}
  294. />
  295. </Space>
  296. </div>
  297. );
  298. const chatList = (
  299. <div className={styles.chatList}>
  300. {messages?.length ? (
  301. /** 消息列表 */
  302. <Bubble.List
  303. style={{ height: '100%', paddingInline: 16 }}
  304. items={messages?.map((i) => ({
  305. ...i.message,
  306. classNames: {
  307. content: i.status === 'loading' ? styles.loadingMessage : '',
  308. },
  309. typing: i.status === 'loading' ? { step: 5, interval: 20, suffix: <>💗</> } : false,
  310. content: <ReactMarkdown>{i.message.content}</ReactMarkdown>,
  311. }))}
  312. roles={{
  313. assistant: {
  314. placement: 'start',
  315. // footer: (
  316. // <div style={{ display: 'flex' }}>
  317. // <Button type="text" size="small" icon={<ReloadOutlined />} />
  318. // <Button type="text" size="small" icon={<CopyOutlined />} />
  319. // <Button type="text" size="small" icon={<LikeOutlined />} />
  320. // <Button type="text" size="small" icon={<DislikeOutlined />} />
  321. // </div>
  322. // ),
  323. loadingRender: () => (
  324. <Space>
  325. <Spin size="small" />
  326. {AGENT_PLACEHOLDER}
  327. </Space>
  328. ),
  329. },
  330. user: { placement: 'end' },
  331. }}
  332. />
  333. ) : (
  334. /** 没有消息时的 welcome */
  335. <Welcome
  336. variant="borderless"
  337. title="预览调试"
  338. description="请输入问题进行预览调试"
  339. className={styles.chatWelcome}
  340. />
  341. )}
  342. </div>
  343. );
  344. const sendHeader = (
  345. <Sender.Header
  346. title="Upload File"
  347. styles={{ content: { padding: 0 } }}
  348. open={attachmentsOpen}
  349. onOpenChange={setAttachmentsOpen}
  350. forceRender
  351. >
  352. <Attachments
  353. ref={attachmentsRef}
  354. beforeUpload={() => false}
  355. items={files}
  356. onChange={({ fileList }) => setFiles(fileList)}
  357. placeholder={(type) =>
  358. type === 'drop'
  359. ? { title: 'Drop file here' }
  360. : {
  361. icon: <CloudUploadOutlined />,
  362. title: 'Upload files',
  363. description: 'Click or drag files to this area to upload',
  364. }
  365. }
  366. />
  367. </Sender.Header>
  368. );
  369. const chatSender = (
  370. <div className={styles.chatSend}>
  371. {/** 输入框 */}
  372. <Suggestion items={MOCK_SUGGESTIONS} onSelect={(itemVal) => setInputValue(`[${itemVal}]:`)}>
  373. {({ onTrigger, onKeyDown }) => (
  374. <Sender
  375. loading={loading}
  376. value={inputValue}
  377. onChange={(v) => {
  378. onTrigger(v === '/');
  379. setInputValue(v);
  380. }}
  381. onSubmit={() => {
  382. handleUserSubmit(inputValue);
  383. setInputValue('');
  384. }}
  385. onCancel={() => {
  386. abortController.current?.abort();
  387. }}
  388. allowSpeech
  389. placeholder="请输入"
  390. onKeyDown={onKeyDown}
  391. header={sendHeader}
  392. // prefix={
  393. // <Button
  394. // type="text"
  395. // icon={<PaperClipOutlined style={{ fontSize: 18 }} />}
  396. // onClick={() => setAttachmentsOpen(!attachmentsOpen)}
  397. // />
  398. // }
  399. onPasteFile={onPasteFile}
  400. actions={(_, info) => {
  401. const { SendButton, LoadingButton, SpeechButton } = info.components;
  402. return (
  403. <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
  404. {/* <SpeechButton className={styles.speechButton} /> */}
  405. {loading ? <LoadingButton type="default" /> : <SendButton type="primary" />}
  406. </div>
  407. );
  408. }}
  409. />
  410. )}
  411. </Suggestion>
  412. </div>
  413. );
  414. useEffect(() => {
  415. // history mock
  416. if (messages?.length) {
  417. setMessageHistory((prev) => ({
  418. ...prev,
  419. [curSession]: messages,
  420. }));
  421. }
  422. }, [messages]);
  423. return (
  424. <div className={styles.copilotChat} style={{ width: copilotOpen ? '100%' : 0 }}>
  425. {/** 对话区 - header */}
  426. {/* {chatHeader} */}
  427. {/** 对话区 - 消息列表 */}
  428. {chatList}
  429. {/** 对话区 - 输入框 */}
  430. {chatSender}
  431. </div>
  432. );
  433. };
  434. const useWorkareaStyle = createStyles(({ token, css }) => {
  435. return {
  436. copilotWrapper: css`
  437. height: 100%;
  438. display: flex;
  439. `,
  440. workarea: css`
  441. flex: 1;
  442. background: ${token.colorBgLayout};
  443. display: flex;
  444. flex-direction: column;
  445. `,
  446. workareaHeader: css`
  447. box-sizing: border-box;
  448. height: 52px;
  449. display: flex;
  450. align-items: center;
  451. justify-content: space-between;
  452. padding: 0 48px 0 28px;
  453. border-bottom: 1px solid ${token.colorBorder};
  454. `,
  455. headerTitle: css`
  456. font-weight: 600;
  457. font-size: 15px;
  458. color: ${token.colorText};
  459. display: flex;
  460. align-items: center;
  461. gap: 8px;
  462. `,
  463. headerButton: css`
  464. background-image: linear-gradient(78deg, #8054f2 7%, #3895da 95%);
  465. border-radius: 12px;
  466. height: 24px;
  467. width: 93px;
  468. display: flex;
  469. align-items: center;
  470. justify-content: center;
  471. color: #fff;
  472. cursor: pointer;
  473. font-size: 12px;
  474. font-weight: 600;
  475. transition: all 0.3s;
  476. &:hover {
  477. opacity: 0.8;
  478. }
  479. `,
  480. workareaBody: css`
  481. flex: 1;
  482. padding: 16px;
  483. background: ${token.colorBgContainer};
  484. border-radius: 16px;
  485. min-height: 0;
  486. `,
  487. bodyContent: css`
  488. overflow: auto;
  489. height: 100%;
  490. padding-right: 10px;
  491. `,
  492. bodyText: css`
  493. color: ${token.colorText};
  494. padding: 8px;
  495. `,
  496. };
  497. });
  498. const Chat: React.FC = () => {
  499. const { styles: workareaStyles } = useWorkareaStyle();
  500. const [copilotOpen, setCopilotOpen] = useState(true);
  501. return (
  502. <div className={workareaStyles.copilotWrapper}>
  503. <Copilot copilotOpen={copilotOpen} setCopilotOpen={setCopilotOpen} />
  504. </div>
  505. );
  506. };
  507. export default Chat;