| | |
| | | import remarkBreaks from "remark-breaks"; |
| | | import rehypeRaw from "rehype-raw"; |
| | | import rehypeSanitize from "rehype-sanitize"; |
| | | import rehypeHighlight from "rehype-highlight"; |
| | | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; |
| | | import { ghcolors } from "react-syntax-highlighter/dist/esm/styles/prism"; |
| | | import logo from "/favicon.png"; |
| | | import "./markdown-styles.css"; |
| | | |
| | | const codeStyle: Record<string, React.CSSProperties> = { |
| | | ...(Object.fromEntries( |
| | | Object.entries(ghcolors).filter(([key]) => typeof key === "string") |
| | | ) as Record<string, React.CSSProperties>), |
| | | }; |
| | | |
| | | const loadMermaid = () => { |
| | | if (typeof window !== "undefined" && !(window as any).mermaid) { |
| | |
| | | botAvatar?: string; |
| | | request: (messages: ChatMessage[]) => Promise<Response>; |
| | | clearMessage?: () => void; |
| | | botId?: string; // 新增 botId 参数 |
| | | botId?: string; |
| | | botTypeId?: number; |
| | | onFileUploaded?: (fileId: string) => void; // 新增文件上传回调 |
| | | onFileRemoved?: (removedFileId?: string) => void; // 新增文件删除回调 |
| | | onFileUploaded?: (fileId: string) => void; |
| | | onFileRemoved?: (removedFileId?: string) => void; |
| | | }; |
| | | |
| | | export const AiProChat = ({ |
| | |
| | | botAvatar = `${logo}`, |
| | | request, |
| | | clearMessage, |
| | | botId, // 新增 |
| | | botId, |
| | | botTypeId, |
| | | onFileUploaded, // 新增 |
| | | onFileRemoved, // 新增 |
| | | onFileUploaded, |
| | | onFileRemoved, |
| | | }: AiProChatProps) => { |
| | | const isControlled = |
| | | parentChats !== undefined && parentOnChatsChange !== undefined; |
| | |
| | | } |
| | | }, [chats]); |
| | | |
| | | |
| | | const handleRemoveFile = (name: string) => { |
| | | const fileToRemove = fileList.find(f => f.name === name); |
| | | const fileToRemove = fileList.find((f) => f.name === name); |
| | | console.log("handleRemoveFile", fileToRemove); |
| | | |
| | | |
| | | if (!fileToRemove) return; |
| | | |
| | | const newUploadedFiles = uploadedFiles.filter(f => f !== name); |
| | | const newFileList = fileList.filter(f => f.name !== name); |
| | | |
| | | |
| | | const newUploadedFiles = uploadedFiles.filter((f) => f !== name); |
| | | const newFileList = fileList.filter((f) => f.name !== name); |
| | | |
| | | setUploadedFiles(newUploadedFiles); |
| | | setFileList(newFileList); |
| | | |
| | | // 确保传递的是文件的 url (即文件ID) |
| | | |
| | | if (fileToRemove.url) { |
| | | onFileRemoved?.(fileToRemove.url); |
| | | } |
| | | }; |
| | | |
| | | |
| | | const handleUpload = async (file: File) => { |
| | | if (botTypeId === 2 && uploadedFiles.length >= 1) { |
| | | messageApi.warning("只能上传一个文件,请删除已上传文件"); |
| | | return false; |
| | | } |
| | | // 多文件模式检查(假设限制最多5个文件) |
| | | if (botTypeId === 3 && uploadedFiles.length >= 5) { |
| | | messageApi.warning("最多只能上传5个文件"); |
| | | return false; |
| | | } |
| | | |
| | | setFileList(prev => [ |
| | | setFileList((prev) => [ |
| | | ...prev, |
| | | { |
| | | uid: file.name, |
| | |
| | | status: "uploading", |
| | | }, |
| | | ]); |
| | | |
| | | |
| | | const formData = new FormData(); |
| | | const tokenKey = `${import.meta.env.VITE_APP_TOKEN_KEY}`; |
| | | formData.append("file", file); |
| | |
| | | const result = await res.json(); |
| | | const fileData = JSON.parse(result.data.trim()); |
| | | |
| | | setFileList(prev => |
| | | prev.map(f => |
| | | f.name === file.name |
| | | setFileList((prev) => |
| | | prev.map((f) => |
| | | f.name === file.name |
| | | ? { ...f, uid: fileData.name, status: "done", url: fileData.id } |
| | | : f |
| | | ) |
| | |
| | | |
| | | setUploadedFiles((prev) => [...prev, fileData.name]); |
| | | messageApi.success(`${fileData.name} 上传成功`); |
| | | // 调用回调函数传递文件ID |
| | | onFileUploaded?.(fileData.id); |
| | | |
| | | return fileData; |
| | | } catch (error) { |
| | | setFileList(prev => |
| | | prev.map(f => |
| | | f.name === file.name |
| | | ? { ...f, status: "error" } |
| | | : f |
| | | ) |
| | | setFileList((prev) => |
| | | prev.map((f) => (f.name === file.name ? { ...f, status: "error" } : f)) |
| | | ); |
| | | messageApi.error((error as Error).message || "上传失败"); |
| | | throw error; |
| | |
| | | } |
| | | }; |
| | | |
| | | const renderMessages = () => { |
| | | if (!chats?.length) { |
| | | return ( |
| | | <Welcome |
| | | variant="borderless" |
| | | icon={ |
| | | // 修改 renderMessages 函数中的 ReactMarkdown 部分 |
| | | const renderMessages = () => { |
| | | if (!chats?.length) { |
| | | return ( |
| | | <Welcome |
| | | variant="borderless" |
| | | icon={ |
| | | <img |
| | | src={botAvatar} |
| | | style={{ width: 32, height: 32, borderRadius: "50%" }} |
| | | alt="AI Avatar" |
| | | /> |
| | | } |
| | | description={helloMessage} |
| | | styles={{ icon: { width: 40, height: 40 } }} |
| | | /> |
| | | ); |
| | | } |
| | | return ( |
| | | <Bubble.List |
| | | autoScroll={true} |
| | | items={chats.map((chat, index) => ({ |
| | | key: chat.id + Math.random().toString(), |
| | | typing: { suffix: <>💗</> }, |
| | | header: <Space>{new Date(chat.created).toLocaleString()}</Space>, |
| | | loading: chat.loading, |
| | | loadingRender: () => ( |
| | | <Space> |
| | | <Spin size="small" /> |
| | | AI正在思考中... |
| | | </Space> |
| | | ), |
| | | footer: ( |
| | | <Space> |
| | | {chat.role === "assistant" && ( |
| | | <Button |
| | | color="default" |
| | | variant="text" |
| | | size="small" |
| | | icon={<SyncOutlined />} |
| | | onClick={() => handleRegenerate(index)} |
| | | /> |
| | | )} |
| | | <Button |
| | | color="default" |
| | | variant="text" |
| | | size="small" |
| | | icon={<CopyOutlined />} |
| | | onClick={async () => { |
| | | try { |
| | | await navigator.clipboard.writeText(chat.content); |
| | | message.success("复制成功"); |
| | | } catch (error) { |
| | | message.error("复制失败"); |
| | | } |
| | | }} |
| | | /> |
| | | </Space> |
| | | ), |
| | | role: chat.role === "user" ? "local" : "ai", |
| | | content: ( |
| | | <div className="markdown-body"> |
| | | {chat.role === "assistant" ? ( |
| | | <ReactMarkdown |
| | | remarkPlugins={[remarkGfm, remarkBreaks]} |
| | | rehypePlugins={[rehypeRaw, rehypeSanitize]} |
| | | components={{ |
| | | code({ node, className, children, ...props }) { |
| | | const match = /language-(\w+)/.exec(className || ""); |
| | | return match ? ( |
| | | <pre className={className} {...props as React.HTMLAttributes<HTMLPreElement>}> |
| | | <code>{children}</code> |
| | | </pre> |
| | | ) : ( |
| | | <code className={className} {...props as React.HTMLAttributes<HTMLElement>}> |
| | | {children} |
| | | </code> |
| | | ); |
| | | }, |
| | | }} |
| | | > |
| | | {chat.content} |
| | | </ReactMarkdown> |
| | | ) : ( |
| | | chat.content |
| | | )} |
| | | </div> |
| | | ), |
| | | avatar: |
| | | chat.role === "assistant" ? ( |
| | | <img |
| | | src={botAvatar} |
| | | style={{ width: 32, height: 32, borderRadius: "50%" }} |
| | | alt="AI Avatar" |
| | | /> |
| | | } |
| | | description={helloMessage} |
| | | styles={{ icon: { width: 40, height: 40 } }} |
| | | /> |
| | | ); |
| | | } |
| | | return ( |
| | | <Bubble.List |
| | | autoScroll={true} |
| | | items={chats.map((chat, index) => ({ |
| | | key: chat.id + Math.random().toString(), |
| | | typing: { suffix: <>💗</> }, |
| | | header: <Space>{new Date(chat.created).toLocaleString()}</Space>, |
| | | loading: chat.loading, |
| | | loadingRender: () => ( |
| | | <Space> |
| | | <Spin size="small" /> |
| | | AI正在思考中... |
| | | </Space> |
| | | ) : ( |
| | | { |
| | | icon: <UserOutlined />, |
| | | style: { color: "#fff", backgroundColor: "#87d068" }, |
| | | } |
| | | ), |
| | | footer: ( |
| | | <Space> |
| | | {chat.role === "assistant" && ( |
| | | <Button |
| | | color="default" |
| | | variant="text" |
| | | size="small" |
| | | icon={<SyncOutlined />} |
| | | onClick={() => handleRegenerate(index)} |
| | | /> |
| | | )} |
| | | <Button |
| | | color="default" |
| | | variant="text" |
| | | size="small" |
| | | icon={<CopyOutlined />} |
| | | onClick={async () => { |
| | | try { |
| | | await navigator.clipboard.writeText(chat.content); |
| | | message.success("复制成功"); |
| | | } catch (error) { |
| | | message.error("复制失败"); |
| | | } |
| | | }} |
| | | /> |
| | | </Space> |
| | | ), |
| | | role: chat.role === "user" ? "local" : "ai", |
| | | content: ( |
| | | <div className="markdown-body"> |
| | | {chat.role === "assistant" ? ( |
| | | <ReactMarkdown |
| | | remarkPlugins={[remarkGfm, remarkBreaks]} |
| | | rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]} |
| | | components={{ |
| | | code({ node, className, children, ...props }) { |
| | | const match = /language-(\w+)/.exec(className || ""); |
| | | const isInline = !!( |
| | | node?.position?.start && |
| | | node.position.end && |
| | | node.position.start.line === node.position.end.line |
| | | ); |
| | | }))} |
| | | roles={{ ai: { placement: "start" }, local: { placement: "end" } }} |
| | | /> |
| | | ); |
| | | }; |
| | | |
| | | return !isInline ? ( |
| | | <SyntaxHighlighter |
| | | language={match?.[1] || "text"} |
| | | style={codeStyle as { [key: string]: React.CSSProperties }} |
| | | PreTag="div" |
| | | {...props} |
| | | > |
| | | {String(children).replace(/\n$/, "")} |
| | | </SyntaxHighlighter> |
| | | ) : ( |
| | | <code className={className} {...props}> |
| | | {children} |
| | | </code> |
| | | ); |
| | | }, |
| | | }} |
| | | > |
| | | {chat.content} |
| | | </ReactMarkdown> |
| | | ) : ( |
| | | chat.content |
| | | )} |
| | | </div> |
| | | ), |
| | | avatar: |
| | | chat.role === "assistant" ? ( |
| | | <img |
| | | src={botAvatar} |
| | | style={{ width: 32, height: 32, borderRadius: "50%" }} |
| | | alt="AI Avatar" |
| | | /> |
| | | ) : ( |
| | | { |
| | | icon: <UserOutlined />, |
| | | style: { color: "#fff", backgroundColor: "#87d068" }, |
| | | } |
| | | ), |
| | | }))} |
| | | roles={{ ai: { placement: "start" }, local: { placement: "end" } }} |
| | | /> |
| | | ); |
| | | }; |
| | | |
| | | return ( |
| | | <div |
| | |
| | | > |
| | | {name} |
| | | </span> |
| | | {/* 添加删除按钮 */} |
| | | <Button |
| | | type="text" |
| | | size="small" |
| | | icon={<DeleteOutlined />} |
| | | onClick={() => { |
| | | handleRemoveFile(name); |
| | | // setUploadedFiles(uploadedFiles.filter((f) => f !== name)); |
| | | // setFileList(fileList.filter((f) => f.name !== name)); |
| | | }} |
| | | /> |
| | | </div> |