|
|
@@ -0,0 +1,535 @@
|
|
|
+import React, { useEffect, useRef, useState } from 'react';
|
|
|
+import { Button, GetProp, GetRef, Popover, Space, Spin, message } from 'antd';
|
|
|
+import {
|
|
|
+ CloseOutlined,
|
|
|
+ CloudUploadOutlined,
|
|
|
+ CommentOutlined,
|
|
|
+ OpenAIFilled,
|
|
|
+ PlusOutlined,
|
|
|
+} from '@ant-design/icons';
|
|
|
+import {
|
|
|
+ Attachments,
|
|
|
+ type AttachmentsProps,
|
|
|
+ Bubble,
|
|
|
+ Conversations,
|
|
|
+ Sender,
|
|
|
+ Suggestion,
|
|
|
+ Welcome,
|
|
|
+ useXAgent,
|
|
|
+ useXChat,
|
|
|
+} from '@ant-design/x';
|
|
|
+import type { Conversation } from '@ant-design/x/es/conversations';
|
|
|
+import { createStyles } from 'antd-style';
|
|
|
+import dayjs from 'dayjs';
|
|
|
+import ReactMarkdown from 'react-markdown';
|
|
|
+
|
|
|
+type BubbleDataType = {
|
|
|
+ role: string;
|
|
|
+ content: string;
|
|
|
+};
|
|
|
+
|
|
|
+const MOCK_SESSION_LIST = [
|
|
|
+ {
|
|
|
+ key: '5',
|
|
|
+ label: 'New session',
|
|
|
+ group: 'Today',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: '4',
|
|
|
+ label: 'What has Ant Design X upgraded?',
|
|
|
+ group: 'Today',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: '3',
|
|
|
+ label: 'New AGI Hybrid Interface',
|
|
|
+ group: 'Today',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: '2',
|
|
|
+ label: 'How to quickly install and import components?',
|
|
|
+ group: 'Yesterday',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: '1',
|
|
|
+ label: 'What is Ant Design X?',
|
|
|
+ group: 'Yesterday',
|
|
|
+ },
|
|
|
+];
|
|
|
+
|
|
|
+const MOCK_SUGGESTIONS = [
|
|
|
+ { label: 'Write a report', value: 'report' },
|
|
|
+ { label: 'Draw a picture', value: 'draw' },
|
|
|
+ {
|
|
|
+ label: 'Check some knowledge',
|
|
|
+ value: 'knowledge',
|
|
|
+ icon: <OpenAIFilled />,
|
|
|
+ children: [
|
|
|
+ { label: 'About React', value: 'react' },
|
|
|
+ { label: 'About Ant Design', value: 'antd' },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+];
|
|
|
+
|
|
|
+const AGENT_PLACEHOLDER = 'Generating content, please wait...';
|
|
|
+
|
|
|
+const useCopilotStyle = createStyles(({ token, css }) => {
|
|
|
+ return {
|
|
|
+ copilotChat: css`
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ background: ${token.colorBgContainer};
|
|
|
+ color: ${token.colorText};
|
|
|
+ `,
|
|
|
+ // chatHeader 样式
|
|
|
+ chatHeader: css`
|
|
|
+ height: 52px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ border-bottom: 1px solid ${token.colorBorder};
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding: 0 10px 0 16px;
|
|
|
+ `,
|
|
|
+ headerTitle: css`
|
|
|
+ font-weight: 600;
|
|
|
+ font-size: 15px;
|
|
|
+ `,
|
|
|
+ headerButton: css`
|
|
|
+ font-size: 18px;
|
|
|
+ `,
|
|
|
+ conversations: css`
|
|
|
+ width: 300px;
|
|
|
+ .ant-conversations-list {
|
|
|
+ padding-inline-start: 0;
|
|
|
+ }
|
|
|
+ `,
|
|
|
+ // chatList 样式
|
|
|
+ chatList: css`
|
|
|
+ overflow: auto;
|
|
|
+ padding-block: 16px;
|
|
|
+ flex: 1;
|
|
|
+ `,
|
|
|
+ chatWelcome: css`
|
|
|
+ margin-inline: 16px;
|
|
|
+ padding: 12px 16px;
|
|
|
+ border-radius: 2px 12px 12px 12px;
|
|
|
+ background: ${token.colorBgTextHover};
|
|
|
+ margin-bottom: 16px;
|
|
|
+ `,
|
|
|
+ loadingMessage: css`
|
|
|
+ background-image: linear-gradient(90deg, #ff6b23 0%, #af3cb8 31%, #53b6ff 89%);
|
|
|
+ background-size: 100% 2px;
|
|
|
+ background-repeat: no-repeat;
|
|
|
+ background-position: bottom;
|
|
|
+ `,
|
|
|
+ // chatSend 样式
|
|
|
+ chatSend: css`
|
|
|
+ padding: 12px;
|
|
|
+ `,
|
|
|
+ sendAction: css`
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ gap: 8px;
|
|
|
+ `,
|
|
|
+ speechButton: css`
|
|
|
+ font-size: 18px;
|
|
|
+ color: ${token.colorText} !important;
|
|
|
+ `,
|
|
|
+ };
|
|
|
+});
|
|
|
+
|
|
|
+interface CopilotProps {
|
|
|
+ copilotOpen: boolean;
|
|
|
+ setCopilotOpen: (open: boolean) => void;
|
|
|
+}
|
|
|
+
|
|
|
+const Copilot = (props: CopilotProps) => {
|
|
|
+ const { copilotOpen, setCopilotOpen } = props;
|
|
|
+ const { styles } = useCopilotStyle();
|
|
|
+ const attachmentsRef = useRef<GetRef<typeof Attachments>>(null);
|
|
|
+ const abortController = useRef<AbortController>(null);
|
|
|
+
|
|
|
+ // ==================== State ====================
|
|
|
+
|
|
|
+ const [messageHistory, setMessageHistory] = useState<Record<string, any>>({});
|
|
|
+
|
|
|
+ const [sessionList, setSessionList] = useState<Conversation[]>(MOCK_SESSION_LIST);
|
|
|
+ const [curSession, setCurSession] = useState(sessionList[0].key);
|
|
|
+
|
|
|
+ const [attachmentsOpen, setAttachmentsOpen] = useState(false);
|
|
|
+ const [files, setFiles] = useState<GetProp<AttachmentsProps, 'items'>>([]);
|
|
|
+
|
|
|
+ const [inputValue, setInputValue] = useState('');
|
|
|
+
|
|
|
+ // 在这里切换请求
|
|
|
+ const [agent] = useXAgent<BubbleDataType>({
|
|
|
+ baseURL: '/api/deepseek/api/chat',
|
|
|
+ });
|
|
|
+
|
|
|
+ const loading = agent.isRequesting();
|
|
|
+
|
|
|
+ const { messages, onRequest, setMessages } = useXChat({
|
|
|
+ agent,
|
|
|
+ requestFallback: (_, { error }) => {
|
|
|
+ if (error.name === 'AbortError') {
|
|
|
+ return {
|
|
|
+ content: 'Request is aborted',
|
|
|
+ role: 'assistant',
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ content: 'Request failed, please try again!',
|
|
|
+ role: 'assistant',
|
|
|
+ };
|
|
|
+ },
|
|
|
+ transformMessage: (info) => {
|
|
|
+ const { originMessage, chunk } = info || {};
|
|
|
+ let currentContent = '';
|
|
|
+ let currentThink = '';
|
|
|
+ try {
|
|
|
+ if (chunk?.data && !chunk?.data.includes('DONE')) {
|
|
|
+ const message = JSON.parse(chunk?.data);
|
|
|
+ currentThink = message?.choices?.[0]?.delta?.reasoning_content || '';
|
|
|
+ currentContent = message?.choices?.[0]?.delta?.content || '';
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error(error);
|
|
|
+ }
|
|
|
+
|
|
|
+ let content = '';
|
|
|
+
|
|
|
+ if (!originMessage?.content && currentThink) {
|
|
|
+ content = `<think>${currentThink}`;
|
|
|
+ } else if (
|
|
|
+ originMessage?.content?.includes('<think>') &&
|
|
|
+ !originMessage?.content.includes('</think>') &&
|
|
|
+ currentContent
|
|
|
+ ) {
|
|
|
+ content = `${originMessage?.content}</think>${currentContent}`;
|
|
|
+ } else {
|
|
|
+ content = `${originMessage?.content || ''}${currentThink}${currentContent}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ content: content,
|
|
|
+ role: 'assistant',
|
|
|
+ };
|
|
|
+ },
|
|
|
+ resolveAbortController: (controller) => {
|
|
|
+ abortController.current = controller;
|
|
|
+ },
|
|
|
+ });
|
|
|
+ // ==================== Event ====================
|
|
|
+ const handleUserSubmit = (val: string) => {
|
|
|
+ onRequest({
|
|
|
+ message: { content: val, role: 'user' },
|
|
|
+ appId: '2942534530212696064',
|
|
|
+ // 进阶配置
|
|
|
+ request_id: undefined,
|
|
|
+ returnType: undefined,
|
|
|
+ knowledge_ids: undefined,
|
|
|
+ document_ids: undefined,
|
|
|
+ });
|
|
|
+
|
|
|
+ // session title mock
|
|
|
+ if (sessionList.find((i) => i.key === curSession)?.label === 'New session') {
|
|
|
+ setSessionList(
|
|
|
+ sessionList.map((i) => (i.key !== curSession ? i : { ...i, label: val?.slice(0, 20) })),
|
|
|
+ );
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const onPasteFile = (_: File, files: FileList) => {
|
|
|
+ for (const file of files) {
|
|
|
+ attachmentsRef.current?.upload(file);
|
|
|
+ }
|
|
|
+ setAttachmentsOpen(true);
|
|
|
+ };
|
|
|
+
|
|
|
+ // ==================== Nodes ====================
|
|
|
+ const chatHeader = (
|
|
|
+ <div className={styles.chatHeader}>
|
|
|
+ <div className={styles.headerTitle}>✨ AI Copilot</div>
|
|
|
+ <Space size={0}>
|
|
|
+ <Button
|
|
|
+ type="text"
|
|
|
+ icon={<PlusOutlined />}
|
|
|
+ onClick={() => {
|
|
|
+ if (agent.isRequesting()) {
|
|
|
+ message.error(
|
|
|
+ 'Message is Requesting, you can create a new conversation after request done or abort it right now...',
|
|
|
+ );
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (messages?.length) {
|
|
|
+ const timeNow = dayjs().valueOf().toString();
|
|
|
+ abortController.current?.abort();
|
|
|
+ // The abort execution will trigger an asynchronous requestFallback, which may lead to timing issues.
|
|
|
+ // In future versions, the sessionId capability will be added to resolve this problem.
|
|
|
+ setTimeout(() => {
|
|
|
+ setSessionList([
|
|
|
+ { key: timeNow, label: 'New session', group: 'Today' },
|
|
|
+ ...sessionList,
|
|
|
+ ]);
|
|
|
+ setCurSession(timeNow);
|
|
|
+ setMessages([]);
|
|
|
+ }, 100);
|
|
|
+ } else {
|
|
|
+ message.error('It is now a new conversation.');
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ className={styles.headerButton}
|
|
|
+ />
|
|
|
+ <Popover
|
|
|
+ placement="bottom"
|
|
|
+ styles={{ body: { padding: 0, maxHeight: 600 } }}
|
|
|
+ content={
|
|
|
+ <Conversations
|
|
|
+ items={sessionList?.map((i) =>
|
|
|
+ i.key === curSession ? { ...i, label: `[current] ${i.label}` } : i,
|
|
|
+ )}
|
|
|
+ activeKey={curSession}
|
|
|
+ groupable
|
|
|
+ onActiveChange={async (val) => {
|
|
|
+ abortController.current?.abort();
|
|
|
+ // The abort execution will trigger an asynchronous requestFallback, which may lead to timing issues.
|
|
|
+ // In future versions, the sessionId capability will be added to resolve this problem.
|
|
|
+ setTimeout(() => {
|
|
|
+ setCurSession(val);
|
|
|
+ setMessages(messageHistory?.[val] || []);
|
|
|
+ }, 100);
|
|
|
+ }}
|
|
|
+ styles={{ item: { padding: '0 8px' } }}
|
|
|
+ className={styles.conversations}
|
|
|
+ />
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <Button type="text" icon={<CommentOutlined />} className={styles.headerButton} />
|
|
|
+ </Popover>
|
|
|
+ <Button
|
|
|
+ type="text"
|
|
|
+ icon={<CloseOutlined />}
|
|
|
+ onClick={() => setCopilotOpen(false)}
|
|
|
+ className={styles.headerButton}
|
|
|
+ />
|
|
|
+ </Space>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ const chatList = (
|
|
|
+ <div className={styles.chatList}>
|
|
|
+ {messages?.length ? (
|
|
|
+ /** 消息列表 */
|
|
|
+ <Bubble.List
|
|
|
+ style={{ height: '100%', paddingInline: 16 }}
|
|
|
+ items={messages?.map((i) => ({
|
|
|
+ ...i.message,
|
|
|
+ classNames: {
|
|
|
+ content: i.status === 'loading' ? styles.loadingMessage : '',
|
|
|
+ },
|
|
|
+ typing: i.status === 'loading' ? { step: 5, interval: 20, suffix: <>💗</> } : false,
|
|
|
+ content: <ReactMarkdown>{i.message.content}</ReactMarkdown>,
|
|
|
+ }))}
|
|
|
+ roles={{
|
|
|
+ assistant: {
|
|
|
+ placement: 'start',
|
|
|
+ // footer: (
|
|
|
+ // <div style={{ display: 'flex' }}>
|
|
|
+ // <Button type="text" size="small" icon={<ReloadOutlined />} />
|
|
|
+ // <Button type="text" size="small" icon={<CopyOutlined />} />
|
|
|
+ // <Button type="text" size="small" icon={<LikeOutlined />} />
|
|
|
+ // <Button type="text" size="small" icon={<DislikeOutlined />} />
|
|
|
+ // </div>
|
|
|
+ // ),
|
|
|
+ loadingRender: () => (
|
|
|
+ <Space>
|
|
|
+ <Spin size="small" />
|
|
|
+ {AGENT_PLACEHOLDER}
|
|
|
+ </Space>
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ user: { placement: 'end' },
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ ) : (
|
|
|
+ /** 没有消息时的 welcome */
|
|
|
+ <Welcome
|
|
|
+ variant="borderless"
|
|
|
+ title="预览调试"
|
|
|
+ description="请输入问题进行预览调试"
|
|
|
+ className={styles.chatWelcome}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ const sendHeader = (
|
|
|
+ <Sender.Header
|
|
|
+ title="Upload File"
|
|
|
+ styles={{ content: { padding: 0 } }}
|
|
|
+ open={attachmentsOpen}
|
|
|
+ onOpenChange={setAttachmentsOpen}
|
|
|
+ forceRender
|
|
|
+ >
|
|
|
+ <Attachments
|
|
|
+ ref={attachmentsRef}
|
|
|
+ beforeUpload={() => false}
|
|
|
+ items={files}
|
|
|
+ onChange={({ fileList }) => setFiles(fileList)}
|
|
|
+ placeholder={(type) =>
|
|
|
+ type === 'drop'
|
|
|
+ ? { title: 'Drop file here' }
|
|
|
+ : {
|
|
|
+ icon: <CloudUploadOutlined />,
|
|
|
+ title: 'Upload files',
|
|
|
+ description: 'Click or drag files to this area to upload',
|
|
|
+ }
|
|
|
+ }
|
|
|
+ />
|
|
|
+ </Sender.Header>
|
|
|
+ );
|
|
|
+ const chatSender = (
|
|
|
+ <div className={styles.chatSend}>
|
|
|
+ {/** 输入框 */}
|
|
|
+ <Suggestion items={MOCK_SUGGESTIONS} onSelect={(itemVal) => setInputValue(`[${itemVal}]:`)}>
|
|
|
+ {({ onTrigger, onKeyDown }) => (
|
|
|
+ <Sender
|
|
|
+ loading={loading}
|
|
|
+ value={inputValue}
|
|
|
+ onChange={(v) => {
|
|
|
+ onTrigger(v === '/');
|
|
|
+ setInputValue(v);
|
|
|
+ }}
|
|
|
+ onSubmit={() => {
|
|
|
+ handleUserSubmit(inputValue);
|
|
|
+ setInputValue('');
|
|
|
+ }}
|
|
|
+ onCancel={() => {
|
|
|
+ abortController.current?.abort();
|
|
|
+ }}
|
|
|
+ allowSpeech
|
|
|
+ placeholder="请输入"
|
|
|
+ onKeyDown={onKeyDown}
|
|
|
+ header={sendHeader}
|
|
|
+ // prefix={
|
|
|
+ // <Button
|
|
|
+ // type="text"
|
|
|
+ // icon={<PaperClipOutlined style={{ fontSize: 18 }} />}
|
|
|
+ // onClick={() => setAttachmentsOpen(!attachmentsOpen)}
|
|
|
+ // />
|
|
|
+ // }
|
|
|
+ onPasteFile={onPasteFile}
|
|
|
+ actions={(_, info) => {
|
|
|
+ const { SendButton, LoadingButton, SpeechButton } = info.components;
|
|
|
+ return (
|
|
|
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
|
+ {/* <SpeechButton className={styles.speechButton} /> */}
|
|
|
+ {loading ? <LoadingButton type="default" /> : <SendButton type="primary" />}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </Suggestion>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ // history mock
|
|
|
+ if (messages?.length) {
|
|
|
+ setMessageHistory((prev) => ({
|
|
|
+ ...prev,
|
|
|
+ [curSession]: messages,
|
|
|
+ }));
|
|
|
+ }
|
|
|
+ }, [messages]);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className={styles.copilotChat} style={{ width: copilotOpen ? '100%' : 0 }}>
|
|
|
+ {/** 对话区 - header */}
|
|
|
+ {/* {chatHeader} */}
|
|
|
+ {/** 对话区 - 消息列表 */}
|
|
|
+ {chatList}
|
|
|
+ {/** 对话区 - 输入框 */}
|
|
|
+ {chatSender}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+const useWorkareaStyle = createStyles(({ token, css }) => {
|
|
|
+ return {
|
|
|
+ copilotWrapper: css`
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ `,
|
|
|
+ workarea: css`
|
|
|
+ flex: 1;
|
|
|
+ background: ${token.colorBgLayout};
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ `,
|
|
|
+ workareaHeader: css`
|
|
|
+ box-sizing: border-box;
|
|
|
+ height: 52px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding: 0 48px 0 28px;
|
|
|
+ border-bottom: 1px solid ${token.colorBorder};
|
|
|
+ `,
|
|
|
+ headerTitle: css`
|
|
|
+ font-weight: 600;
|
|
|
+ font-size: 15px;
|
|
|
+ color: ${token.colorText};
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ `,
|
|
|
+ headerButton: css`
|
|
|
+ background-image: linear-gradient(78deg, #8054f2 7%, #3895da 95%);
|
|
|
+ border-radius: 12px;
|
|
|
+ height: 24px;
|
|
|
+ width: 93px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #fff;
|
|
|
+ cursor: pointer;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 600;
|
|
|
+ transition: all 0.3s;
|
|
|
+ &:hover {
|
|
|
+ opacity: 0.8;
|
|
|
+ }
|
|
|
+ `,
|
|
|
+ workareaBody: css`
|
|
|
+ flex: 1;
|
|
|
+ padding: 16px;
|
|
|
+ background: ${token.colorBgContainer};
|
|
|
+ border-radius: 16px;
|
|
|
+ min-height: 0;
|
|
|
+ `,
|
|
|
+ bodyContent: css`
|
|
|
+ overflow: auto;
|
|
|
+ height: 100%;
|
|
|
+ padding-right: 10px;
|
|
|
+ `,
|
|
|
+ bodyText: css`
|
|
|
+ color: ${token.colorText};
|
|
|
+ padding: 8px;
|
|
|
+ `,
|
|
|
+ };
|
|
|
+});
|
|
|
+
|
|
|
+const Chat: React.FC = () => {
|
|
|
+ const { styles: workareaStyles } = useWorkareaStyle();
|
|
|
+ const [copilotOpen, setCopilotOpen] = useState(true);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className={workareaStyles.copilotWrapper}>
|
|
|
+ <Copilot copilotOpen={copilotOpen} setCopilotOpen={setCopilotOpen} />
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+export default Chat;
|