/* eslint-disable no-constant-condition */
|
import React, { useLayoutEffect, useRef, useState, useEffect } from "react";
|
import { Bubble, Sender, Welcome } from "@ant-design/x";
|
import { App, Button, message, Space, Spin, Upload, UploadFile } from "antd";
|
import {
|
CopyOutlined,
|
OpenAIOutlined,
|
SyncOutlined,
|
UserOutlined,
|
UploadOutlined,
|
DeleteOutlined,
|
} from "@ant-design/icons";
|
import ReactMarkdown from "react-markdown";
|
import remarkGfm from "remark-gfm";
|
import remarkBreaks from "remark-breaks";
|
import rehypeRaw from "rehype-raw";
|
import rehypeSanitize from "rehype-sanitize";
|
import logo from "/favicon.png";
|
import "./markdown-styles.css";
|
|
const loadMermaid = () => {
|
if (typeof window !== "undefined" && !(window as any).mermaid) {
|
const script = document.createElement("script");
|
script.src = "https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js";
|
script.onload = () => {
|
(window as any).mermaid.initialize({
|
startOnLoad: true,
|
theme: "default",
|
securityLevel: "loose",
|
});
|
};
|
document.body.appendChild(script);
|
}
|
};
|
|
export type ChatMessage = {
|
id: string;
|
content: string;
|
role: "user" | "assistant" | "aiLoading";
|
created: number;
|
updateAt: number;
|
loading?: boolean;
|
};
|
|
export type AiProChatProps = {
|
loading?: boolean;
|
chats?: ChatMessage[];
|
onChatsChange?: (
|
value: ((prevState: ChatMessage[]) => ChatMessage[]) | ChatMessage[]
|
) => void;
|
style?: React.CSSProperties;
|
appStyle?: React.CSSProperties;
|
helloMessage?: string;
|
botAvatar?: string;
|
request: (messages: ChatMessage[]) => Promise<Response>;
|
clearMessage?: () => void;
|
botId?: string;
|
botTypeId?: number;
|
onFileUploaded?: (fileId: string) => void;
|
onFileRemoved?: (removedFileId?: string) => void;
|
};
|
|
export const AiProChat = ({
|
loading,
|
chats: parentChats,
|
onChatsChange: parentOnChatsChange,
|
style = {},
|
appStyle = {},
|
helloMessage = "欢迎使用AI助手",
|
botAvatar = `${logo}`,
|
request,
|
clearMessage,
|
botId,
|
botTypeId,
|
onFileUploaded,
|
onFileRemoved,
|
}: AiProChatProps) => {
|
const isControlled =
|
parentChats !== undefined && parentOnChatsChange !== undefined;
|
const [internalChats, setInternalChats] = useState<ChatMessage[]>([]);
|
const chats = isControlled ? parentChats : internalChats;
|
const setChats = isControlled ? parentOnChatsChange : setInternalChats;
|
const [content, setContent] = useState("");
|
const [sendLoading, setSendLoading] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
const [uploadedFiles, setUploadedFiles] = useState<string[]>([]);
|
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const autoScrollEnabled = useRef(true);
|
const isUserScrolledUp = useRef(false);
|
const { message: messageApi } = App.useApp();
|
|
useEffect(() => {
|
loadMermaid();
|
}, []);
|
|
useEffect(() => {
|
if (typeof window !== "undefined" && (window as any).mermaid) {
|
(window as any).mermaid.init(undefined, ".mermaid");
|
}
|
}, [chats]);
|
|
const handleRemoveFile = (name: string) => {
|
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);
|
|
setUploadedFiles(newUploadedFiles);
|
setFileList(newFileList);
|
|
if (fileToRemove.url) {
|
onFileRemoved?.(fileToRemove.url);
|
}
|
};
|
|
const handleUpload = async (file: File) => {
|
if (botTypeId === 2 && uploadedFiles.length >= 1) {
|
messageApi.warning("只能上传一个文件,请删除已上传文件");
|
return false;
|
}
|
if (botTypeId === 3 && uploadedFiles.length >= 5) {
|
messageApi.warning("最多只能上传5个文件");
|
return false;
|
}
|
|
setFileList((prev) => [
|
...prev,
|
{
|
uid: file.name,
|
name: file.name,
|
status: "uploading",
|
},
|
]);
|
|
const formData = new FormData();
|
const tokenKey = `${import.meta.env.VITE_APP_TOKEN_KEY}`;
|
formData.append("file", file);
|
formData.append("botId", botId || "");
|
try {
|
const res = await fetch("/api/v1/aiBot/files/upload", {
|
method: "POST",
|
headers: {
|
[tokenKey]: localStorage.getItem("authKey") || "",
|
Accept: "application/json",
|
},
|
body: formData,
|
});
|
if (!res.ok) throw new Error(`上传失败: ${res.statusText}`);
|
const result = await res.json();
|
const fileData = JSON.parse(result.data.trim());
|
|
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} 上传成功`);
|
onFileUploaded?.(fileData.id);
|
|
return fileData;
|
} catch (error) {
|
setFileList((prev) =>
|
prev.map((f) => (f.name === file.name ? { ...f, status: "error" } : f))
|
);
|
messageApi.error((error as Error).message || "上传失败");
|
throw error;
|
}
|
};
|
|
const scrollToBottom = () => {
|
const container = messagesContainerRef.current;
|
if (container && autoScrollEnabled.current) {
|
container.scrollTop = container.scrollHeight;
|
}
|
};
|
|
useLayoutEffect(() => {
|
scrollToBottom();
|
}, []);
|
|
useLayoutEffect(() => {
|
if (autoScrollEnabled.current) {
|
scrollToBottom();
|
}
|
}, [chats]);
|
|
useLayoutEffect(() => {
|
const container = messagesContainerRef.current;
|
if (!container) return;
|
|
const handleScroll = () => {
|
const { scrollTop, scrollHeight, clientHeight } = container;
|
const atBottom = scrollHeight - scrollTop <= clientHeight + 5;
|
|
if (atBottom) {
|
autoScrollEnabled.current = true;
|
isUserScrolledUp.current = false;
|
} else {
|
autoScrollEnabled.current = false;
|
isUserScrolledUp.current = true;
|
}
|
};
|
|
container.addEventListener("scroll", handleScroll);
|
return () => {
|
container.removeEventListener("scroll", handleScroll);
|
};
|
}, []);
|
|
const handleSubmit = async () => {
|
if (!content.trim() && uploadedFiles.length === 0) return;
|
|
setSendLoading(true);
|
setIsStreaming(true);
|
|
let finalContent = content;
|
if (uploadedFiles.length > 0) {
|
finalContent =
|
uploadedFiles.map((name) => `📄 ${name}`).join("\n") +
|
(content ? "\n" + content : "");
|
}
|
|
const userMessage: ChatMessage = {
|
role: "user",
|
id: Date.now().toString(),
|
content: finalContent,
|
created: Date.now(),
|
updateAt: Date.now(),
|
};
|
|
const aiMessage: ChatMessage = {
|
role: "assistant",
|
id: Date.now().toString(),
|
content: "",
|
loading: true,
|
created: Date.now(),
|
updateAt: Date.now(),
|
};
|
|
setChats?.((prev: ChatMessage[]) => [
|
...(prev || []),
|
userMessage,
|
aiMessage,
|
]);
|
setUploadedFiles([]);
|
setContent("");
|
setTimeout(scrollToBottom, 50);
|
|
try {
|
const response = await request([...(chats || []), userMessage]);
|
if (!response?.body) return;
|
const reader = response.body.getReader();
|
const decoder = new TextDecoder();
|
let partial = "";
|
let currentContent = "";
|
while (true) {
|
const { done, value } = await reader.read();
|
if (done) break;
|
partial += decoder.decode(value, { stream: true });
|
|
const id = setInterval(() => {
|
currentContent = partial.slice(0, currentContent.length + 2);
|
setChats?.((prev: ChatMessage[]) => {
|
const newChats = [...(prev || [])];
|
const lastMsg = newChats[newChats.length - 1];
|
if (lastMsg?.role === "assistant") {
|
lastMsg.loading = false;
|
lastMsg.content = currentContent;
|
lastMsg.updateAt = Date.now();
|
}
|
return newChats;
|
});
|
if (autoScrollEnabled.current) {
|
scrollToBottom();
|
}
|
if (currentContent === partial) {
|
clearInterval(id);
|
}
|
}, 50);
|
}
|
} catch (error) {
|
console.error("Error:", error);
|
message.error("请求失败,请重试");
|
} finally {
|
setIsStreaming(false);
|
setSendLoading(false);
|
}
|
};
|
|
const handleRegenerate = async (index: number) => {
|
const prevMessage: ChatMessage = {
|
role: "user",
|
id: Date.now().toString(),
|
content: chats[index - 1].content,
|
loading: false,
|
created: Date.now(),
|
updateAt: Date.now(),
|
};
|
const aiMessage: ChatMessage = {
|
role: "assistant",
|
id: Date.now().toString(),
|
content: "",
|
loading: true,
|
created: Date.now(),
|
updateAt: Date.now(),
|
};
|
const temp = [prevMessage, aiMessage];
|
setChats?.((prev: ChatMessage[]) => [...(prev || []), ...temp]);
|
setTimeout(scrollToBottom, 50);
|
try {
|
const response = await request([...(chats || []), prevMessage]);
|
if (!response?.body) return;
|
const reader = response.body.getReader();
|
const decoder = new TextDecoder();
|
let partial = "";
|
let currentContent = "";
|
while (true) {
|
const { done, value } = await reader.read();
|
if (done) break;
|
partial += decoder.decode(value, { stream: true });
|
|
const id = setInterval(() => {
|
currentContent = partial.slice(0, currentContent.length + 2);
|
setChats?.((prev: ChatMessage[]) => {
|
const newChats = [...(prev || [])];
|
const lastMsg = newChats[newChats.length - 1];
|
if (lastMsg.role === "assistant") {
|
lastMsg.loading = false;
|
lastMsg.content = currentContent;
|
lastMsg.updateAt = Date.now();
|
}
|
return newChats;
|
});
|
if (currentContent === partial) {
|
clearInterval(id);
|
}
|
}, 50);
|
}
|
} catch (error) {
|
console.error("Error:", error);
|
}
|
};
|
|
// 修改 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"
|
/>
|
) : (
|
{
|
icon: <UserOutlined />,
|
style: { color: "#fff", backgroundColor: "#87d068" },
|
}
|
),
|
}))}
|
roles={{ ai: { placement: "start" }, local: { placement: "end" } }}
|
/>
|
);
|
};
|
|
|
return (
|
<div
|
style={{
|
width: "100%",
|
height: "100%",
|
display: "flex",
|
flexDirection: "column",
|
background: "#fff",
|
border: "1px solid #f3f3f3",
|
...appStyle,
|
...style,
|
}}
|
>
|
<div
|
ref={messagesContainerRef}
|
style={{
|
flex: 1,
|
overflowY: "auto",
|
padding: "16px",
|
scrollbarWidth: "none",
|
}}
|
>
|
{loading ? (
|
<Spin tip="加载中..." />
|
) : (
|
<>
|
{renderMessages()}
|
<div ref={messagesEndRef} />
|
</>
|
)}
|
</div>
|
<div style={{ borderTop: "1px solid #eee", padding: "12px" }}>
|
{uploadedFiles.length > 0 && (
|
<div
|
style={{
|
marginBottom: 8,
|
padding: 8,
|
background: "#f5f5f5",
|
borderRadius: 4,
|
display: "flex",
|
flexWrap: "wrap",
|
gap: 8,
|
}}
|
>
|
{uploadedFiles.map((name) => (
|
<div
|
key={name}
|
style={{
|
display: "flex",
|
alignItems: "center",
|
padding: "4px 8px",
|
background: "#fff",
|
borderRadius: 4,
|
border: "1px solid #d9d9d9",
|
}}
|
>
|
<span style={{ marginRight: 4 }}>📄</span>
|
<span
|
style={{
|
maxWidth: 200,
|
overflow: "hidden",
|
textOverflow: "ellipsis",
|
whiteSpace: "nowrap",
|
}}
|
>
|
{name}
|
</span>
|
<Button
|
type="text"
|
size="small"
|
icon={<DeleteOutlined />}
|
onClick={() => {
|
handleRemoveFile(name);
|
}}
|
/>
|
</div>
|
))}
|
</div>
|
)}
|
|
<Sender
|
value={content}
|
onChange={setContent}
|
onSubmit={handleSubmit}
|
loading={sendLoading || isStreaming}
|
actions={(_, info) => (
|
<Space size="small">
|
{(botTypeId === 2 || botTypeId === 3) && (
|
<Upload
|
showUploadList={false}
|
beforeUpload={(file) => {
|
handleUpload(file);
|
return false;
|
}}
|
fileList={fileList}
|
>
|
<Button
|
icon={<UploadOutlined />}
|
loading={fileList.some((f) => f.status === "uploading")}
|
>
|
上传文件
|
</Button>
|
</Upload>
|
)}
|
<info.components.ClearButton
|
disabled={false}
|
title="删除对话记录"
|
style={{ fontSize: 20 }}
|
onClick={(e) => {
|
e.preventDefault();
|
clearMessage?.();
|
}}
|
/>
|
<info.components.SendButton
|
type="primary"
|
icon={<OpenAIOutlined />}
|
loading={sendLoading || isStreaming}
|
/>
|
</Space>
|
)}
|
/>
|
</div>
|
</div>
|
);
|
};
|