| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535 |
- 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;
|