|
@@ -6,14 +6,134 @@ import {
|
|
|
UserOutlined,
|
|
|
SendOutlined,
|
|
|
LoadingOutlined,
|
|
|
- PauseOutlined,
|
|
|
} from "@ant-design/icons";
|
|
|
-import { Button, Tooltip, Input } from "antd";
|
|
|
-import { useModel } from "umi";
|
|
|
+import { Button, Tooltip, Input, Avatar, Form } from "antd";
|
|
|
+import { useChat, Message } from "ai/react";
|
|
|
+import MarkdownViewer from "./MarkdownViewer";
|
|
|
+import { uuid } from "@repo/utils";
|
|
|
|
|
|
-export default function Chat() {
|
|
|
- const { setActiveAIChat } = useModel("appModel");
|
|
|
+function UserMessage({ message }: { message: Message }) {
|
|
|
+ return (
|
|
|
+ <>
|
|
|
+ <div className="rounded-8px bg-#eff6ff p-8px leading-1.5em">
|
|
|
+ {message.content ?? ""}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Avatar size={32} icon={<UserOutlined />}></Avatar>
|
|
|
+ </>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function AssistantMessage({ message }: { message: Message }) {
|
|
|
+ return (
|
|
|
+ <>
|
|
|
+ <Avatar
|
|
|
+ size={32}
|
|
|
+ className="flex-shrink-0"
|
|
|
+ icon={
|
|
|
+ <svg className="icon h-32px w-32px" aria-hidden="true">
|
|
|
+ <use xlinkHref="#icon-AI1"></use>
|
|
|
+ </svg>
|
|
|
+ }
|
|
|
+ ></Avatar>
|
|
|
+
|
|
|
+ <div
|
|
|
+ className="rounded-8px bg-#fff p-8px leading-1.5em overflow-x-auto"
|
|
|
+ style={{ overflowX: "auto" }}
|
|
|
+ >
|
|
|
+ <MarkdownViewer content={message.content ?? ""} />
|
|
|
+ </div>
|
|
|
+ </>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function MessageInfo({ message }: { message: Message }) {
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ key={message.id}
|
|
|
+ className={`flex items-start space-x-2 mb-4 ${message.role === "user" ? "justify-end" : "justify-start"}`}
|
|
|
+ >
|
|
|
+ {message.role === "assistant" ? (
|
|
|
+ <AssistantMessage message={message}></AssistantMessage>
|
|
|
+ ) : (
|
|
|
+ <UserMessage message={message}></UserMessage>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function MessageList({ messages }: { messages: Message[] }) {
|
|
|
+ return messages.map((message) => (
|
|
|
+ <MessageInfo key={message.id} message={message}></MessageInfo>
|
|
|
+ ));
|
|
|
+}
|
|
|
+
|
|
|
+export default function Chat(props: { onClose?: () => void }) {
|
|
|
const [focused, setFocused] = React.useState(false);
|
|
|
+ const [chatStarted, setChatStarted] = React.useState(false);
|
|
|
+ const [scrollHeight, setScrollHeight] = React.useState(0);
|
|
|
+ const scrollAreaRef = React.useRef<HTMLDivElement>(null);
|
|
|
+
|
|
|
+ const [chatId, setChatId] = React.useState(uuid());
|
|
|
+
|
|
|
+ const {
|
|
|
+ messages,
|
|
|
+ input,
|
|
|
+ handleInputChange,
|
|
|
+ handleSubmit,
|
|
|
+ isLoading,
|
|
|
+ stop,
|
|
|
+ error,
|
|
|
+ reload,
|
|
|
+ setMessages,
|
|
|
+ } = useChat({
|
|
|
+ api: "http://localhost:3000/ai/chat",
|
|
|
+ keepLastMessageOnError: true,
|
|
|
+ id: chatId,
|
|
|
+ });
|
|
|
+
|
|
|
+ // 处理提交
|
|
|
+ const onSubmit = () => {
|
|
|
+ if (input.trim()) {
|
|
|
+ handleSubmit();
|
|
|
+ if (!chatStarted) setChatStarted(true);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 开启新的对话
|
|
|
+ const onNewChat = () => {
|
|
|
+ setChatId(uuid());
|
|
|
+ setMessages([]);
|
|
|
+ setChatStarted(false);
|
|
|
+ };
|
|
|
+
|
|
|
+ React.useEffect(() => {
|
|
|
+ return () => {
|
|
|
+ // 取消所有进行中的请求
|
|
|
+ const controller = new AbortController();
|
|
|
+ controller.abort();
|
|
|
+ };
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ React.useEffect(() => {
|
|
|
+ const scrollElement = scrollAreaRef.current;
|
|
|
+ if (scrollElement) {
|
|
|
+ const observer = new ResizeObserver((entries) => {
|
|
|
+ for (let entry of entries) {
|
|
|
+ if (entry.target === scrollElement) {
|
|
|
+ setScrollHeight(entry.target.scrollHeight);
|
|
|
+ entry.target.scrollTop = entry.target.scrollHeight;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ observer.observe(scrollElement);
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ observer.disconnect();
|
|
|
+ };
|
|
|
+ }
|
|
|
+ }, [messages]);
|
|
|
|
|
|
return (
|
|
|
<div className="flex-1 h-full flex flex-col">
|
|
@@ -21,47 +141,118 @@ export default function Chat() {
|
|
|
<i className="iconfont icon-duihua"></i>
|
|
|
<span>
|
|
|
<Tooltip title="新建会话">
|
|
|
- <Button type="text" size="small" icon={<PlusOutlined />}></Button>
|
|
|
- </Tooltip>
|
|
|
- <Tooltip title="历史记录">
|
|
|
<Button
|
|
|
type="text"
|
|
|
size="small"
|
|
|
- icon={<FieldTimeOutlined />}
|
|
|
+ icon={<PlusOutlined />}
|
|
|
+ onClick={onNewChat}
|
|
|
></Button>
|
|
|
</Tooltip>
|
|
|
- <Tooltip title="关闭">
|
|
|
+ <Tooltip title="历史记录">
|
|
|
<Button
|
|
|
type="text"
|
|
|
size="small"
|
|
|
- icon={<CloseOutlined />}
|
|
|
- onClick={() => setActiveAIChat(false)}
|
|
|
+ icon={<FieldTimeOutlined />}
|
|
|
></Button>
|
|
|
</Tooltip>
|
|
|
+ <Button
|
|
|
+ type="text"
|
|
|
+ size="small"
|
|
|
+ icon={<CloseOutlined />}
|
|
|
+ onClick={() => props.onClose?.()}
|
|
|
+ ></Button>
|
|
|
</span>
|
|
|
</div>
|
|
|
|
|
|
- <div className="chat-content flex-1 bg-#f5f5f5 px-10px">
|
|
|
- <div className="text-center pt-40px"><i className="iconfont icon-robot-2-line text-#333 text-32px"></i></div>
|
|
|
- <h2 className="text-center">询问AI助手</h2>
|
|
|
- <p>您好,我是AI助手,有什么可以帮您的吗?</p>
|
|
|
- </div>
|
|
|
+ <div
|
|
|
+ className="chat-content flex-1 bg-#f5f5f5 px-10px overflow-y-auto mt-12px"
|
|
|
+ ref={scrollAreaRef}
|
|
|
+ >
|
|
|
+ <div style={{ minHeight: `${scrollHeight}px` }}>
|
|
|
+ {!chatStarted && (
|
|
|
+ <>
|
|
|
+ <div className="text-center pt-200px">
|
|
|
+ <svg className="icon h-40px! w-40px!" aria-hidden="true">
|
|
|
+ <use xlinkHref="#icon-AI1"></use>
|
|
|
+ </svg>
|
|
|
+ </div>
|
|
|
+ <h2 className="text-center">询问AI助手</h2>
|
|
|
+ <p className="text-center">我是AI助手,有什么可以帮您的吗?</p>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
|
|
|
- <div style={{
|
|
|
- borderColor: focused ? '#1890ff' : '#ddd'
|
|
|
- }} className="chat-foot bg-#f3f4f6 rounded-10px border border-solid border-1px m-10px">
|
|
|
- <Input.TextArea
|
|
|
- rows={3}
|
|
|
- autoSize={{ maxRows: 3, minRows: 3 }}
|
|
|
- placeholder="输入询问内容..."
|
|
|
- variant="borderless"
|
|
|
- onFocus={() => setFocused(true)}
|
|
|
- onBlur={() => setFocused(false)}
|
|
|
- />
|
|
|
- <div className="float-right p-10px">
|
|
|
- <Button type="primary" icon={<SendOutlined />}>发送</Button>
|
|
|
+ {chatStarted && (
|
|
|
+ <div className="overflow-y-auto h-full">
|
|
|
+ <MessageList messages={messages}></MessageList>
|
|
|
+ <div className="flex justify-center items-center h-40px">
|
|
|
+ {isLoading && (
|
|
|
+ <Button
|
|
|
+ type="primary"
|
|
|
+ icon={<LoadingOutlined />}
|
|
|
+ loading={isLoading}
|
|
|
+ >
|
|
|
+ 思考中...
|
|
|
+ </Button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ {error && (
|
|
|
+ <div className="flex justify-center items-center h-40px">
|
|
|
+ <Button
|
|
|
+ type="primary"
|
|
|
+ shape="circle"
|
|
|
+ onClick={() => reload()}
|
|
|
+ >
|
|
|
+ 重试
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
</div>
|
|
|
+
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ borderColor: focused ? "#1890ff" : "#ddd",
|
|
|
+ }}
|
|
|
+ className="chat-foot bg-#f3f4f6 rounded-10px border border-solid border-1px m-10px"
|
|
|
+ >
|
|
|
+ <Form onFinish={onSubmit}>
|
|
|
+ <Input.TextArea
|
|
|
+ rows={3}
|
|
|
+ autoSize={{ maxRows: 3, minRows: 3 }}
|
|
|
+ placeholder="输入询问内容..."
|
|
|
+ variant="borderless"
|
|
|
+ onFocus={() => setFocused(true)}
|
|
|
+ onBlur={() => setFocused(false)}
|
|
|
+ value={input}
|
|
|
+ onChange={handleInputChange}
|
|
|
+ disabled={isLoading}
|
|
|
+ onPressEnter={onSubmit}
|
|
|
+ />
|
|
|
+ <div className="float-right p-10px">
|
|
|
+ {isLoading ? (
|
|
|
+ <Tooltip title="停止生成">
|
|
|
+ <Button
|
|
|
+ type="primary"
|
|
|
+ shape="circle"
|
|
|
+ icon={<i className="iconfont icon-stopcircle" />}
|
|
|
+ onClick={stop}
|
|
|
+ ></Button>
|
|
|
+ </Tooltip>
|
|
|
+ ) : (
|
|
|
+ <Button
|
|
|
+ type="primary"
|
|
|
+ icon={<SendOutlined />}
|
|
|
+ disabled={!input.trim()}
|
|
|
+ htmlType="submit"
|
|
|
+ >
|
|
|
+ 发送
|
|
|
+ </Button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </Form>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
);
|
|
|
}
|