Assistant.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  1. import {
  2. Bubble,
  3. Conversations,
  4. Prompts,
  5. Sender,
  6. Suggestion,
  7. XProvider,
  8. Welcome,
  9. Attachments,
  10. AttachmentsProps,
  11. } from "@ant-design/x";
  12. import { Conversation } from "@ant-design/x/lib/conversations";
  13. import { useChat } from "@/hooks/useChat";
  14. import {
  15. Card,
  16. Divider,
  17. Flex,
  18. message,
  19. Button,
  20. Space,
  21. Spin,
  22. Typography,
  23. Modal,
  24. Input,
  25. } from "antd";
  26. import { useEffect, useRef, useState } from "react";
  27. import {
  28. BulbOutlined,
  29. SmileOutlined,
  30. UserOutlined,
  31. EditOutlined,
  32. DeleteOutlined,
  33. PlusOutlined,
  34. CloudUploadOutlined,
  35. LinkOutlined,
  36. CopyOutlined,
  37. RedoOutlined,
  38. ReadOutlined,
  39. } from "@ant-design/icons";
  40. import type { GetProp, GetRef } from "antd";
  41. import type { ConversationsProps } from "@ant-design/x";
  42. import type { AgentItem } from "./data";
  43. import MarkdownViewer from "@/components/ai/MarkdownViewer";
  44. import { ChangeSessionName, DeleteSession } from "@/api/ai";
  45. type AssistantProps = {
  46. agent?: AgentItem;
  47. };
  48. // bubbles角色配置
  49. const roles: GetProp<typeof Bubble.List, "roles"> = {
  50. assistant: {
  51. placement: "start",
  52. avatar: {
  53. icon: <i className="iconfont icon-AI1" />,
  54. style: { background: "#fde3cf" },
  55. },
  56. // typing: { step: 5, interval: 20 },
  57. loadingRender: () => (
  58. <Space>
  59. <Spin size="small" />
  60. 思考中...
  61. </Space>
  62. ),
  63. messageRender: (content) => {
  64. return typeof content === "string" ? (
  65. <Typography>
  66. <MarkdownViewer content={content} />
  67. </Typography>
  68. ) : (
  69. content
  70. );
  71. },
  72. header: "易码工坊AI助手",
  73. },
  74. user: {
  75. placement: "end",
  76. avatar: { icon: <UserOutlined />, style: { background: "#87d068" } },
  77. },
  78. };
  79. export default (props: AssistantProps) => {
  80. const [senderVal, setSenderVal] = useState("");
  81. const {
  82. messages,
  83. setMessages,
  84. activeConversation,
  85. changeConversation,
  86. conversationList,
  87. onRequest,
  88. cancel,
  89. loading,
  90. loadingSession,
  91. addConversation,
  92. setConversationList,
  93. } = useChat({
  94. app_name: props.agent?.key || "",
  95. onSuccess: (msg) => {
  96. setMessages((messages) => {
  97. const arr = [...messages];
  98. const query = arr[messages.length - 2].content as string;
  99. arr[messages.length - 1].status = "done";
  100. arr[messages.length - 1].footer = (
  101. <BubbleFooter
  102. content={arr[messages.length - 1].content as string}
  103. query={query}
  104. />
  105. );
  106. return arr;
  107. });
  108. },
  109. onUpdate: (msg) => {
  110. setMessages((messages) => {
  111. const arr = [...messages];
  112. arr[messages.length - 1].content += msg.answer;
  113. arr[messages.length - 1].id = msg.message_id;
  114. arr[messages.length - 1].loading = false;
  115. arr[messages.length - 1].status = "error";
  116. return arr;
  117. });
  118. },
  119. onError: (error) => {
  120. message.error(error.message);
  121. setMessages((messages) => {
  122. const arr = [...messages];
  123. arr[messages.length - 1].content = (
  124. <Typography.Text type="danger">{error.message}</Typography.Text>
  125. );
  126. arr[messages.length - 1].status = "error";
  127. arr[messages.length - 1].loading = false;
  128. return arr;
  129. });
  130. },
  131. });
  132. const handleChangeConversationName = (conversation: Conversation) => {
  133. let new_name: string;
  134. Modal.info({
  135. title: "修改对话名称",
  136. okText: "提交",
  137. closable: true,
  138. content: (
  139. <div>
  140. <Input
  141. type="text"
  142. defaultValue={conversation.label + ""}
  143. onChange={(e) => {
  144. new_name = e.target.value;
  145. }}
  146. />
  147. </div>
  148. ),
  149. onOk: () => {
  150. return ChangeSessionName({
  151. app_name: props.agent?.key || "",
  152. session_id: conversation.key,
  153. new_name,
  154. }).then(() => {
  155. message.success("修改成功");
  156. setConversationList((list) =>
  157. list?.map((item) =>
  158. item.key === conversation.key
  159. ? { ...item, label: new_name }
  160. : item
  161. )
  162. );
  163. });
  164. },
  165. });
  166. };
  167. const handleDeleteConversation = (conversation: Conversation) => {
  168. Modal.confirm({
  169. title: "删除对话",
  170. content: "是否删除对话?",
  171. okText: "删除",
  172. cancelText: "取消",
  173. onOk: () => {
  174. return DeleteSession({
  175. app_name: props.agent?.key || "",
  176. session_id: conversation.key,
  177. }).then(() => {
  178. message.success("删除成功");
  179. setConversationList((list) =>
  180. list?.filter((item) => item.key !== conversation.key)
  181. );
  182. addConversation();
  183. });
  184. },
  185. });
  186. };
  187. const menuConfig: ConversationsProps["menu"] = (conversation) => {
  188. if (conversation.key === "1") return undefined;
  189. return {
  190. items: [
  191. {
  192. label: "修改对话名称",
  193. key: "edit",
  194. icon: <EditOutlined />,
  195. },
  196. {
  197. label: "删除对话",
  198. key: "del",
  199. icon: <DeleteOutlined />,
  200. danger: true,
  201. },
  202. ],
  203. onClick: (menuInfo) => {
  204. // 修改对话名称
  205. if (menuInfo.key === "edit") {
  206. handleChangeConversationName(conversation);
  207. }
  208. // 删除对话
  209. if (menuInfo.key === "del") {
  210. handleDeleteConversation(conversation);
  211. }
  212. },
  213. };
  214. };
  215. const [openAttachment, setOpenAttachment] = useState(false);
  216. const attachmentsRef = useRef<GetRef<typeof Attachments>>(null);
  217. const senderRef = useRef<GetRef<typeof Sender>>(null);
  218. const [attachmentItems, setAttachmentItems] = useState<
  219. GetProp<AttachmentsProps, "items">
  220. >([]);
  221. const contentRef = useRef<HTMLDivElement>(null);
  222. const [contentHeight, setContentHeight] = useState(0);
  223. const setHeight = () => {
  224. setContentHeight(contentRef.current?.clientHeight || 0);
  225. };
  226. useEffect(() => {
  227. setHeight();
  228. window.addEventListener("resize", setHeight);
  229. return () => {
  230. window.removeEventListener("resize", setHeight);
  231. };
  232. }, []);
  233. // 附件组件
  234. const senderHeader = (
  235. <Sender.Header
  236. title="附件"
  237. styles={{
  238. content: {
  239. padding: 0,
  240. },
  241. }}
  242. open={openAttachment}
  243. onOpenChange={setOpenAttachment}
  244. forceRender
  245. >
  246. <Attachments
  247. ref={attachmentsRef}
  248. beforeUpload={() => false}
  249. items={attachmentItems}
  250. onChange={({ fileList }) => setAttachmentItems(fileList)}
  251. placeholder={(type) =>
  252. type === "drop"
  253. ? {
  254. title: "拖拽文件到这里",
  255. }
  256. : {
  257. icon: <CloudUploadOutlined />,
  258. title: "文件列表",
  259. description: "点击或者拖拽文件到这里上传",
  260. }
  261. }
  262. getDropContainer={() => senderRef.current?.nativeElement}
  263. />
  264. </Sender.Header>
  265. );
  266. // 底部组件
  267. const BubbleFooter = (props: { content: string; query: string }) => {
  268. const handleCopy = () => {
  269. navigator.clipboard.writeText(props.content);
  270. message.success("复制成功");
  271. };
  272. const handleRedo = () => {
  273. submitMessage(props.query);
  274. };
  275. return (
  276. <Space>
  277. <Button
  278. type="text"
  279. size="small"
  280. icon={<CopyOutlined />}
  281. onClick={handleCopy}
  282. >
  283. 复制
  284. </Button>
  285. <Button
  286. type="text"
  287. size="small"
  288. icon={<RedoOutlined />}
  289. onClick={handleRedo}
  290. >
  291. 重新生成
  292. </Button>
  293. </Space>
  294. );
  295. };
  296. // 提交消息
  297. const submitMessage = (msg: string) => {
  298. setSenderVal("");
  299. setMessages((arr) => {
  300. const index = arr.length;
  301. return [
  302. ...arr,
  303. { id: index + "", content: msg, status: "done", role: "user" },
  304. {
  305. id: index + 1 + "",
  306. content: "",
  307. status: "loading",
  308. role: "assistant",
  309. loading: true,
  310. },
  311. ];
  312. });
  313. onRequest(msg);
  314. };
  315. // 点击提示词
  316. const handlePromptItem = (item: any) => {
  317. const msg = item.data.description || item.data.label;
  318. const index = messages.length;
  319. setMessages([
  320. ...messages,
  321. { id: index + "", content: msg, status: "done", role: "user" },
  322. {
  323. id: index + 1 + "",
  324. content: "",
  325. status: "loading",
  326. role: "assistant",
  327. loading: true,
  328. },
  329. ]);
  330. onRequest(msg);
  331. };
  332. // 停止对话
  333. const handleStop = () => {
  334. cancel();
  335. setMessages((messages) => {
  336. const arr = [...messages];
  337. arr[messages.length - 1].status = "stop";
  338. arr[messages.length - 1].loading = false;
  339. arr[messages.length - 1].footer = (
  340. <div>
  341. <div className="text-12px text-text-secondary pl-12px">(已停止思考)</div>
  342. <BubbleFooter
  343. content={arr[messages.length - 1].content as string}
  344. query={arr[messages.length - 2].content as string}
  345. />
  346. </div>
  347. );
  348. return arr;
  349. });
  350. };
  351. return (
  352. <>
  353. <Card
  354. className="w-full h-full"
  355. styles={{
  356. body: {
  357. height: "calc(100% - 48px)",
  358. },
  359. }}
  360. title={
  361. <span className="flex items-center">
  362. <img
  363. className="w-20px h-20px rounded-8px"
  364. src={props.agent?.icon}
  365. />
  366. <span className="ml-4px">{props.agent?.name}</span>
  367. </span>
  368. }
  369. >
  370. <XProvider direction="ltr">
  371. <Flex style={{ height: "100%" }} gap={12}>
  372. <Spin spinning={loadingSession}>
  373. <div className="w-200px">
  374. <div className="w-full px-12px">
  375. <Button
  376. type="primary"
  377. className="w-full"
  378. icon={<PlusOutlined />}
  379. onClick={addConversation}
  380. >
  381. 新对话
  382. </Button>
  383. </div>
  384. <Conversations
  385. style={{ width: 200 }}
  386. activeKey={activeConversation}
  387. onActiveChange={changeConversation}
  388. menu={menuConfig}
  389. items={conversationList}
  390. />
  391. </div>
  392. </Spin>
  393. <Divider type="vertical" style={{ height: "100%" }} />
  394. <Flex vertical style={{ flex: 1 }} gap={8}>
  395. <div
  396. className="flex-1"
  397. ref={contentRef}
  398. style={{ height: contentHeight }}
  399. >
  400. {!messages.length ? (
  401. <>
  402. <div className="mt-20 mb-10">
  403. <Welcome
  404. icon={
  405. <img
  406. src={props.agent?.icon}
  407. className="rounded-8px"
  408. />
  409. }
  410. title={`你好!我是易码工坊${props.agent?.name || "AI"}助手`}
  411. description={props.agent?.description}
  412. />
  413. </div>
  414. <Prompts
  415. title={
  416. props.agent?.promptsItems ? "✨ 你可以这样问我:" : ""
  417. }
  418. items={props.agent?.promptsItems || []}
  419. wrap
  420. onItemClick={handlePromptItem}
  421. />
  422. </>
  423. ) : (
  424. <Bubble.List
  425. style={{ maxHeight: contentHeight }}
  426. autoScroll
  427. roles={roles}
  428. items={messages}
  429. />
  430. )}
  431. </div>
  432. <Prompts
  433. items={[
  434. {
  435. key: "1",
  436. icon: <BulbOutlined style={{ color: "#FFD700" }} />,
  437. label: "写需求",
  438. },
  439. {
  440. key: "2",
  441. icon: <SmileOutlined style={{ color: "#52C41A" }} />,
  442. label: "生成代码",
  443. },
  444. {
  445. key: "3",
  446. icon: <ReadOutlined style={{ color: "#52C41A" }} />,
  447. label: "问题解答",
  448. },
  449. ]}
  450. onItemClick={handlePromptItem}
  451. />
  452. <Suggestion
  453. items={[{ label: "写一个应用介绍", value: "report" }]}
  454. onSelect={submitMessage}
  455. >
  456. {({ onTrigger, onKeyDown }) => {
  457. return (
  458. <Sender
  459. ref={senderRef}
  460. header={senderHeader}
  461. loading={loading}
  462. prefix={
  463. <Button
  464. type="text"
  465. icon={<LinkOutlined />}
  466. onClick={() => {
  467. setOpenAttachment(!openAttachment);
  468. }}
  469. />
  470. }
  471. value={senderVal}
  472. onPasteFile={(file) => {
  473. attachmentsRef.current?.upload(file);
  474. setOpenAttachment(true);
  475. }}
  476. onChange={(nextVal) => {
  477. if (nextVal === "/") {
  478. onTrigger();
  479. } else if (!nextVal) {
  480. onTrigger(false);
  481. }
  482. setSenderVal(nextVal);
  483. }}
  484. onKeyDown={onKeyDown}
  485. placeholder="输入/获取快捷提示"
  486. onSubmit={submitMessage}
  487. onCancel={handleStop}
  488. />
  489. );
  490. }}
  491. </Suggestion>
  492. </Flex>
  493. </Flex>
  494. </XProvider>
  495. </Card>
  496. </>
  497. );
  498. };