|
@@ -1,4 +1,4 @@
|
|
|
-import React from "react";
|
|
|
+import React, { useEffect, useMemo, useRef } from "react";
|
|
|
import {
|
|
|
PlusOutlined,
|
|
|
CloseOutlined,
|
|
@@ -6,12 +6,24 @@ import {
|
|
|
UserOutlined,
|
|
|
SendOutlined,
|
|
|
LoadingOutlined,
|
|
|
+ EditOutlined,
|
|
|
+ DeleteOutlined,
|
|
|
} from "@ant-design/icons";
|
|
|
-import { Button, Tooltip, Input, Avatar, Form } from "antd";
|
|
|
+import {
|
|
|
+ Button,
|
|
|
+ Tooltip,
|
|
|
+ Input,
|
|
|
+ Avatar,
|
|
|
+ Form,
|
|
|
+ Dropdown,
|
|
|
+ MenuProps,
|
|
|
+} from "antd";
|
|
|
import { useChat, Message } from "ai/react";
|
|
|
import MarkdownViewer from "./MarkdownViewer";
|
|
|
import { uuid } from "@repo/utils";
|
|
|
+import { useLocalStorageState } from "ahooks";
|
|
|
|
|
|
+// 用户消息
|
|
|
function UserMessage({ message }: { message: Message }) {
|
|
|
return (
|
|
|
<>
|
|
@@ -24,6 +36,7 @@ function UserMessage({ message }: { message: Message }) {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+// 助手消息
|
|
|
function AssistantMessage({ message }: { message: Message }) {
|
|
|
return (
|
|
|
<>
|
|
@@ -47,6 +60,7 @@ function AssistantMessage({ message }: { message: Message }) {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+// 消息信息
|
|
|
function MessageInfo({ message }: { message: Message }) {
|
|
|
return (
|
|
|
<div
|
|
@@ -62,17 +76,79 @@ function MessageInfo({ message }: { message: Message }) {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+// 消息列表
|
|
|
function MessageList({ messages }: { messages: Message[] }) {
|
|
|
return messages.map((message) => (
|
|
|
<MessageInfo key={message.id} message={message}></MessageInfo>
|
|
|
));
|
|
|
}
|
|
|
|
|
|
+interface ChatHistoryItem {
|
|
|
+ id: string;
|
|
|
+ messages: Message[];
|
|
|
+ createdAt: number;
|
|
|
+ updatedAt: number;
|
|
|
+ title: string;
|
|
|
+}
|
|
|
+
|
|
|
+interface DateGroups {
|
|
|
+ today: ChatHistoryItem[];
|
|
|
+ yesterday: ChatHistoryItem[];
|
|
|
+ last7Days: ChatHistoryItem[];
|
|
|
+ last30Days: ChatHistoryItem[];
|
|
|
+ older: ChatHistoryItem[];
|
|
|
+}
|
|
|
+
|
|
|
+// 对历史记录进行分组
|
|
|
+function groupDataByDate(data: ChatHistoryItem[]): DateGroups {
|
|
|
+ const normalizeDate = (date: Date | string): Date => {
|
|
|
+ const d = new Date(date);
|
|
|
+ d.setHours(0, 0, 0, 0);
|
|
|
+ return d;
|
|
|
+ };
|
|
|
+
|
|
|
+ const now = normalizeDate(new Date()); // 当前日期归一化
|
|
|
+
|
|
|
+ const groups: DateGroups = {
|
|
|
+ today: [],
|
|
|
+ yesterday: [],
|
|
|
+ last7Days: [],
|
|
|
+ last30Days: [],
|
|
|
+ older: [],
|
|
|
+ };
|
|
|
+
|
|
|
+ data.forEach((item: ChatHistoryItem) => {
|
|
|
+ const itemDate = normalizeDate(new Date(item.updatedAt));
|
|
|
+ const diffTime = now.getTime() - itemDate.getTime();
|
|
|
+ const diffDays = Math.floor(diffTime / (1000 * 3600 * 24));
|
|
|
+
|
|
|
+ if (diffDays === 0) {
|
|
|
+ groups.today.push(item);
|
|
|
+ } else if (diffDays === 1) {
|
|
|
+ groups.yesterday.push(item);
|
|
|
+ } else if (diffDays <= 6) {
|
|
|
+ groups.last7Days.push(item);
|
|
|
+ } else if (diffDays <= 29) {
|
|
|
+ groups.last30Days.push(item);
|
|
|
+ } else {
|
|
|
+ groups.older.push(item);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ return groups;
|
|
|
+}
|
|
|
+
|
|
|
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 observer = useRef<ResizeObserver | null>(null);
|
|
|
+ // 对话历史
|
|
|
+ const [history, setHistory] = useLocalStorageState<ChatHistoryItem[]>(
|
|
|
+ "chat-history",
|
|
|
+ { defaultValue: [] }
|
|
|
+ );
|
|
|
|
|
|
const [chatId, setChatId] = React.useState(uuid());
|
|
|
|
|
@@ -92,6 +168,42 @@ export default function Chat(props: { onClose?: () => void }) {
|
|
|
id: chatId,
|
|
|
});
|
|
|
|
|
|
+ useEffect(() => {
|
|
|
+ // 判断messages是否存在消息
|
|
|
+ if (!messages.length) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // 再判断历史记录是否存在该聊天,存在则更新记录
|
|
|
+ if (history?.find((item) => item.id === chatId)) {
|
|
|
+ setHistory(
|
|
|
+ history?.map((item) => {
|
|
|
+ if (item.id === chatId) {
|
|
|
+ return {
|
|
|
+ ...item,
|
|
|
+ messages,
|
|
|
+ updatedAt: Date.now(),
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return item;
|
|
|
+ })
|
|
|
+ );
|
|
|
+ } else {
|
|
|
+ setHistory((history) => {
|
|
|
+ const title =
|
|
|
+ messages.find((message) => message.role === "user")?.content ||
|
|
|
+ "新对话";
|
|
|
+ const newData = {
|
|
|
+ id: chatId,
|
|
|
+ messages,
|
|
|
+ createdAt: Date.now(),
|
|
|
+ updatedAt: Date.now(),
|
|
|
+ title,
|
|
|
+ };
|
|
|
+ return history ? [newData, ...history] : [newData];
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }, [messages, chatId]);
|
|
|
+
|
|
|
// 处理提交
|
|
|
const onSubmit = () => {
|
|
|
if (input.trim()) {
|
|
@@ -103,7 +215,6 @@ export default function Chat(props: { onClose?: () => void }) {
|
|
|
// 开启新的对话
|
|
|
const onNewChat = () => {
|
|
|
setChatId(uuid());
|
|
|
- setMessages([]);
|
|
|
setChatStarted(false);
|
|
|
};
|
|
|
|
|
@@ -118,7 +229,7 @@ export default function Chat(props: { onClose?: () => void }) {
|
|
|
React.useEffect(() => {
|
|
|
const scrollElement = scrollAreaRef.current;
|
|
|
if (scrollElement) {
|
|
|
- const observer = new ResizeObserver((entries) => {
|
|
|
+ observer.current = new ResizeObserver((entries) => {
|
|
|
for (let entry of entries) {
|
|
|
if (entry.target === scrollElement) {
|
|
|
setScrollHeight(entry.target.scrollHeight);
|
|
@@ -127,14 +238,173 @@ export default function Chat(props: { onClose?: () => void }) {
|
|
|
}
|
|
|
});
|
|
|
|
|
|
- observer.observe(scrollElement);
|
|
|
+ observer.current.observe(scrollElement);
|
|
|
|
|
|
return () => {
|
|
|
- observer.disconnect();
|
|
|
+ observer.current?.disconnect();
|
|
|
};
|
|
|
}
|
|
|
}, [messages]);
|
|
|
|
|
|
+ // 添加一个 useEffect 来监听 chatId 的变化
|
|
|
+ useEffect(() => {
|
|
|
+ // 当 chatId 变化时,从历史记录中找到对应的消息
|
|
|
+ const currentChat = history?.find((item) => item.id === chatId);
|
|
|
+ if (currentChat) {
|
|
|
+ observer.current?.disconnect();
|
|
|
+ // 清空可滚动高度
|
|
|
+ setScrollHeight(0);
|
|
|
+ setTimeout(() => {
|
|
|
+ setMessages(currentChat.messages);
|
|
|
+ setChatStarted(true);
|
|
|
+ }, 100);
|
|
|
+ }
|
|
|
+ }, [chatId]);
|
|
|
+
|
|
|
+ const items = useMemo(() => {
|
|
|
+ const hasCurrentChat = history?.find((item) => item.id === chatId);
|
|
|
+ const groups = groupDataByDate(
|
|
|
+ hasCurrentChat
|
|
|
+ ? history || []
|
|
|
+ : [
|
|
|
+ {
|
|
|
+ id: chatId,
|
|
|
+ messages,
|
|
|
+ createdAt: Date.now(),
|
|
|
+ updatedAt: Date.now(),
|
|
|
+ title:
|
|
|
+ messages.find((message) => message.role === "user")?.content ||
|
|
|
+ "新对话",
|
|
|
+ },
|
|
|
+ ...(history || []),
|
|
|
+ ]
|
|
|
+ );
|
|
|
+
|
|
|
+ // 获取items
|
|
|
+ const getItems = (list: ChatHistoryItem[]) => {
|
|
|
+ return (list || []).map((item) => {
|
|
|
+ return {
|
|
|
+ key: item.id,
|
|
|
+ label: (
|
|
|
+ <div className="w-180px relative">
|
|
|
+ <div className="w-full flex">
|
|
|
+ <span
|
|
|
+ className="truncate"
|
|
|
+ style={{
|
|
|
+ overflow: "hidden",
|
|
|
+ whiteSpace: "nowrap",
|
|
|
+ textOverflow: "ellipsis",
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Tooltip title={item.title}>{item.title}</Tooltip>
|
|
|
+ </span>
|
|
|
+ {item.id === chatId ? (
|
|
|
+ <span className="text-12px color-#999 flex-shrink-0">
|
|
|
+ (当前)
|
|
|
+ </span>
|
|
|
+ ) : null}
|
|
|
+ </div>
|
|
|
+ <div className="h-full w-50px text-right absolute right-0 top-0 bg-#fff opacity-0 hover:opacity-100">
|
|
|
+ <EditOutlined onClick={(e) => {
|
|
|
+ e.stopPropagation();
|
|
|
+ }}/>
|
|
|
+ <DeleteOutlined/>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ),
|
|
|
+ onClick: () => {
|
|
|
+ if (item.id === chatId) return;
|
|
|
+
|
|
|
+ setChatId(item.id);
|
|
|
+ },
|
|
|
+ };
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ const today = groups.today.length
|
|
|
+ ? [
|
|
|
+ {
|
|
|
+ key: "today",
|
|
|
+ label: "今天",
|
|
|
+ disabled: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: "today-divider",
|
|
|
+ type: "divider",
|
|
|
+ },
|
|
|
+ ...getItems(groups.today),
|
|
|
+ ]
|
|
|
+ : [];
|
|
|
+
|
|
|
+ const yesterday = groups.yesterday.length
|
|
|
+ ? [
|
|
|
+ {
|
|
|
+ key: "yesterday",
|
|
|
+ label: "昨天",
|
|
|
+ disabled: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: "yesterday-divider",
|
|
|
+ type: "divider",
|
|
|
+ },
|
|
|
+ ...getItems(groups.yesterday),
|
|
|
+ ]
|
|
|
+ : [];
|
|
|
+
|
|
|
+ const last7Days = groups.last7Days.length
|
|
|
+ ? [
|
|
|
+ {
|
|
|
+ key: "last7Days",
|
|
|
+ label: "最近7天",
|
|
|
+ disabled: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: "last7Days-divider",
|
|
|
+ type: "divider",
|
|
|
+ },
|
|
|
+ ...getItems(groups.last7Days),
|
|
|
+ ]
|
|
|
+ : [];
|
|
|
+
|
|
|
+ const last30Days = groups.last30Days.length
|
|
|
+ ? [
|
|
|
+ {
|
|
|
+ key: "last30Days",
|
|
|
+ label: "最近30天",
|
|
|
+ disabled: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: "last30Days-divider",
|
|
|
+ type: "divider",
|
|
|
+ },
|
|
|
+ ...getItems(groups.last30Days),
|
|
|
+ ]
|
|
|
+ : [];
|
|
|
+
|
|
|
+ const older = groups.older.length
|
|
|
+ ? [
|
|
|
+ {
|
|
|
+ key: "older",
|
|
|
+ label: "更早",
|
|
|
+ disabled: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: "older-divider",
|
|
|
+ type: "divider",
|
|
|
+ },
|
|
|
+ ...getItems(groups.older),
|
|
|
+ ]
|
|
|
+ : [];
|
|
|
+
|
|
|
+ return [
|
|
|
+ ...today,
|
|
|
+ ...yesterday,
|
|
|
+ ...last7Days,
|
|
|
+ ...last30Days,
|
|
|
+ ...older,
|
|
|
+ ] as MenuProps["items"];
|
|
|
+ }, [messages, chatId]);
|
|
|
+
|
|
|
return (
|
|
|
<div className="flex-1 h-full flex flex-col">
|
|
|
<div className="chat-head w-full h-40px px-10px color-#333 flex items-center justify-between">
|
|
@@ -148,13 +418,20 @@ export default function Chat(props: { onClose?: () => void }) {
|
|
|
onClick={onNewChat}
|
|
|
></Button>
|
|
|
</Tooltip>
|
|
|
- <Tooltip title="历史记录">
|
|
|
- <Button
|
|
|
- type="text"
|
|
|
- size="small"
|
|
|
- icon={<FieldTimeOutlined />}
|
|
|
- ></Button>
|
|
|
- </Tooltip>
|
|
|
+ <Dropdown
|
|
|
+ menu={{ items }}
|
|
|
+ trigger={["click"]}
|
|
|
+ placement="bottomLeft"
|
|
|
+ arrow
|
|
|
+ >
|
|
|
+ <Tooltip title="历史记录">
|
|
|
+ <Button
|
|
|
+ type="text"
|
|
|
+ size="small"
|
|
|
+ icon={<FieldTimeOutlined />}
|
|
|
+ ></Button>
|
|
|
+ </Tooltip>
|
|
|
+ </Dropdown>
|
|
|
<Button
|
|
|
type="text"
|
|
|
size="small"
|
|
@@ -196,14 +473,16 @@ export default function Chat(props: { onClose?: () => void }) {
|
|
|
)}
|
|
|
</div>
|
|
|
{error && (
|
|
|
- <div className="flex justify-center items-center h-40px">
|
|
|
- <Button
|
|
|
- type="primary"
|
|
|
- shape="circle"
|
|
|
- onClick={() => reload()}
|
|
|
- >
|
|
|
- 重试
|
|
|
- </Button>
|
|
|
+ <div>
|
|
|
+ <div className="text-center">请求失败:{error.message}</div>
|
|
|
+ <div className="flex justify-center items-center h-40px">
|
|
|
+ <Button
|
|
|
+ type="primary"
|
|
|
+ onClick={() => reload()}
|
|
|
+ >
|
|
|
+ 重试
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|