Browse Source

perf: 优化数据模型AI对话

liaojiaxing 2 weeks ago
parent
commit
8dfd4728e5

+ 150 - 507
apps/designer/src/components/ai/AIChat.tsx

@@ -13,462 +13,156 @@ import {
   Button,
   Tooltip,
   Input,
-  Avatar,
   Form,
   Dropdown,
-  MenuProps,
+  Typography,
+  Space,
+  Spin,
 } from "antd";
-import { Message } from "ai/react";
+import { GetProp } from "antd";
+import { Bubble, Sender } from "@ant-design/x";
 import MarkdownViewer from "./MarkdownViewer";
-import { uuid } from "@repo/utils";
-import { useLocalStorageState } from "ahooks";
-import { useModel } from "umi";
-
-// 用户消息
-function UserMessage({ message }: { message: Message }) {
-  return (
-    <>
-      <div className="rounded-8px bg-#eff6ff p-8px leading-1.5em">
-        {message.content ?? ""}
-      </div>
-
-      <Avatar
-        className="flex-shrink-0"
-        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>
-      )}
-      {message.role === "user" && <UserMessage message={message}></UserMessage>}
-    </div>
-  );
-}
-
-// 消息列表
-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;
-}
+import { useChat } from "@/hooks/useChat";
+
+// bubbles角色配置
+const roles: GetProp<typeof Bubble.List, "roles"> = {
+  assistant: {
+    placement: "start",
+    avatar: undefined,
+    loadingRender: () => (
+      <Space>
+        <Spin size="small" />
+        思考中...
+      </Space>
+    ),
+    messageRender: (content) => {
+      return typeof content === "string" ? (
+        <Typography className={content?.includes("```") ? "w-full" : ""}>
+          <MarkdownViewer content={content} />
+        </Typography>
+      ) : (
+        content
+      );
+    },
+    header: <span className="text-12px text-#666">数据模型助手</span>,
+  },
+  user: {
+    placement: "start",
+    avatar: undefined,
+    messageRender: (content) => {
+      return <div style={{ whiteSpace: "pre-wrap" }}>{content}</div>;
+    },
+  },
+};
 
 export default function AIChat(props: { onClose?: () => void }) {
-  const [focused, setFocused] = useState(false);
   const [chatStarted, setChatStarted] = useState(false);
-  const [scrollHeight, setScrollHeight] = useState(0);
-  const scrollAreaRef = useRef<HTMLDivElement>(null);
-  const observer = useRef<ResizeObserver | null>(null);
-  const [messages, setMessages] = useState<Message[]>([]);
   const [inputVal, setInputVal] = useState("");
-  // 对话历史
-  const [history, setHistory] = useLocalStorageState<ChatHistoryItem[]>(
-    "chat-history",
-    { defaultValue: [] }
-  );
-  const { loading, agent, cancel } = useModel("aiModel");
-
-  const [chatId, setChatId] = React.useState(uuid());
+  const contentRef = useRef<HTMLDivElement>(null);
+  const [contentHeight, setContentHeight] = useState(0);
 
+  const setHeight = () => {
+    setContentHeight(contentRef.current?.clientHeight || 0);
+  };
   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]);
+    setHeight();
+    const resizeObserver = new ResizeObserver(() => {
+      setHeight();
+    });
+    resizeObserver.observe(contentRef.current!);
+    return () => {
+      resizeObserver.disconnect();
+    };
+  }, []);
 
-  // 发起请求
-  const onRequest = () => {
-    agent.request(
-      {
-        // 应用名称
-        app_name: "app1",
-        // 会话内容
-        chat_query: inputVal,
-        // 会话名称 第一次
-        chat_name: "新会话",
-        // 会话id 后续会话带入
-        // conversation_id: ;
+  const { loading, onRequest, cancel, messages, setMessages, addConversation } =
+    useChat({
+      app_name: "system_design",
+      onSuccess: (msg) => {
+        setMessages((messages) => {
+          const arr = [...messages];
+          // const query = arr[messages.length - 2].content as string;
+          arr[messages.length - 1].status = "done";
+          arr[messages.length - 1].footer = <></>;
+          return arr;
+        });
       },
-      {
-        onSuccess: (msg) => {
-          console.log("success", msg);
-          setMessages((messages) => {
-            const arr = [...messages];
-            return arr;
-          });
-        },
-        onError: (error) => {
-          console.log("err:", error);
-        },
-        onUpdate: (msg) => {
-          console.log("update", msg);
-          setMessages((messages) => {
-            const arr = [...messages];
-            arr[messages.length - 1].content += msg.answer;
-            arr[messages.length - 1].id = msg.message_id;
-            setChatId(msg.conversation_id);
-            return arr;
-          });
-        },
-      }
-    );
-    setInputVal("");
-  };
+      onUpdate: (msg) => {
+        setMessages((messages) => {
+          const arr = [...messages];
+          arr[messages.length - 1].content += msg.answer;
+          arr[messages.length - 1].id = msg.message_id;
+          arr[messages.length - 1].loading = false;
+          arr[messages.length - 1].status = "loading";
+          return arr;
+        });
+      },
+      onError: (error) => {
+        // message.error(error.message);
+        setMessages((messages) => {
+          const arr = [...messages];
+          arr[messages.length - 1].content = (
+            <Typography.Text type="danger">{error.message}</Typography.Text>
+          );
+          arr[messages.length - 1].status = "error";
+          arr[messages.length - 1].loading = false;
+          return arr;
+        });
+      },
+    });
 
   // 处理提交
-  const onSubmit = () => {
-    if (inputVal.trim()) {
+  const onSubmit = (val: string) => {
+    setInputVal("");
+    if (val.trim()) {
       if (!chatStarted) setChatStarted(true);
     }
-    setMessages((arr) => {
-      const index = arr.length;
-      return [
-        ...arr,
-        { id: index + "", content: inputVal, role: "user" },
+    if (val.trim()) {
+      setMessages((prev) => [
+        ...(prev || []),
+        {
+          id: Date.now() + "",
+          role: "user",
+          content: val.trim(),
+          status: "done",
+        },
         {
-          id: index + 1 + "",
+          id: Date.now() + "1",
           content: "",
+          status: "loading",
           role: "assistant",
+          loading: true,
         },
-      ];
-    });
-    onRequest();
+      ]);
+
+      onRequest(val.trim());
+    }
   };
 
   // 开启新的对话
   const onNewChat = () => {
-    setChatId(uuid());
+    addConversation();
     setChatStarted(false);
   };
 
-  const stop = () => {
+  const onStop = () => {
     cancel();
   };
 
-  React.useEffect(() => {
-    return () => {
-      // 取消所有进行中的请求
-      const controller = new AbortController();
-      controller.abort();
-    };
-  }, []);
-
-  React.useEffect(() => {
-    const scrollElement = scrollAreaRef.current;
-    if (scrollElement) {
-      observer.current = new ResizeObserver((entries) => {
-        for (let entry of entries) {
-          if (entry.target === scrollElement) {
-            setScrollHeight(entry.target.scrollHeight);
-            entry.target.scrollTop = entry.target.scrollHeight;
-          }
-        }
-      });
-
-      observer.current.observe(scrollElement);
-
-      return () => {
-        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 [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 handleInputChange = (str: string) => {
-    setInputVal(str);
-  };
-
   return (
-    <div className="flex-1 h-full flex flex-col">
+    <div
+      className="flex-1 h-full flex flex-col"
+      style={{
+        backgroundImage: "linear-gradient(137deg, #e5f4ff 0%, #efe7ff 100%)",
+      }}
+    >
       <div className="chat-head w-full h-40px px-10px color-#333 flex items-center justify-between">
-        <i className="iconfont icon-duihua"></i>
+      <span className="text-14px">
+          <svg className="icon h-32px w-32px mr-4px" aria-hidden="true">
+            <use xlinkHref="#icon-AI1"></use>
+          </svg>
+          <span>AI对话</span>
+        </span>
         <span>
           <Tooltip title="新建会话">
             <Button
@@ -479,7 +173,7 @@ export default function AIChat(props: { onClose?: () => void }) {
             ></Button>
           </Tooltip>
           <Dropdown
-            menu={{ items }}
+            menu={{ items: [] }}
             trigger={["click"]}
             placement="bottomLeft"
             arrow
@@ -502,92 +196,41 @@ export default function AIChat(props: { onClose?: () => void }) {
       </div>
 
       <div
-        className="chat-content flex-1 bg-#f5f5f5 px-10px overflow-y-auto mt-12px"
-        ref={scrollAreaRef}
+        className="chat-content flex-1 px-10px overflow-hidden mt-12px"
+        ref={contentRef}
+        style={{ height: contentHeight }}
       >
-        <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>
-            </>
-          )}
-
-          {chatStarted && (
-            <div className="overflow-y-auto h-full">
-              <MessageList messages={messages}></MessageList>
-              <div className="flex justify-center items-center h-40px">
-                {loading && (
-                  <Button
-                    type="primary"
-                    icon={<LoadingOutlined />}
-                    loading={loading}
-                  >
-                    思考中...
-                  </Button>
-                )}
-              </div>
-              {/* {error && (
-                <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>
-              )} */}
+        {!messages.length ? (
+          <>
+            <div className="text-center pt-200px">
+              <svg className="icon h-40px! w-40px!" aria-hidden="true">
+                <use xlinkHref="#icon-AI1"></use>
+              </svg>
             </div>
-          )}
-        </div>
+            <h2 className="text-center">询问AI助手</h2>
+            <p className="text-center">我是AI助手,有什么可以帮您的吗?</p>
+          </>
+        ) : (
+          <div className="overflow-y-auto h-full">
+            <Bubble.List
+              style={{ maxHeight: contentHeight }}
+              autoScroll
+              items={messages}
+              roles={roles}
+            />
+          </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={inputVal}
-            onChange={(e) => handleInputChange(e.target.value)}
-            disabled={loading}
-            onPressEnter={onSubmit}
-          />
-          <div className="float-right p-10px">
-            {loading ? (
-              <Tooltip title="停止生成">
-                <Button
-                  type="primary"
-                  shape="circle"
-                  icon={<i className="iconfont icon-stopcircle" />}
-                  onClick={stop}
-                ></Button>
-              </Tooltip>
-            ) : (
-              <Button
-                type="primary"
-                icon={<SendOutlined />}
-                disabled={!inputVal.trim()}
-                htmlType="submit"
-              >
-                发送
-              </Button>
-            )}
-          </div>
-        </Form>
+      <div className="px-12px py-16px">
+        <Sender
+          placeholder="你想咨询什么?"
+          loading={loading}
+          value={inputVal}
+          onChange={setInputVal}
+          onSubmit={onSubmit}
+          onCancel={onStop}
+        />
       </div>
     </div>
   );

+ 4 - 3
apps/designer/src/pages/flow/components/Config/AiCreator.tsx

@@ -3,6 +3,7 @@ import {
   CloseOutlined,
   SendOutlined,
   CaretDownOutlined,
+  LoadingOutlined
 } from "@ant-design/icons";
 import { Button, Tooltip, Input, Form, Dropdown, message } from "antd";
 import type { DropDownProps } from "antd";
@@ -34,7 +35,7 @@ export default function AICreator(props: {
   const { loading, onRequest, cancel } = useChat({
     app_name: "system_design",
     onUpdate: (msg) => {
-      setInput("");
+      // setInput("");
       msgContent.current += msg.answer;
     },
     onSuccess: (msg) => {
@@ -436,7 +437,7 @@ export default function AICreator(props: {
       {contextHolder}
       <div className="chat-head w-full h-40px px-10px color-#333 flex items-center justify-between">
         <span className="text-14px">
-          <svg className="icon h-32px w-32px" aria-hidden="true">
+          <svg className="icon h-32px w-32px mr-4px" aria-hidden="true">
             <use xlinkHref="#icon-AI1"></use>
           </svg>
           <span>AI创作</span>
@@ -484,7 +485,7 @@ export default function AICreator(props: {
                   <Button
                     type="primary"
                     shape="circle"
-                    icon={<i className="iconfont icon-stopcircle" />}
+                    icon={<LoadingOutlined/>}
                     onClick={handleStop}
                   ></Button>
                 </Tooltip>

+ 65 - 28
apps/designer/src/hooks/useChat.ts

@@ -2,6 +2,7 @@ import { useXAgent, XStream } from "@ant-design/x";
 import { useEffect, useRef, useState } from "react";
 import { useSessionStorageState } from "ahooks";
 import { GetSessionList, GetSessionMessageList } from "@/api/ai";
+import { getDateGroupString } from "@/utils";
 
 import type { ConversationsProps } from "@ant-design/x";
 import type { ReactNode } from "react";
@@ -42,8 +43,6 @@ type ChatProps = {
   app_name: string;
   // 会话id 后续会话带入
   conversation_id?: string;
-  // 开始流式传输内容
-  onStart?: (data?: ResponseMessageItem) => void;
   // 成功获取会话内容
   onSuccess?: (data: ResponseMessageItem) => void;
   // 更新流式消息内容
@@ -56,9 +55,10 @@ const defaultConversation = {
   // 会话id
   key: "1",
   label: "新的对话",
+  group: '今日'
 };
 
-export function useChat({ app_name, onStart, onSuccess, onUpdate, onError }: ChatProps) {
+export function useChat({ app_name, onSuccess, onUpdate, onError }: ChatProps) {
   /**
    * 发送消息加载状态
    */
@@ -93,39 +93,73 @@ export function useChat({ app_name, onStart, onSuccess, onUpdate, onError }: Cha
   // 当前智能体对象
   const [currentAgent, setCurrentAgent] = useSessionStorageState("agent-map");
 
-  useEffect(() => {
+  // 有更多对话
+  const [hasMoreConversation, setHasMoreConversation] = useState(false);
+
+  // 会话分页
+  const [pageIndex, setPageIndex] = useState(1);
+
+  const getSession = (page: number) => {
     setLoadingSession(true);
     GetSessionList({
       app_name,
-      page_index: 1,
+      page_index: page,
     })
       .then((res) => {
-        setConversationList([
-          { ...defaultConversation },
-          ...(res?.result?.model || []).map((item: any) => ({
-            ...item,
-            key: item.sessionId,
-            label: item.name,
-          })),
-        ]);
+        if(page === 1)  {
+          setConversationList([
+            { ...defaultConversation },
+            ...(res?.result?.model || []).map((item: any) => ({
+              ...item,
+              key: item.sessionId,
+              label: item.name,
+              group: getDateGroupString(item.updateTime)
+            })),
+          ]);
+        } else {
+          setConversationList([
+            ...(conversationList || []),
+            ...(res?.result?.model || []).map((item: any) => ({
+              ...item,
+              key: item.sessionId,
+              label: item.name,
+              group: getDateGroupString(item.updateTime)
+            })),
+          ]);
+        }
+        setHasMoreConversation(res?.result.totalPages > page);
       })
       .finally(() => {
         setLoadingSession(false);
       });
+  }
+
+  // 切换app时获取会话记录
+  useEffect(() => {
+    setPageIndex(1);
+    getSession(1);
   }, [app_name]);
 
+  /**
+   * 加载更多会话
+   */
+  const loadMoreConversation = () => {
+    getSession(pageIndex + 1);
+    setPageIndex(pageIndex + 1);
+  };
+
   /**
    * 切换会话
    * @param key 会话id
    * @returns 
    */
   const changeConversation = async (key: string) => {
-    cancel();
     setActiveConversation(key);
     if (key === "1") {
       setMessages([]);
       return;
     }
+    cancel();
     setLoadingMessages(true);
     // 获取会话内容
     try {
@@ -158,27 +192,22 @@ export function useChat({ app_name, onStart, onSuccess, onUpdate, onError }: Cha
     }
   };
 
-  const baseUrl = process.env.NODE_ENV === "production" ? "" : "/api";
-
   /**
    * 封装智能体
    */
   const [agent] = useXAgent<ResponseMessageItem>({
     request: async (message, { onError, onSuccess, onUpdate }) => {
-      const enterpriseCode = sessionStorage.getItem("enterpriseCode");
-      const token = localStorage.getItem("token_" + enterpriseCode) || '';
-
       abortController.current = new AbortController();
       const signal = abortController.current.signal;
       try {
         setLoading(true);
         const response = await fetch(
-          baseUrl + "/api/ai/chat-message",
+          "https://design.shalu.com/api/ai/chat-message",
           {
             method: "POST",
             body: JSON.stringify(message),
             headers: {
-              Authorization: token,
+              Authorization: localStorage.getItem("token_a") || "",
               "Content-Type": "application/json",
             },
             signal,
@@ -200,21 +229,17 @@ export function useChat({ app_name, onStart, onSuccess, onUpdate, onError }: Cha
                 onError(data);
               } else if (data?.event === "ping") {
                 console.log(">>>> stream start <<<<");
-                onStart?.(data);
               } else {
                 console.log(">>>> stream error <<<<");
-                console.log(data);
-                onError?.(Error(data?.message || 'AI调用失败'));
+                onError(Error(data?.message || '请求失败'));
               }
             }
           }
         } else {
           // 接口异常处理
           response.json().then(res => {
-            if(res.code === 0 ) {
               onError?.(Error(res?.error || '请求失败'));
               cancel();
-            }
           });
         }
       } catch (error) {
@@ -234,11 +259,11 @@ export function useChat({ app_name, onStart, onSuccess, onUpdate, onError }: Cha
    * @param chat_query 对话内容
    */
   const onRequest = (chat_query: string, callbacks?: {
-    onUpdate: (message: ResponseMessageItem) => void;
     onSuccess: (message: ResponseMessageItem) => void;
+    onUpdate: (message: ResponseMessageItem) => void;
     onError: (error: Error) => void;
   }, name?: string) => {
-    setConversationList((list) => {
+    activeConversation === '1' && setConversationList((list) => {
       return list?.map((item) => {
         return {
           ...item,
@@ -287,6 +312,7 @@ export function useChat({ app_name, onStart, onSuccess, onUpdate, onError }: Cha
    */
   const cancel = () => {
     abortController.current?.abort();
+    setLoading(false);
   };
 
   /**
@@ -307,6 +333,14 @@ export function useChat({ app_name, onStart, onSuccess, onUpdate, onError }: Cha
     }
   };
 
+  /**
+   * 更新会话列表
+   */
+  const refreshConversationList = () => {
+    setPageIndex(1);
+    getSession(1);
+  };
+
   return {
     agent,
     loading,
@@ -322,5 +356,8 @@ export function useChat({ app_name, onStart, onSuccess, onUpdate, onError }: Cha
     onRequest,
     addConversation,
     changeConversation,
+    loadMoreConversation,
+    hasMoreConversation,
+    refreshConversationList
   };
 }

+ 0 - 78
apps/designer/src/models/aiModel.ts

@@ -1,78 +0,0 @@
-import { useXAgent, XStream } from "@ant-design/x";
-import { useRef, useState } from "react";
-
-type MessageItem = {
-  answer: string;
-  conversation_id: string;
-  created_at: number;
-  event: "message" | "message_end" | "message_error" | "ping";
-  message_id: string;
-  task_id: string;
-};
-
-export default function aiModel() {
-  const [loading, setLoading] = useState(false);
-  const abortController = useRef<AbortController | null>(null);
-
-  // 封装智能体
-  const [agent] = useXAgent<MessageItem>({
-    request: async (message, { onError, onSuccess, onUpdate }) => {
-      abortController.current = new AbortController();
-      const signal = abortController.current.signal;
-      try {
-        setLoading(true);
-        const response = await fetch(
-          "https://design.shalu.com/api/ai/chat-message",
-          {
-            method: "POST",
-            body: JSON.stringify(message),
-            headers: {
-              Authorization: localStorage.getItem("token_a") || "",
-              "Content-Type": "application/json",
-            },
-            signal
-          }
-        );
-
-        if (response.body) {
-          for await (const chunk of XStream({
-            readableStream: response.body,
-          })) {
-            const data = JSON.parse(chunk.data);
-            if (data?.event === "message") {
-              onUpdate(data);
-            }
-            if (data?.event === "message_end") {
-              onSuccess(data);
-            }
-            if (data?.event === "message_error") {
-              onError(data);
-            }
-            if (data?.event === "ping") {
-              console.log("start");
-            }
-          }
-        }
-      } catch (error) {
-        if(signal.aborted) {
-          return;
-        }
-        onError(error as Error);
-      } finally {
-        setLoading(false);
-      }
-    },
-  });
-
-  // 停止对话
-  const cancel = () => {
-    abortController.current?.abort();
-  };
-
-  return {
-    agent,
-    loading,
-    setLoading,
-    cancel,
-  };
-}

+ 2 - 2
apps/designer/src/pages/flow/components/Config/index.tsx

@@ -1,12 +1,12 @@
 import { ConfigProvider, Tabs } from "antd";
-import React, { useEffect, useMemo, useState } from "react";
+import { useMemo } from "react";
 import PageStyle from "./PageStyle";
 import GraphStyle from "./GraphStyle";
 import NodeAttrs from "@/components/NodeAttrs";
 import { useModel } from "umi";
 import InsetCss from "insert-css";
 import AIChat from "@/components/ai/AIChat";
-import AICreator from "./AiCreator";
+import AICreator from "@/components/ai/AiCreator";
 import { useDragResize } from "@/hooks/useDragResize";
 
 InsetCss(`

+ 4 - 4
apps/designer/src/pages/flow/components/ToolBar/index.tsx

@@ -776,7 +776,7 @@ export default function ToolBar() {
         <MermaidModal ref={mermaidModelRef} onChange={handleInsertMermaid} />
         <div>
           <Tooltip placement="bottom" title="AI助手">
-            {/* <Dropdown
+            <Dropdown
               menu={{
                 items: [
                   {
@@ -805,7 +805,7 @@ export default function ToolBar() {
                   },
                 ],
               }}
-            > */}
+            >
             <Button
               type="text"
               icon={
@@ -815,9 +815,9 @@ export default function ToolBar() {
               }
               className={activeAI ? "active" : ""}
               style={{ marginRight: 16 }}
-              onClick={() => setActiveAI("creator")}
+              // onClick={() => setActiveAI("creator")}
             />
-            {/* </Dropdown> */}
+            </Dropdown>
           </Tooltip>
           <Tooltip placement="bottom" title="替换">
             <Button

+ 72 - 1
apps/designer/src/utils/index.ts

@@ -2,6 +2,7 @@ export * from "./hander";
 import { AddGraph } from "@/api/systemDesigner";
 import { GraphType } from "@/enum";
 import type { MessageInstance } from "antd/lib/message/interface";
+import { uuid } from "@repo/utils";
 
 /**
  * 打印图片
@@ -118,6 +119,43 @@ function regexExtractJSON(markdown: string) {
   return matches;
 }
 
+/**
+ * AI生成的数据id可能有问题,重试生成id
+ */
+const replaceId = (data: any) => {
+  if(Object.prototype.toString.call(data) === "[object Object]") {
+    if(data?.id) {
+      data.id = uuid();
+    }
+    data?.ports?.items?.forEach((item: any) => {
+      if(item?.id) {
+        item.id = uuid();
+      }
+    });
+  }
+  if(Object.prototype.toString.call(data) === "[object Array]") {
+    data.forEach((item: any) => {
+      const id = uuid();
+      const originalId = item.id;
+      item.id = id;
+      data.forEach((cell: any) => {
+        if(cell?.source?.cell === originalId ) { cell.source.cell = id; }
+        if(cell?.target?.cell === originalId ) { cell.target.cell = id; }
+      });
+      item?.ports?.items?.forEach((port: any) => {
+        const id = uuid();
+        const originalId = port.id;
+        port.id = id;
+        data.forEach((cell: any) => {
+          if(cell?.source?.port === originalId ) { cell.source.port = id; }
+          if(cell?.target?.port === originalId ) { cell.target.port = id; }
+        });
+      });
+    });
+  }
+  return data;
+};
+
 /**
  * 转换解析出ai数据
  * @param content
@@ -148,7 +186,9 @@ export const handleParseAIData = ({
       json = JSON.parse(content);
     }
 
-    onSuccess?.(json);
+    // 处理AI生成数据
+    const data = replaceId(json);
+    onSuccess?.(data);
   } catch (error) {
     message.instance.open({
       key: message.key,
@@ -163,3 +203,34 @@ export const handleParseAIData = ({
     onError?.(new Error("AI创作失败"));
   }
 };
+
+/**
+ * 获取日期格式化分组
+ * @param dateVal 日期
+ * @returns 
+ */
+export function getDateGroupString(dateVal: Date | string): string {
+  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 itemDate = normalizeDate(new Date(dateVal));
+  const diffTime = now.getTime() - itemDate.getTime();
+  const diffDays = Math.floor(diffTime / (1000 * 3600 * 24));
+
+  if (diffDays === 0) {
+    return "今日";
+  } else if (diffDays === 1) {
+    return "昨日";
+  } else if (diffDays <= 6) {
+    return "7日内";
+  } else if (diffDays <= 29) {
+    return "30日内";
+  } else {
+    return "更久~";
+  }
+}

+ 41 - 0
apps/er-designer/src/components/LoadingDot/index.less

@@ -0,0 +1,41 @@
+.loader {
+  display: inline-block;
+  position: relative;
+  width: 30px;
+}
+.loader-3 .dot {
+  width: 5px;
+  height: 5px;
+  background: #00e676;
+  border-radius: 50%;
+  position: absolute;
+  top: calc(50% + 2px)
+}
+
+.loader-3 .dot1 {
+  left: 0;
+  -webkit-animation: dot-jump .5s cubic-bezier(0.77,0.47,0.64,0.28) alternate infinite;
+  animation: dot-jump .5s cubic-bezier(0.77,0.47,0.64,0.28) alternate infinite
+}
+
+.loader-3 .dot2 {
+  left: 10px;
+  -webkit-animation: dot-jump .5s .2s cubic-bezier(0.77,0.47,0.64,0.28) alternate infinite;
+  animation: dot-jump .5s .2s cubic-bezier(0.77,0.47,0.64,0.28) alternate infinite
+}
+
+.loader-3 .dot3 {
+  left: 20px;
+  -webkit-animation: dot-jump .5s .4s cubic-bezier(0.77,0.47,0.64,0.28) alternate infinite;
+  animation: dot-jump .5s .4s cubic-bezier(0.77,0.47,0.64,0.28) alternate infinite
+}
+
+@keyframes dot-jump {
+  0% {
+      transform: translateY(0)
+  }
+
+  100% {
+      transform: translateY(-15px)
+  }
+}

+ 12 - 0
apps/er-designer/src/components/LoadingDot/index.tsx

@@ -0,0 +1,12 @@
+import React from "react";
+import "./index.less";
+
+export default function LoadingDot() {
+  return (
+    <div className="loader loader-3">
+      <div className="dot dot1"></div>
+      <div className="dot dot2"></div>
+      <div className="dot dot3"></div>
+    </div>
+  );
+}

+ 94 - 0
apps/er-designer/src/components/ai/MarkdownViewer.tsx

@@ -0,0 +1,94 @@
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import rehypeRaw from "rehype-raw";
+import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
+import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
+import { useState } from "react";
+
+interface MarkdownViewerProps {
+  content: string;
+}
+
+const CodeHeader: React.FC<{
+  language: string;
+  onCopy: () => void;
+  copied: boolean;
+}> = ({ language, onCopy, copied }) => (
+  <div
+    className="flex justify-between items-center text-white"
+    style={{ background: "#afadad", padding: "3px 4px" }}
+  >
+    <div className="flex items-center">
+      <span className="text-xs text-#fff">{language}</span>
+    </div>
+
+    <div>
+      <span className="cursor-pointer text-xs" onClick={onCopy}>
+        {copied ? "已复制!" : "复制代码"}
+      </span>
+    </div>
+  </div>
+);
+
+const MarkdownViewer: React.FC<MarkdownViewerProps> = ({ content }) => {
+  const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
+
+  const handleCopy = (code: string, index: number) => {
+    navigator.clipboard.writeText(code);
+    setCopiedIndex(index);
+    setTimeout(() => setCopiedIndex(null), 2000);
+  };
+
+  return (
+    <ReactMarkdown
+      className="markdown-body"
+      remarkPlugins={[remarkGfm]}
+      rehypePlugins={[rehypeRaw]}
+      components={{
+        code({ node, className, children, ...props }) {
+          const match = /language-(\w+)/.exec(className || "");
+          const code = String(children).replace(/\n$/, "");
+          const language = match ? match[1] : "";
+
+          if (match) {
+            return (
+              <div className="rounded-md overflow-hidden mb-4">
+                <CodeHeader
+                  language={language}
+                  onCopy={() =>
+                    handleCopy(code, node?.position?.start.line ?? 0)
+                  }
+                  copied={copiedIndex === node?.position?.start.line}
+                />
+                <div className="max-w-full overflow-x-auto">
+                  <SyntaxHighlighter
+                    style={vscDarkPlus}
+                    language={language}
+                    PreTag="div"
+                    {...props}
+                    customStyle={{
+                      margin: 0,
+                      borderTopLeftRadius: 0,
+                      borderTopRightRadius: 0,
+                    }}
+                  >
+                    {code}
+                  </SyntaxHighlighter>
+                </div>
+              </div>
+            );
+          }
+          return (
+            <code className={className} {...props}>
+              {children}
+            </code>
+          );
+        },
+      }}
+    >
+      {content}
+    </ReactMarkdown>
+  );
+};
+
+export default MarkdownViewer;

+ 10 - 0
apps/er-designer/src/components/ai/mermaid.less

@@ -0,0 +1,10 @@
+.mermaid-container {
+  width: 100%;
+  height: 100%;
+  img {
+    max-width: 100%;
+    max-height: 100%;
+    margin: 0 auto;
+    display: block;
+  }
+}

+ 1 - 0
apps/er-designer/src/hooks/useChat.ts

@@ -227,6 +227,7 @@ export function useChat({ app_name, onSuccess, onUpdate, onError }: ChatProps) {
               if (data?.event === "message") {
                 onUpdate(data);
               } else if (data?.event === "message_end") {
+                setLoading(false);
                 onSuccess(data);
               } else if (data?.event === "message_error") {
                 onError(data);

+ 104 - 40
apps/er-designer/src/models/erModel.tsx

@@ -7,10 +7,15 @@ import { Keyboard } from "@antv/x6-plugin-keyboard";
 import { Export } from "@antv/x6-plugin-export";
 import { Selection } from "@antv/x6-plugin-selection";
 import { SaveDataModel, UploadFile, BatchAddAICreateResult } from "@/api";
-import { useFullscreen, useSessionStorageState, useLocalStorageState } from "ahooks";
+import {
+  useFullscreen,
+  useSessionStorageState,
+  useLocalStorageState,
+} from "ahooks";
 import { createTable, createColumn } from "@/utils";
 import dayjs from "dayjs";
 import { getClassRules, base64ToFile, uuid } from "@repo/utils";
+import { merge } from "lodash-es";
 
 import type {
   ColumnItem,
@@ -46,7 +51,10 @@ export default function erModel() {
     }
   );
   // 更新画布标识
-  const [updateKey, setUpdateKey] = useSessionStorageState('update-key', { listenStorageChange: true, defaultValue: 0 });
+  const [updateKey, setUpdateKey] = useSessionStorageState("update-key", {
+    listenStorageChange: true,
+    defaultValue: 0,
+  });
   const [saveTime, setSaveTime] = useState<string>();
   const [project, setProjectInfo] = useState<ProjectInfo>({
     id: "",
@@ -85,7 +93,7 @@ export default function erModel() {
     // 清除定时器
     clearTimeout(timer.current);
     timer.current = setTimeout(() => {
-      SaveDataModel({ erDataModel: info});
+      SaveDataModel({ erDataModel: info });
       // 格式化当前时间
       setSaveTime(dayjs().format("YYYY-MM-DD HH:mm:ss"));
     }, 500);
@@ -153,16 +161,13 @@ export default function erModel() {
     graphRef.current && render(graphRef.current, project);
   }, [project.setting.showRelation]);
 
-  const [hideDefaultColumn] = useLocalStorageState(
-    "er-hideDefaultColumn",
-    {
-      defaultValue: false,
-      listenStorageChange: true
-    }
-  );
+  const [hideDefaultColumn] = useLocalStorageState("er-hideDefaultColumn", {
+    defaultValue: false,
+    listenStorageChange: true,
+  });
 
   useEffect(() => {
-    if(graph) {
+    if (graph) {
       requestAnimationFrame(() => {
         render(graph, project);
       });
@@ -262,9 +267,11 @@ export default function erModel() {
         },
       })
     );
-    instance.use(new Scroller({
-      pannable: true
-    }));
+    instance.use(
+      new Scroller({
+        pannable: true,
+      })
+    );
     instance.use(new Keyboard());
     instance.use(new Export());
     instance.use(
@@ -450,7 +457,7 @@ export default function erModel() {
     setTableActive(newTable.table.id);
 
     const cell = graphRef.current?.getCellById(newTable.table.id);
-    if(cell) {
+    if (cell) {
       graphRef.current?.select(cell);
       graphRef.current?.centerCell(cell);
     }
@@ -524,9 +531,9 @@ export default function erModel() {
       topicAreas: [...project.topicAreas, newTopicArea],
     });
     setTabActiveKey("3");
-    
+
     const cell = graphRef.current?.getCellById(topicAreaId);
-    if(cell) {
+    if (cell) {
       graphRef.current?.select(cell);
       graphRef.current?.centerCell(cell);
     }
@@ -586,7 +593,7 @@ export default function erModel() {
     setTabActiveKey("4");
 
     const cell = graphRef.current?.getCellById(remarkId);
-    if(cell) {
+    if (cell) {
       graphRef.current?.select(cell);
       graphRef.current?.centerCell(cell);
     }
@@ -650,7 +657,7 @@ export default function erModel() {
         canAdd: false,
       };
     }
-    if ( sourceColumn.tableId === targetColumn.tableId) {
+    if (sourceColumn.tableId === targetColumn.tableId) {
       return {
         relations: project.relations,
         canAdd: false,
@@ -837,8 +844,8 @@ export default function erModel() {
           ...data,
           table: {
             ...data.table,
-            schemaName: data.table.schemaName + '_copy',
-            aliasName: data.table.aliasName + 'Copy',
+            schemaName: data.table.schemaName + "_copy",
+            aliasName: data.table.aliasName + "Copy",
             id: tableId,
             style: {
               ...data.table.style,
@@ -864,7 +871,7 @@ export default function erModel() {
         const topicAreaId = uuid();
         const newTopicArea = {
           ...data,
-          name: data.name + '_copy',
+          name: data.name + "_copy",
           id: topicAreaId,
           style: {
             ...data.style,
@@ -882,7 +889,7 @@ export default function erModel() {
         const remarkId = uuid();
         const newRemark = {
           ...data,
-          name: data.name + '_copy',
+          name: data.name + "_copy",
           id: remarkId,
           style: {
             ...data.style,
@@ -972,7 +979,7 @@ export default function erModel() {
             erDataModel: {
               ...state,
               coverImage: res?.result?.[0]?.id,
-            }
+            },
           }).finally(() => {
             message.destroy();
           });
@@ -1003,22 +1010,79 @@ export default function erModel() {
       relations: []
     } 
   */
-  const onCreateByAi = async (data: any) => {
-    console.log(data);
-    // if(data?.tables?.length) {
-    //   data.tables.forEach((tableItem: TableItemType) => {
-    //     const newTable = createTable(project.type || 3, project.id);
-    //     merge(newTable.table, tableItem.table);
-    //     table
-    //   })
-    // }
-    await BatchAddAICreateResult({
-      ...data,
-      dataModelId: project.id
-    });
+  const onCreateByAi = async (data: {
+    tables: TableItemType[];
+    relations: ColumnRelation[];
+  }) => {
+    if (data?.tables?.length) {
+      const rect = graphRef.current?.getAllCellsBBox();
+      // 计算起始位置 默认往最后加
+      const startY = (rect?.height || 0) + (rect?.y || 0) + 20;
 
-    setUpdateKey((state) => (state || 0) + 1);
-  }
+      // 创建表
+      const generateTables = data.tables.map(
+        (tableItem: TableItemType, index: number) => {
+          const newTable = createTable(project.type || 3, project.id);
+          data.relations.forEach((relation) => {
+            // 修改表关系的id
+            if (relation.foreignTable === tableItem.table.id) {
+              relation.foreignTable = newTable.table.id;
+            }
+            if (relation.primaryTable === tableItem.table.id) {
+              relation.primaryTable = newTable.table.id;
+            }
+          });
+          newTable.table = merge(newTable.table, tableItem.table, {
+            id: newTable.table.id,
+            style: {
+              x: 100 + index * (220 + 20),
+              y: startY,
+            },
+          });
+          newTable.tableColumnList = tableItem.tableColumnList.map(
+            (columnItem: ColumnItem) => {
+              const newColumn = createColumn(newTable.table.id);
+              data.relations.forEach((relation) => {
+                // 修改表关系的id
+                if (relation.foreignKey === columnItem.id) {
+                  relation.foreignKey = newColumn.id;
+                }
+                if (relation.primaryKey === columnItem.id) {
+                  relation.primaryKey = newColumn.id;
+                }
+              });
+              merge(newColumn, columnItem, { id: newColumn.id });
+              return newColumn;
+            }
+          );
+
+          return newTable;
+        }
+      );
+
+      const generateRelations = data.relations.map((relation) => {
+        return {
+          ...relation,
+          dataModelId: project.id,
+        };
+      });
+
+      console.log("newTableList", generateTables, generateRelations);
+      setProject((project) => {
+        return {
+          ...project,
+          tables: [...project.tables, ...generateTables],
+          relations: [...project.relations, ...generateRelations],
+        };
+      });
+    }
+    // await BatchAddAICreateResult({
+    //   ...data,
+    //   dataModelId: modelId || project.id
+    // });
+
+    // setUpdateKey((state) => (state || 0) + 1);
+  };
 
   return {
     initGraph,
@@ -1055,6 +1119,6 @@ export default function erModel() {
     onSave,
     tableActive,
     setTableActive,
-    onCreateByAi
+    onCreateByAi,
   };
 }

+ 5 - 5
apps/er-designer/src/pages/detail/index.tsx

@@ -301,10 +301,10 @@ export default function index() {
     </div>
   );
 
-  const handleAiCreate = async (data: any) => {
-    await onCreateByAi(data);
-    refresh();
-  }
+  // const handleAiCreate = async (data: any) => {
+  //   await onCreateByAi(data);
+  //   refresh();
+  // }
 
   return (
     <Spin spinning={loading}>
@@ -419,7 +419,7 @@ export default function index() {
                     right: 10,
                     top: 20
                   }}
-                  onChange={handleAiCreate}
+                  // onChange={handleAiCreate}
                   trigger={
                     <Button
                       type="text"

+ 128 - 83
apps/er-designer/src/pages/er/components/AICreator.tsx

@@ -1,5 +1,13 @@
 import React, { useMemo, useRef, useState } from "react";
-import { Modal, Typography, message, Space, Spin } from "antd";
+import {
+  Modal,
+  Typography,
+  message,
+  Space,
+  Spin,
+  Dropdown,
+  Button,
+} from "antd";
 import type { DraggableData, DraggableEvent } from "react-draggable";
 import Draggable from "react-draggable";
 import { Sender, Welcome, Prompts, Bubble } from "@ant-design/x";
@@ -8,6 +16,8 @@ import aiLogo from "@/assets/icon-ai-3.png";
 import { CoffeeOutlined, SmileOutlined, UserOutlined } from "@ant-design/icons";
 import { useChat } from "@/hooks/useChat";
 import type { GetProp } from "antd/lib";
+import MarkdownViewer from "@/components/ai/MarkdownViewer";
+import AITable from "./AITable";
 
 type AICteatorProps = {
   trigger: JSX.Element;
@@ -21,53 +31,6 @@ type AICteatorProps = {
   };
 };
 
-const items: PromptsProps["items"] = [
-  {
-    key: "6",
-    icon: <CoffeeOutlined style={{ color: "#964B00" }} />,
-    description: "帮我创建一个用户表",
-    disabled: false,
-  },
-  {
-    key: "7",
-    icon: <SmileOutlined style={{ color: "#FAAD14" }} />,
-    description: "创建一个订单表",
-    disabled: false,
-  },
-];
-
-// bubbles角色配置
-const roles: GetProp<typeof Bubble.List, "roles"> = {
-  assistant: {
-    placement: "start",
-    avatar: undefined,
-    loadingRender: () => (
-      <Space>
-        <Spin size="small" />
-        思考中...
-      </Space>
-    ),
-    messageRender: (content) => {
-      return typeof content === "string" ? (
-        <Typography className={content?.includes("```") ? "w-full" : ""}>
-          {/* <MarkdownViewer content={content} /> */}
-          {content}
-        </Typography>
-      ) : (
-        content
-      );
-    },
-    header: "数据模型助手",
-  },
-  user: {
-    placement: "start",
-    avatar: undefined,
-    messageRender: (content) => {
-      return <div style={{ whiteSpace: "pre-wrap" }}>{content}</div>;
-    },
-  },
-};
-
 export default (props: AICteatorProps) => {
   const [open, setOpen] = useState(false);
   const [disabled, setDisabled] = useState(true);
@@ -79,39 +42,74 @@ export default (props: AICteatorProps) => {
   });
   const [input, setInput] = useState("");
   const draggleRef = useRef<HTMLDivElement>(null!);
-  const [messageApi, contextHolder] = message.useMessage();
-  const msgContent = useRef<string>("");
-  const messageKey = "data-model";
-
-  function regexExtractJSON(markdown: string) {
-    const jsonRegex = /```(?:json)?\n([\s\S]*?)\n```/g;
-    const matches = [];
-    let match;
+  const [assistantType, setAssistantType] = useState<"chat" | "generate">(
+    "generate"
+  );
+  const scrollRef = useRef<HTMLDivElement>(null);
 
-    while ((match = jsonRegex.exec(markdown)) !== null) {
-      try {
-        const jsonObj = JSON.parse(match[1]);
-        matches.push(jsonObj);
-      } catch (e) {
-        console.warn("无效JSON:", match[0]);
-      }
-    }
-    return matches;
-  }
+  const items: PromptsProps["items"] = [
+    {
+      key: "6",
+      icon: <CoffeeOutlined style={{ color: "#964B00" }} />,
+      description: "帮我创建一个用户表",
+      disabled: false,
+    },
+    {
+      key: "7",
+      icon: <SmileOutlined style={{ color: "#FAAD14" }} />,
+      description: "创建一个订单表",
+      disabled: false,
+    },
+  ];
 
-  const handleParseJsonByMd = (str: string) => {
-    // 根据markdown格式取出json部分数据
-    const md = str;
-    let json: string;
-    if (md.includes("```json")) {
-      json = regexExtractJSON(msgContent.current)?.[0];
-    } else {
-      json = JSON.parse(msgContent.current);
-    }
+  const handleTry = () => {
+    onSubmit(messages[messages.length - 2].content as string);
+  };
 
-    // console.log("解析结果:", json);
-    // props.onChange?.(json);
-    return json;
+  // bubbles角色配置
+  const roles: GetProp<typeof Bubble.List, "roles"> = {
+    assistant: {
+      placement: "start",
+      avatar: undefined,
+      loadingRender: () => (
+        <Space>
+          <Spin size="small" />
+          思考中...
+        </Space>
+      ),
+      messageRender: (content) => {
+        return typeof content === "string" ? (
+          <Typography className={content?.includes("```") ? "w-full" : ""}>
+            {/* <MarkdownViewer content={content} /> */}
+            {content?.includes("<generate>") ? (
+              <AITable
+                content={content}
+                onChange={props.onChange}
+                onTry={handleTry}
+              ></AITable>
+            ) : (
+              <MarkdownViewer
+                content={content.replace("<chat>", "").replace("</chat>", "")}
+              />
+            )}
+          </Typography>
+        ) : (
+          content
+        );
+      },
+    },
+    user: {
+      placement: "end",
+      avatar: undefined,
+      messageRender: (content) => {
+        return <div style={{ whiteSpace: "pre-wrap" }}>{content}</div>;
+      },
+      styles: {
+        content: {
+          backgroundColor: "#d2e1fb",
+        },
+      },
+    },
   };
 
   const { loading, onRequest, cancel, messages, setMessages, addConversation } =
@@ -122,7 +120,7 @@ export default (props: AICteatorProps) => {
           const arr = [...messages];
           const query = arr[messages.length - 2].content as string;
           arr[messages.length - 1].status = "done";
-          arr[messages.length - 1].footer = <></>;
+          arr[messages.length - 1].content += `</${assistantType}>`;
           return arr;
         });
       },
@@ -135,6 +133,10 @@ export default (props: AICteatorProps) => {
           arr[messages.length - 1].status = "loading";
           return arr;
         });
+        setTimeout(() => {
+          const scrollHeight = scrollRef.current?.scrollHeight;
+          scrollRef.current?.scrollTo(0, scrollHeight || 0);
+        }, 200);
       },
       onError: (error) => {
         message.error(error.message);
@@ -172,6 +174,7 @@ export default (props: AICteatorProps) => {
   };
 
   const onSubmit = (value: string) => {
+    setInput("");
     if (value.trim()) {
       setMessages((prev) => [
         ...(prev || []),
@@ -181,10 +184,25 @@ export default (props: AICteatorProps) => {
           content: value.trim(),
           status: "done",
         },
+        {
+          id: Date.now() + "1",
+          content: `<${assistantType}>`,
+          status: "loading",
+          role: "assistant",
+          loading: true,
+        },
       ]);
 
-      const query = `设计一个数据模型内容,需求如下:${value.trim()}`;
+      const query =
+        assistantType === "generate"
+          ? `#角色:你是一个数据模型助手,根据需求部分的描述用生成数据模型工具为用户生成数据模型的json数据,不需要返回其他内容。
+#需求:${value.trim()}`
+          : value.trim();
       onRequest(query, value.trim());
+      setTimeout(() => {
+        const scrollHeight = scrollRef.current?.scrollHeight;
+        scrollRef.current?.scrollTo(0, scrollHeight || 0);
+      }, 200);
     }
   };
 
@@ -212,7 +230,6 @@ export default (props: AICteatorProps) => {
   return (
     <>
       {triggerDom}
-      {contextHolder}
       <Modal
         title={
           <div
@@ -229,7 +246,7 @@ export default (props: AICteatorProps) => {
         mask={false}
         maskClosable={false}
         open={open}
-        width={440}
+        width={500}
         style={getStyle}
         styles={{
           content: {
@@ -258,7 +275,7 @@ export default (props: AICteatorProps) => {
         )}
       >
         <div className="h-full flex flex-col overflow-hidden">
-          <div className="flex-1">
+          <div ref={scrollRef} className="flex-1 overflow-auto p-b-12px">
             <div className="my-10">
               <Welcome
                 variant="borderless"
@@ -279,6 +296,34 @@ export default (props: AICteatorProps) => {
           </div>
           <Sender
             placeholder="如:创建一个用户表"
+            prefix={
+              <Dropdown
+                menu={{
+                  items: [
+                    {
+                      key: "1",
+                      label: "生成实体表",
+                      onClick: () => {
+                        setAssistantType("generate");
+                        addConversation();
+                      },
+                    },
+                    {
+                      key: "2",
+                      label: "咨询聊天",
+                      onClick: () => {
+                        setAssistantType("chat");
+                        addConversation();
+                      },
+                    },
+                  ],
+                }}
+              >
+                <Button type="text" className="w-80px">
+                  {assistantType === "generate" ? "生成实体表" : "咨询聊天"}
+                </Button>
+              </Dropdown>
+            }
             loading={loading}
             value={input}
             onChange={setInput}

+ 369 - 0
apps/er-designer/src/pages/er/components/AITable.tsx

@@ -0,0 +1,369 @@
+import {
+  useState,
+  useEffect,
+  useMemo,
+  forwardRef,
+  useImperativeHandle,
+  useRef,
+} from "react";
+import { Alert, Button, Col, Form, Input, message, Row } from "antd";
+import LangInput from "@/components/LangInput";
+import type { TableItemType, ColumnRelation, ColumnItem } from "@/type";
+import { EditableProTable, ProColumns } from "@ant-design/pro-components";
+import { DATA_TYPE_OPTIONS } from "@/constants";
+import {
+  validateColumnCode,
+  validateAliasName,
+  validateTableCode,
+} from "@/utils/validator";
+import LoadingDot from "@/components/LoadingDot";
+import { useModel } from "umi";
+
+const columns: ProColumns<ColumnItem>[] = [
+  {
+    title: "字段名称",
+    dataIndex: "langNameList",
+    width: 120,
+    renderFormItem: () => {
+      return <LangInput />;
+    },
+  },
+  {
+    title: "字段编码",
+    dataIndex: "schemaName",
+    valueType: "text",
+    width: 140,
+    formItemProps: {
+      rules: [
+        { required: true, message: "请输入字段编码" },
+        validateColumnCode,
+      ],
+    },
+  },
+  {
+    title: "字段类型",
+    dataIndex: "type",
+    valueType: "select",
+    fieldProps: {
+      options: DATA_TYPE_OPTIONS,
+    },
+    formItemProps: {
+      rules: [{ required: true, message: "请选择字段类型" }],
+    },
+  },
+];
+
+const TableItem = forwardRef(
+  (
+    {
+      data,
+      onChange,
+    }: {
+      data: TableItemType;
+      onChange: (data: TableItemType) => void;
+    },
+    ref
+  ) => {
+    const [form] = Form.useForm();
+    const [tableForm] = Form.useForm();
+    const [editableKeys, setEditableRowKeys] = useState<React.Key[]>(
+      data?.tableColumnList.map((item) => item.id)
+    );
+    const [selectedRowKeys, setSelectedKeys] = useState<React.Key[]>(
+      data?.tableColumnList.map((item) => item.id)
+    );
+    const [selectedRows, setSelectedRows] = useState<ColumnItem[]>(
+      data?.tableColumnList || []
+    );
+    const [dataSource, setDataSource] = useState<readonly ColumnItem[]>(
+      data?.tableColumnList || []
+    );
+
+    useImperativeHandle(ref, () => ({
+      // 校验表单与可编辑表格
+      validate: async () => {
+        await form.validateFields();
+        await tableForm.validateFields(
+          selectedRowKeys
+            .map((key) => [
+              [key, "schemaName"],
+              [key, "type"],
+            ])
+            .flat(1)
+        );
+      },
+    }));
+
+    const change = (tableColumnList: ColumnItem[]) => {
+      const values = form.getFieldsValue();
+      onChange({
+        isTable: true,
+        table: {
+          ...data?.table,
+          ...values,
+        },
+        tableColumnList,
+      });
+    };
+
+    useEffect(() => {
+      change(selectedRows);
+    }, [selectedRows]);
+
+    return (
+      <div className="rounded-12px overflow-hidden bg-white">
+        <div
+          className="w-full h-10px mb-4px"
+          style={{ background: data?.table?.style?.color || "#0076fc" }}
+        ></div>
+        <div className="px-12px mb-4px">
+          {
+            data?.table?.langNameList?.find((item) => item.name === "zh-CN")
+              ?.value
+          }
+        </div>
+        <Form className="px-12px" form={form} initialValues={data?.table}>
+          <Row gutter={8}>
+            <Col span={12}>
+              <Form.Item
+                name="schemaName"
+                label="表名"
+                rules={[
+                  { required: true, message: "请输入表名" },
+                  validateTableCode,
+                ]}
+              >
+                <Input placeholder="请输入" />
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item
+                name="aliasName"
+                label="别名"
+                rules={[
+                  { required: true, message: "请输入别名" },
+                  validateAliasName,
+                ]}
+              >
+                <Input placeholder="请输入" />
+              </Form.Item>
+            </Col>
+          </Row>
+        </Form>
+        <EditableProTable
+          columns={columns}
+          value={dataSource}
+          onChange={setDataSource}
+          pagination={false}
+          recordCreatorProps={false}
+          rowKey={"id"}
+          tableAlertRender={false}
+          rowSelection={{
+            type: "checkbox",
+            selectedRowKeys,
+            onChange: (keys, selectedRows) => {
+              setSelectedKeys(keys);
+              setSelectedRows(selectedRows);
+            },
+            alwaysShowAlert: false,
+          }}
+          editable={{
+            form: tableForm,
+            type: "multiple",
+            editableKeys,
+            onValuesChange: (_, recordList) => {
+              change(
+                recordList.filter((item) => selectedRowKeys.includes(item.id))
+              );
+              setDataSource(recordList);
+            },
+            onChange: setEditableRowKeys,
+          }}
+        />
+      </div>
+    );
+  }
+);
+
+export default function AITable(props: {
+  content: string;
+  onChange?: (data: any) => void;
+  onTry?: () => void;
+}) {
+  // AI生成数据
+  const [data, setData] = useState<{
+    tables: TableItemType[];
+    relations: ColumnRelation[];
+  }>();
+  const { onCreateByAi } = useModel("erModel");
+
+  // 记录已选数据
+  const [selectedData, setSelectedData] = useState<TableItemType[]>([]);
+
+  // 操作状态
+  const [status, setStatus] = useState<"done" | "try" | "abort">();
+
+  // 可编辑表格ref
+  const tableRef = useRef<Record<string, { validate: () => Promise<void> }>>(
+    {}
+  );
+
+  function regexExtractJSON(markdown: string) {
+    const jsonRegex = /```(?:json)?\n([\s\S]*?)\n```/g;
+    const matches = [];
+    let match;
+
+    while ((match = jsonRegex.exec(markdown)) !== null) {
+      try {
+        const jsonObj = JSON.parse(match[1]);
+        matches.push(jsonObj);
+      } catch (e) {
+        console.warn("无效JSON:", match[0]);
+      }
+    }
+    return matches;
+  }
+
+  const handleParseJsonByMd = (str: string) => {
+    // 根据markdown格式取出json部分数据
+    const md = str;
+    let json: unknown;
+    if (md.includes("```json")) {
+      json = regexExtractJSON(props.content)?.[0];
+      console.log("解析结果:", json);
+      return json;
+    }
+
+    return undefined;
+  };
+
+  useEffect(() => {
+    const data = handleParseJsonByMd(props.content);
+    setData(data as any);
+    setTimeout(() => {}, 300);
+  }, [props.content]);
+
+  const handleChange = (data: TableItemType) => {
+    if (selectedData.find((item) => item.table.id === data.table.id)) {
+    }
+    setSelectedData((prev) => {
+      const index = prev.findIndex((item) => item.table.id === data.table.id);
+      if (index > -1) {
+        return prev.map((item, i) => {
+          if (i === index) return data;
+          return item;
+        });
+      }
+
+      return [...prev, data];
+    });
+  };
+
+  const canOk = useMemo(() => {
+    let can = false;
+    selectedData.forEach((item) => {
+      if (item.tableColumnList.length) can = true;
+    });
+    return can;
+  }, [selectedData]);
+
+  const handleSubmit = (status: "done" | "try" | "abort") => {
+    switch (status) {
+      case "done":
+        // 校验数据
+        const result = selectedData.filter(
+          (item) => item.tableColumnList.length
+        );
+        Promise.all(
+          result.map((item) => tableRef.current[item.table.id].validate())
+        )
+          .then(() => {
+            // 勾选后 过滤失效的关联关系
+            const relations = (data?.relations || []).filter((relation) => {
+              return !!result.find((tableItem) =>
+                tableItem.tableColumnList.find(
+                  (columnItem) =>
+                    columnItem.id === relation.primaryKey ||
+                    columnItem.id === relation.foreignKey
+                )
+              );
+            });
+            onCreateByAi({
+              relations,
+              tables: result,
+            });
+            setStatus("done");
+            props.onChange?.(result);
+          })
+          .catch((err) => {
+            message.warning("请检查数据");
+            console.log("校验失败:", err);
+          });
+        break;
+      case "try":
+        setStatus("try");
+        props.onTry?.();
+        break;
+      case "abort":
+        setStatus("abort");
+        break;
+    }
+  };
+
+  return (
+    <div>
+      {data ? (
+        <>
+          <div className="mb-12px text-#333 font-bold">
+            以为你生成以下内容:
+          </div>
+          <div className="flex flex-col gap-12px">
+            {data?.tables?.map((item) => {
+              return (
+                <TableItem
+                  key={item.table.id}
+                  ref={(ref) =>
+                    ref && (tableRef.current[item.table.id] = ref as any)
+                  }
+                  data={item}
+                  onChange={handleChange}
+                />
+              );
+            })}
+          </div>
+          {!status && (
+            <div className="flex gap-12px mt-12px">
+              <Button disabled={!canOk} onClick={() => handleSubmit("done")}>
+                确认
+              </Button>
+              <Button onClick={() => handleSubmit("try")}>再试一次</Button>
+              <Button onClick={() => handleSubmit("abort")}>放弃</Button>
+            </div>
+          )}
+          {status === "done" && (
+            <Alert
+              className="mt-12px"
+              message="已确认生成"
+              type="success"
+              showIcon
+            />
+          )}
+          {status === "abort" && (
+            <Alert className="mt-12px" message="已放弃该方案" type="error" />
+          )}
+        </>
+      ) : (
+        <div className="flex">
+          {/\<generate>[\s\S]*?<\/generate>/.test(props.content) ? (
+            <span>易码工坊智能助手.</span>
+          ) : (
+            <>
+              <LoadingDot />
+              思考完成,正在生成数据...
+            </>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}

+ 53 - 6
apps/er-designer/src/pages/er/components/RelationPanel.tsx

@@ -10,7 +10,7 @@ import {
   Select,
   Tooltip,
 } from "antd";
-import React from "react";
+import React, { useEffect } from "react";
 import { RELATION_TYPE_OPTIONS } from "@/constants";
 import { useModel } from "umi";
 import noData from "@/assets/no-data.png";
@@ -80,6 +80,34 @@ export default function RelationPanel() {
     };
   };
 
+  const CustomInput = ({
+    value,
+    onChange,
+  }: {
+    value: string;
+    onChange: (value: string) => void;
+  }) => {
+    const [name, setName] = React.useState(value);
+    useEffect(() => {
+      setName(value);
+    }, [value]);
+    return (
+      <Input
+        value={name}
+        onChange={(e) => {
+          setName(e.target.value);
+        }}
+        onBlur={() => {
+          if (name.trim()) {
+            onChange(name);
+          } else {
+            setName(value);
+          }
+        }}
+      />
+    );
+  };
+
   return (
     <div className="px-12px">
       <Input
@@ -130,15 +158,25 @@ export default function RelationPanel() {
               }}
             >
               <Form layout="vertical" className="overflow-hidden">
+                <Form.Item label="名称" layout="horizontal">
+                  <CustomInput
+                    value={item.name}
+                    onChange={(val) => handleChange(index, "name", val)}
+                  />
+                </Form.Item>
                 <div className="flex justify-between gap-25px">
                   <Form.Item className="w-120px" label="主键">
                     <Tooltip title={getPrimaryColumn(item)?.table?.schemaName}>
-                      <div className="truncate">{getPrimaryColumn(item)?.table?.schemaName}</div>
+                      <div className="truncate">
+                        {getPrimaryColumn(item)?.table?.schemaName}
+                      </div>
                     </Tooltip>
                   </Form.Item>
                   <Form.Item className="w-120px" label="外键">
                     <Tooltip title={getForeignColumn(item)?.table?.schemaName}>
-                      <div className="truncate">{getForeignColumn(item)?.table?.schemaName}</div>
+                      <div className="truncate">
+                        {getForeignColumn(item)?.table?.schemaName}
+                      </div>
                     </Tooltip>
                   </Form.Item>
                   <Popover
@@ -193,9 +231,18 @@ export default function RelationPanel() {
                     <Select
                       placeholder="请选择"
                       options={[
-                        { value: RelationLineType.Dash, label: <img className="h-16px" src={line2}/> },
-                        { value: RelationLineType.Solid, label: <img className="h-16px" src={line3}/> },
-                        { value: RelationLineType.Dotted, label: <img  className="h-16px"src={line4}/> },
+                        {
+                          value: RelationLineType.Dash,
+                          label: <img className="h-16px" src={line2} />,
+                        },
+                        {
+                          value: RelationLineType.Solid,
+                          label: <img className="h-16px" src={line3} />,
+                        },
+                        {
+                          value: RelationLineType.Dotted,
+                          label: <img className="h-16px" src={line4} />,
+                        },
                       ]}
                       value={item.style?.lineType}
                       onChange={(val) =>

+ 45 - 48
apps/er-designer/src/pages/er/components/TableItem.tsx

@@ -1,7 +1,4 @@
-import {
-  DeleteOutlined,
-  PlusOutlined,
-} from "@ant-design/icons";
+import { DeleteOutlined, PlusOutlined } from "@ant-design/icons";
 import {
   Col,
   Row,
@@ -58,7 +55,7 @@ export default function TableItem({
     "er-hideDefaultColumn",
     {
       defaultValue: false,
-      listenStorageChange: true
+      listenStorageChange: true,
     }
   );
 
@@ -241,48 +238,48 @@ export default function TableItem({
                 onChange={(val) => handleTableChange("type", val)}
               />
             </Form.Item>
-            <Row gutter={8}>
-              <Col span={12}>
-                <Form.Item
-                  label="编码"
-                  name="schemaName"
-                  rules={[
-                    { required: true, message: "请输入编码" },
-                    validateTableCode,
-                  ]}
-                >
-                  <Tooltip title={table.schemaName}>
-                    <Input
-                      placeholder="请输入"
-                      value={table.schemaName}
-                      onChange={(e) =>
-                        handleTableChange("schemaName", e.target.value)
-                      }
-                    />
-                  </Tooltip>
-                </Form.Item>
-              </Col>
-              <Col span={12}>
-                <Form.Item
-                  label="别名"
-                  name="aliasName"
-                  rules={[
-                    { required: true, message: "请输入编码" },
-                    validateAliasName,
-                  ]}
-                >
-                  <Tooltip title={table.aliasName}>
-                    <Input
-                      placeholder="请输入"
-                      value={table.aliasName}
-                      onChange={(e) =>
-                        handleTableChange("aliasName", e.target.value)
-                      }
-                    />
-                  </Tooltip>
-                </Form.Item>
-              </Col>
-            </Row>
+
+            <Form.Item
+              label="编码"
+              name="schemaName"
+              labelCol={{ span: 4 }}
+              wrapperCol={{ span: 21 }}
+              rules={[
+                { required: true, message: "请输入编码" },
+                validateTableCode,
+              ]}
+            >
+              <Tooltip title={table.schemaName}>
+                <Input
+                  placeholder="请输入"
+                  value={table.schemaName}
+                  onChange={(e) =>
+                    handleTableChange("schemaName", e.target.value)
+                  }
+                />
+              </Tooltip>
+            </Form.Item>
+
+            <Form.Item
+              label="别名"
+              name="aliasName"
+              labelCol={{ span: 4 }}
+              wrapperCol={{ span: 21 }}
+              rules={[
+                { required: true, message: "请输入编码" },
+                validateAliasName,
+              ]}
+            >
+              <Tooltip title={table.aliasName}>
+                <Input
+                  placeholder="请输入"
+                  value={table.aliasName}
+                  onChange={(e) =>
+                    handleTableChange("aliasName", e.target.value)
+                  }
+                />
+              </Tooltip>
+            </Form.Item>
           </Form>
 
           <div className="flex justify-between m-b-10px">
@@ -309,7 +306,7 @@ export default function TableItem({
               )}
               <Button
                 type="primary"
-                 className="w-50px"
+                className="w-50px"
                 onClick={handleAddColumn}
               >
                 字段

+ 0 - 2
apps/er-designer/src/pages/er/components/Toolbar.tsx

@@ -19,7 +19,6 @@ export default function Toolbar() {
     project,
     setProject,
     onSave,
-    onCreateByAi,
   } = useModel("erModel");
   const todoRef = React.useRef<{ open: () => void }>();
   const [isFullscreen, { toggleFullscreen }] = useFullscreen(document.body);
@@ -330,7 +329,6 @@ export default function Toolbar() {
                     </svg>
                   </div>
                 }
-                onChange={onCreateByAi}
               />
             </Tooltip>
           </div>