AIChat.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595
  1. import React, { useEffect, useMemo, useRef, useState } from "react";
  2. import {
  3. PlusOutlined,
  4. CloseOutlined,
  5. FieldTimeOutlined,
  6. UserOutlined,
  7. SendOutlined,
  8. LoadingOutlined,
  9. EditOutlined,
  10. DeleteOutlined,
  11. } from "@ant-design/icons";
  12. import {
  13. Button,
  14. Tooltip,
  15. Input,
  16. Avatar,
  17. Form,
  18. Dropdown,
  19. MenuProps,
  20. } from "antd";
  21. import { Message } from "ai/react";
  22. import MarkdownViewer from "./MarkdownViewer";
  23. import { uuid } from "@repo/utils";
  24. import { useLocalStorageState } from "ahooks";
  25. import { useModel } from "umi";
  26. // 用户消息
  27. function UserMessage({ message }: { message: Message }) {
  28. return (
  29. <>
  30. <div className="rounded-8px bg-#eff6ff p-8px leading-1.5em">
  31. {message.content ?? ""}
  32. </div>
  33. <Avatar
  34. className="flex-shrink-0"
  35. size={32}
  36. icon={<UserOutlined />}
  37. ></Avatar>
  38. </>
  39. );
  40. }
  41. // 助手消息
  42. function AssistantMessage({ message }: { message: Message }) {
  43. return (
  44. <>
  45. <Avatar
  46. size={32}
  47. className="flex-shrink-0"
  48. icon={
  49. <svg className="icon h-32px w-32px" aria-hidden="true">
  50. <use xlinkHref="#icon-AI1"></use>
  51. </svg>
  52. }
  53. ></Avatar>
  54. <div
  55. className="rounded-8px bg-#fff p-8px leading-1.5em overflow-x-auto"
  56. style={{ overflowX: "auto" }}
  57. >
  58. <MarkdownViewer content={message.content ?? ""} />
  59. </div>
  60. </>
  61. );
  62. }
  63. // 消息信息
  64. function MessageInfo({ message }: { message: Message }) {
  65. return (
  66. <div
  67. key={message.id}
  68. className={`flex items-start space-x-2 mb-4 ${message.role === "user" ? "justify-end" : "justify-start"}`}
  69. >
  70. {message.role === "assistant" && (
  71. <AssistantMessage message={message}></AssistantMessage>
  72. )}
  73. {message.role === "user" && <UserMessage message={message}></UserMessage>}
  74. </div>
  75. );
  76. }
  77. // 消息列表
  78. function MessageList({ messages }: { messages: Message[] }) {
  79. return messages.map((message) => (
  80. <MessageInfo key={message.id} message={message}></MessageInfo>
  81. ));
  82. }
  83. interface ChatHistoryItem {
  84. id: string;
  85. messages: Message[];
  86. createdAt: number;
  87. updatedAt: number;
  88. title: string;
  89. }
  90. interface DateGroups {
  91. today: ChatHistoryItem[];
  92. yesterday: ChatHistoryItem[];
  93. last7Days: ChatHistoryItem[];
  94. last30Days: ChatHistoryItem[];
  95. older: ChatHistoryItem[];
  96. }
  97. // 对历史记录进行分组
  98. function groupDataByDate(data: ChatHistoryItem[]): DateGroups {
  99. const normalizeDate = (date: Date | string): Date => {
  100. const d = new Date(date);
  101. d.setHours(0, 0, 0, 0);
  102. return d;
  103. };
  104. const now = normalizeDate(new Date()); // 当前日期归一化
  105. const groups: DateGroups = {
  106. today: [],
  107. yesterday: [],
  108. last7Days: [],
  109. last30Days: [],
  110. older: [],
  111. };
  112. data.forEach((item: ChatHistoryItem) => {
  113. const itemDate = normalizeDate(new Date(item.updatedAt));
  114. const diffTime = now.getTime() - itemDate.getTime();
  115. const diffDays = Math.floor(diffTime / (1000 * 3600 * 24));
  116. if (diffDays === 0) {
  117. groups.today.push(item);
  118. } else if (diffDays === 1) {
  119. groups.yesterday.push(item);
  120. } else if (diffDays <= 6) {
  121. groups.last7Days.push(item);
  122. } else if (diffDays <= 29) {
  123. groups.last30Days.push(item);
  124. } else {
  125. groups.older.push(item);
  126. }
  127. });
  128. return groups;
  129. }
  130. export default function AIChat(props: { onClose?: () => void }) {
  131. const [focused, setFocused] = useState(false);
  132. const [chatStarted, setChatStarted] = useState(false);
  133. const [scrollHeight, setScrollHeight] = useState(0);
  134. const scrollAreaRef = useRef<HTMLDivElement>(null);
  135. const observer = useRef<ResizeObserver | null>(null);
  136. const [messages, setMessages] = useState<Message[]>([]);
  137. const [inputVal, setInputVal] = useState("");
  138. // 对话历史
  139. const [history, setHistory] = useLocalStorageState<ChatHistoryItem[]>(
  140. "chat-history",
  141. { defaultValue: [] }
  142. );
  143. const { loading, agent, cancel } = useModel("aiModel");
  144. const [chatId, setChatId] = React.useState(uuid());
  145. useEffect(() => {
  146. // 判断messages是否存在消息
  147. if (!messages.length) {
  148. return;
  149. }
  150. // 再判断历史记录是否存在该聊天,存在则更新记录
  151. if (history?.find((item) => item.id === chatId)) {
  152. setHistory(
  153. history?.map((item) => {
  154. if (item.id === chatId) {
  155. return {
  156. ...item,
  157. messages,
  158. updatedAt: Date.now(),
  159. };
  160. }
  161. return item;
  162. })
  163. );
  164. } else {
  165. setHistory((history) => {
  166. const title =
  167. messages.find((message) => message.role === "user")?.content ||
  168. "新对话";
  169. const newData = {
  170. id: chatId,
  171. messages,
  172. createdAt: Date.now(),
  173. updatedAt: Date.now(),
  174. title,
  175. };
  176. return history ? [newData, ...history] : [newData];
  177. });
  178. }
  179. }, [messages, chatId]);
  180. // 发起请求
  181. const onRequest = () => {
  182. agent.request(
  183. {
  184. // 应用名称
  185. app_name: "app1",
  186. // 会话内容
  187. chat_query: inputVal,
  188. // 会话名称 第一次
  189. chat_name: "新会话",
  190. // 会话id 后续会话带入
  191. // conversation_id: ;
  192. },
  193. {
  194. onSuccess: (msg) => {
  195. console.log("success", msg);
  196. setMessages((messages) => {
  197. const arr = [...messages];
  198. return arr;
  199. });
  200. },
  201. onError: (error) => {
  202. console.log("err:", error);
  203. },
  204. onUpdate: (msg) => {
  205. console.log("update", msg);
  206. setMessages((messages) => {
  207. const arr = [...messages];
  208. arr[messages.length - 1].content += msg.answer;
  209. arr[messages.length - 1].id = msg.message_id;
  210. setChatId(msg.conversation_id);
  211. return arr;
  212. });
  213. },
  214. }
  215. );
  216. setInputVal("");
  217. };
  218. // 处理提交
  219. const onSubmit = () => {
  220. if (inputVal.trim()) {
  221. if (!chatStarted) setChatStarted(true);
  222. }
  223. setMessages((arr) => {
  224. const index = arr.length;
  225. return [
  226. ...arr,
  227. { id: index + "", content: inputVal, role: "user" },
  228. {
  229. id: index + 1 + "",
  230. content: "",
  231. role: "assistant",
  232. },
  233. ];
  234. });
  235. onRequest();
  236. };
  237. // 开启新的对话
  238. const onNewChat = () => {
  239. setChatId(uuid());
  240. setChatStarted(false);
  241. };
  242. const stop = () => {
  243. cancel();
  244. };
  245. React.useEffect(() => {
  246. return () => {
  247. // 取消所有进行中的请求
  248. const controller = new AbortController();
  249. controller.abort();
  250. };
  251. }, []);
  252. React.useEffect(() => {
  253. const scrollElement = scrollAreaRef.current;
  254. if (scrollElement) {
  255. observer.current = new ResizeObserver((entries) => {
  256. for (let entry of entries) {
  257. if (entry.target === scrollElement) {
  258. setScrollHeight(entry.target.scrollHeight);
  259. entry.target.scrollTop = entry.target.scrollHeight;
  260. }
  261. }
  262. });
  263. observer.current.observe(scrollElement);
  264. return () => {
  265. observer.current?.disconnect();
  266. };
  267. }
  268. }, [messages]);
  269. // 添加一个 useEffect 来监听 chatId 的变化
  270. useEffect(() => {
  271. // 当 chatId 变化时,从历史记录中找到对应的消息
  272. const currentChat = history?.find((item) => item.id === chatId);
  273. if (currentChat) {
  274. observer.current?.disconnect();
  275. // 清空可滚动高度
  276. setScrollHeight(0);
  277. setTimeout(() => {
  278. setMessages(currentChat.messages);
  279. setChatStarted(true);
  280. }, 100);
  281. }
  282. }, [chatId]);
  283. const [hoverId, setHoverId] = useState<string | null>(null);
  284. const items = useMemo(() => {
  285. const hasCurrentChat = history?.find((item) => item.id === chatId);
  286. const groups = groupDataByDate(
  287. hasCurrentChat
  288. ? history || []
  289. : [
  290. {
  291. id: chatId,
  292. messages,
  293. createdAt: Date.now(),
  294. updatedAt: Date.now(),
  295. title:
  296. messages.find((message) => message.role === "user")?.content ||
  297. "新对话",
  298. },
  299. ...(history || []),
  300. ]
  301. );
  302. // 获取items
  303. const getItems = (list: ChatHistoryItem[]) => {
  304. return (list || []).map((item) => {
  305. return {
  306. key: item.id,
  307. label: (
  308. <div className="w-180px relative">
  309. <div className="w-full flex">
  310. <span
  311. className="truncate"
  312. style={{
  313. overflow: "hidden",
  314. whiteSpace: "nowrap",
  315. textOverflow: "ellipsis",
  316. }}
  317. >
  318. <Tooltip title={item.title}>{item.title}</Tooltip>
  319. </span>
  320. {item.id === chatId ? (
  321. <span className="text-12px color-#999 flex-shrink-0">
  322. (当前)
  323. </span>
  324. ) : null}
  325. </div>
  326. {/* TODO: 添加编辑删除按钮 */}
  327. {hoverId === item.id && (
  328. <div className="h-full w-50px text-right absolute right-0 top-0 bg-#fff">
  329. <EditOutlined
  330. onClick={(e) => {
  331. e.stopPropagation();
  332. }}
  333. />
  334. <DeleteOutlined />
  335. </div>
  336. )}
  337. </div>
  338. ),
  339. onClick: () => {
  340. if (item.id === chatId) return;
  341. setChatId(item.id);
  342. },
  343. onMouseOver: () => {
  344. setHoverId(item.id);
  345. },
  346. };
  347. });
  348. };
  349. const today = groups.today.length
  350. ? [
  351. {
  352. key: "today",
  353. label: "今天",
  354. disabled: true,
  355. },
  356. {
  357. key: "today-divider",
  358. type: "divider",
  359. },
  360. ...getItems(groups.today),
  361. ]
  362. : [];
  363. const yesterday = groups.yesterday.length
  364. ? [
  365. {
  366. key: "yesterday",
  367. label: "昨天",
  368. disabled: true,
  369. },
  370. {
  371. key: "yesterday-divider",
  372. type: "divider",
  373. },
  374. ...getItems(groups.yesterday),
  375. ]
  376. : [];
  377. const last7Days = groups.last7Days.length
  378. ? [
  379. {
  380. key: "last7Days",
  381. label: "最近7天",
  382. disabled: true,
  383. },
  384. {
  385. key: "last7Days-divider",
  386. type: "divider",
  387. },
  388. ...getItems(groups.last7Days),
  389. ]
  390. : [];
  391. const last30Days = groups.last30Days.length
  392. ? [
  393. {
  394. key: "last30Days",
  395. label: "最近30天",
  396. disabled: true,
  397. },
  398. {
  399. key: "last30Days-divider",
  400. type: "divider",
  401. },
  402. ...getItems(groups.last30Days),
  403. ]
  404. : [];
  405. const older = groups.older.length
  406. ? [
  407. {
  408. key: "older",
  409. label: "更早",
  410. disabled: true,
  411. },
  412. {
  413. key: "older-divider",
  414. type: "divider",
  415. },
  416. ...getItems(groups.older),
  417. ]
  418. : [];
  419. return [
  420. ...today,
  421. ...yesterday,
  422. ...last7Days,
  423. ...last30Days,
  424. ...older,
  425. ] as MenuProps["items"];
  426. }, [messages, chatId]);
  427. const handleInputChange = (str: string) => {
  428. setInputVal(str);
  429. };
  430. return (
  431. <div className="flex-1 h-full flex flex-col">
  432. <div className="chat-head w-full h-40px px-10px color-#333 flex items-center justify-between">
  433. <i className="iconfont icon-duihua"></i>
  434. <span>
  435. <Tooltip title="新建会话">
  436. <Button
  437. type="text"
  438. size="small"
  439. icon={<PlusOutlined />}
  440. onClick={onNewChat}
  441. ></Button>
  442. </Tooltip>
  443. <Dropdown
  444. menu={{ items }}
  445. trigger={["click"]}
  446. placement="bottomLeft"
  447. arrow
  448. >
  449. <Tooltip title="历史记录">
  450. <Button
  451. type="text"
  452. size="small"
  453. icon={<FieldTimeOutlined />}
  454. ></Button>
  455. </Tooltip>
  456. </Dropdown>
  457. <Button
  458. type="text"
  459. size="small"
  460. icon={<CloseOutlined />}
  461. onClick={() => props.onClose?.()}
  462. ></Button>
  463. </span>
  464. </div>
  465. <div
  466. className="chat-content flex-1 bg-#f5f5f5 px-10px overflow-y-auto mt-12px"
  467. ref={scrollAreaRef}
  468. >
  469. <div style={{ minHeight: `${scrollHeight}px` }}>
  470. {!chatStarted && (
  471. <>
  472. <div className="text-center pt-200px">
  473. <svg className="icon h-40px! w-40px!" aria-hidden="true">
  474. <use xlinkHref="#icon-AI1"></use>
  475. </svg>
  476. </div>
  477. <h2 className="text-center">询问AI助手</h2>
  478. <p className="text-center">我是AI助手,有什么可以帮您的吗?</p>
  479. </>
  480. )}
  481. {chatStarted && (
  482. <div className="overflow-y-auto h-full">
  483. <MessageList messages={messages}></MessageList>
  484. <div className="flex justify-center items-center h-40px">
  485. {loading && (
  486. <Button
  487. type="primary"
  488. icon={<LoadingOutlined />}
  489. loading={loading}
  490. >
  491. 思考中...
  492. </Button>
  493. )}
  494. </div>
  495. {/* {error && (
  496. <div>
  497. <div className="text-center">请求失败:{error.message}</div>
  498. <div className="flex justify-center items-center h-40px">
  499. <Button type="primary" onClick={() => reload()}>
  500. 重试
  501. </Button>
  502. </div>
  503. </div>
  504. )} */}
  505. </div>
  506. )}
  507. </div>
  508. </div>
  509. <div
  510. style={{
  511. borderColor: focused ? "#1890ff" : "#ddd",
  512. }}
  513. className="chat-foot bg-#f3f4f6 rounded-10px border border-solid border-1px m-10px"
  514. >
  515. <Form onFinish={onSubmit}>
  516. <Input.TextArea
  517. rows={3}
  518. autoSize={{ maxRows: 3, minRows: 3 }}
  519. placeholder="输入询问内容..."
  520. variant="borderless"
  521. onFocus={() => setFocused(true)}
  522. onBlur={() => setFocused(false)}
  523. value={inputVal}
  524. onChange={(e) => handleInputChange(e.target.value)}
  525. disabled={loading}
  526. onPressEnter={onSubmit}
  527. />
  528. <div className="float-right p-10px">
  529. {loading ? (
  530. <Tooltip title="停止生成">
  531. <Button
  532. type="primary"
  533. shape="circle"
  534. icon={<i className="iconfont icon-stopcircle" />}
  535. onClick={stop}
  536. ></Button>
  537. </Tooltip>
  538. ) : (
  539. <Button
  540. type="primary"
  541. icon={<SendOutlined />}
  542. disabled={!inputVal.trim()}
  543. htmlType="submit"
  544. >
  545. 发送
  546. </Button>
  547. )}
  548. </div>
  549. </Form>
  550. </div>
  551. </div>
  552. );
  553. }