|
@@ -0,0 +1,433 @@
|
|
|
+import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
|
+import {
|
|
|
+ CloseOutlined,
|
|
|
+ FieldTimeOutlined,
|
|
|
+ SendOutlined,
|
|
|
+ LoadingOutlined,
|
|
|
+ EditOutlined,
|
|
|
+ DeleteOutlined,
|
|
|
+ CaretDownOutlined,
|
|
|
+} from "@ant-design/icons";
|
|
|
+import { Button, Tooltip, Input, Form, Dropdown, MenuProps } from "antd";
|
|
|
+import { useChat, Message } from "ai/react";
|
|
|
+import { uuid } from "@repo/utils";
|
|
|
+import { useLocalStorageState } from "ahooks";
|
|
|
+
|
|
|
+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());
|
|
|
+
|
|
|
+ const {
|
|
|
+ messages,
|
|
|
+ input,
|
|
|
+ handleInputChange,
|
|
|
+ handleSubmit,
|
|
|
+ isLoading,
|
|
|
+ stop,
|
|
|
+ error,
|
|
|
+ reload,
|
|
|
+ setMessages,
|
|
|
+ } = useChat({
|
|
|
+ api: "http://localhost:3000/ai/chat",
|
|
|
+ keepLastMessageOnError: true,
|
|
|
+ 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()) {
|
|
|
+ handleSubmit();
|
|
|
+ if (!chatStarted) setChatStarted(true);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ React.useEffect(() => {
|
|
|
+ return () => {
|
|
|
+ // 取消所有进行中的请求
|
|
|
+ const controller = new AbortController();
|
|
|
+ controller.abort();
|
|
|
+ };
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const [hoverId, setHoverId] = useState<string | null>(null);
|
|
|
+
|
|
|
+ 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>
|
|
|
+ {/* TODO: 添加编辑删除按钮 */}
|
|
|
+ {hoverId === item.id && (
|
|
|
+ <div className="h-full w-50px text-right absolute right-0 top-0 bg-#fff">
|
|
|
+ <EditOutlined
|
|
|
+ onClick={(e) => {
|
|
|
+ e.stopPropagation();
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ <DeleteOutlined />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ ),
|
|
|
+ onClick: () => {
|
|
|
+ if (item.id === chatId) return;
|
|
|
+
|
|
|
+ setChatId(item.id);
|
|
|
+ },
|
|
|
+ onMouseOver: () => {
|
|
|
+ setHoverId(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]);
|
|
|
+
|
|
|
+ const handleList = [
|
|
|
+ {
|
|
|
+ key: "1",
|
|
|
+ label: "风格美化",
|
|
|
+ icon: "icon-yijianmeihua",
|
|
|
+ color: "#a171f2",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: "2",
|
|
|
+ label: "语法修复",
|
|
|
+ icon: "icon-tubiao_yufajiucuo",
|
|
|
+ color: "#00c4ad",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: "3",
|
|
|
+ label: "翻译为英文",
|
|
|
+ icon: "icon-fanyiweiyingwen",
|
|
|
+ color: "#8c4ff0",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: "4",
|
|
|
+ label: "翻译为中文",
|
|
|
+ icon: "icon-fanyiweizhongwen",
|
|
|
+ color: "#3d72fb",
|
|
|
+ },
|
|
|
+ ];
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="flex-1 h-full">
|
|
|
+ <div className="chat-head w-full h-40px px-10px color-#333 flex items-center justify-between">
|
|
|
+ <i className="iconfont icon-AIchuangzuo"></i>
|
|
|
+ <span>
|
|
|
+ <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"
|
|
|
+ icon={<CloseOutlined />}
|
|
|
+ onClick={() => props.onClose?.()}
|
|
|
+ ></Button>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="text-14px pl-12px text-#333">绘制图形</div>
|
|
|
+ <div
|
|
|
+ className="chat-content bg-#f5f5f5 px-10px overflow-y-auto mt-12px"
|
|
|
+ ref={scrollAreaRef}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ borderColor: focused ? "#1890ff" : "#ddd",
|
|
|
+ }}
|
|
|
+ className="chat-foot bg-#fff rounded-10px border border-solid border-1px shadow-sm"
|
|
|
+ >
|
|
|
+ <Dropdown menu={{ items: [] }} placement="bottomLeft">
|
|
|
+ <div className="text-12px pl-10px pt-10px">
|
|
|
+ 帮我绘制-流程图
|
|
|
+ <CaretDownOutlined />
|
|
|
+ </div>
|
|
|
+ </Dropdown>
|
|
|
+ <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="text-right p-10px">
|
|
|
+ {isLoading ? (
|
|
|
+ <Tooltip title="停止生成">
|
|
|
+ <Button
|
|
|
+ type="primary"
|
|
|
+ shape="circle"
|
|
|
+ icon={<i className="iconfont icon-stopcircle" />}
|
|
|
+ onClick={stop}
|
|
|
+ ></Button>
|
|
|
+ </Tooltip>
|
|
|
+ ) : (
|
|
|
+ <Button
|
|
|
+ type="text"
|
|
|
+ icon={<SendOutlined />}
|
|
|
+ disabled={!input.trim()}
|
|
|
+ htmlType="submit"
|
|
|
+ ></Button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </Form>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="text-14px pl-12px text-#333 mt-32px">图形处理</div>
|
|
|
+ <div className="flex flex-wrap gap-10px p-10px">
|
|
|
+ {handleList.map((item) => (
|
|
|
+ <div
|
|
|
+ key={item.key}
|
|
|
+ className="flex-[40%] h-50px bg-#fff rounded-10px shadow-sm flex items-center pl-10px text-12px cursor-pointer"
|
|
|
+ style={{}}
|
|
|
+ >
|
|
|
+ <i
|
|
|
+ className={`iconfont ${item.icon} text-16px`}
|
|
|
+ style={{ color: item.color }}
|
|
|
+ ></i>
|
|
|
+ <span className="ml-10px">{item.label}</span>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|