mirror of
https://github.com/sqlchat/sqlchat.git
synced 2025-07-29 18:23:25 +08:00
feat: add message request loader
This commit is contained in:
@ -2,7 +2,12 @@ import { useEffect } from "react";
|
|||||||
import { useChatStore } from "@/store";
|
import { useChatStore } from "@/store";
|
||||||
import Icon from "../Icon";
|
import Icon from "../Icon";
|
||||||
|
|
||||||
const Header = () => {
|
interface Props {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Header = (props: Props) => {
|
||||||
|
const { className } = props;
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const currentChat = chatStore.currentChat;
|
const currentChat = chatStore.currentChat;
|
||||||
const title = currentChat?.title || "SQL Chat";
|
const title = currentChat?.title || "SQL Chat";
|
||||||
@ -12,7 +17,11 @@ const Header = () => {
|
|||||||
}, [title]);
|
}, [title]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sticky top-0 w-full flex flex-row justify-between items-center lg:grid lg:grid-cols-3 py-2 border-b bg-white z-1">
|
<div
|
||||||
|
className={`${
|
||||||
|
className || ""
|
||||||
|
} sticky top-0 w-full flex flex-row justify-between items-center lg:grid lg:grid-cols-3 py-2 border-b bg-white z-1`}
|
||||||
|
>
|
||||||
<div className="ml-2 flex justify-center items-center">
|
<div className="ml-2 flex justify-center items-center">
|
||||||
<label htmlFor="connection-drawer" className="w-8 h-8 p-1 mr-1 block lg:hidden rounded-md cursor-pointer hover:bg-gray-100">
|
<label htmlFor="connection-drawer" className="w-8 h-8 p-1 mr-1 block lg:hidden rounded-md cursor-pointer hover:bg-gray-100">
|
||||||
<Icon.IoIosMenu className="text-gray-600 w-full h-auto" />
|
<Icon.IoIosMenu className="text-gray-600 w-full h-auto" />
|
||||||
|
@ -56,6 +56,7 @@ const MessageTextarea = (props: Props) => {
|
|||||||
creatorRole: CreatorRole.User,
|
creatorRole: CreatorRole.User,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
content: value,
|
content: value,
|
||||||
|
isGenerated: true,
|
||||||
});
|
});
|
||||||
setValue("");
|
setValue("");
|
||||||
textareaRef.current!.value = "";
|
textareaRef.current!.value = "";
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { ReactElement } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { useUserStore } from "@/store";
|
import { useUserStore } from "@/store";
|
||||||
@ -39,23 +40,20 @@ const MessageView = (props: Props) => {
|
|||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
components={{
|
components={{
|
||||||
pre({ node, className, children, ...props }) {
|
pre({ node, className, children, ...props }) {
|
||||||
|
const child = children[0] as ReactElement;
|
||||||
|
const match = /language-(\w+)/.exec(child.props.className || "");
|
||||||
|
const language = match ? match[1] : "plain";
|
||||||
return (
|
return (
|
||||||
<pre className={`${className || ""} p-0 w-full`} {...props}>
|
<pre className={`${className || ""} p-0 w-full`} {...props}>
|
||||||
{children}
|
<CodeBlock
|
||||||
|
key={Math.random()}
|
||||||
|
language={language || "plain"}
|
||||||
|
value={String(child.props.children).replace(/\n$/, "")}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
</pre>
|
</pre>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
code({ node, inline, className, children, ...props }) {
|
|
||||||
const match = /language-(\w+)/.exec(className || "");
|
|
||||||
const language = match ? match[1] : "plain";
|
|
||||||
return !inline ? (
|
|
||||||
<CodeBlock key={Math.random()} language={language || "plain"} value={String(children).replace(/\n$/, "")} {...props} />
|
|
||||||
) : (
|
|
||||||
<code className={className} {...props}>
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{message.content}
|
{message.content}
|
||||||
|
@ -1,23 +1,37 @@
|
|||||||
import { head } from "lodash-es";
|
import { head, last } from "lodash-es";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { getAssistantById, getPromptGeneratorOfAssistant, useChatStore, useMessageStore, useConnectionStore } from "@/store";
|
import { getAssistantById, getPromptGeneratorOfAssistant, useChatStore, useMessageStore, useConnectionStore } from "@/store";
|
||||||
import { CreatorRole, Message } from "@/types";
|
import { CreatorRole, Message } from "@/types";
|
||||||
import { generateUUID } from "@/utils";
|
import { generateUUID } from "@/utils";
|
||||||
import Icon from "../Icon";
|
|
||||||
import Header from "./Header";
|
import Header from "./Header";
|
||||||
import EmptyView from "../EmptyView";
|
import EmptyView from "../EmptyView";
|
||||||
import MessageView from "./MessageView";
|
import MessageView from "./MessageView";
|
||||||
import MessageTextarea from "./MessageTextarea";
|
import MessageTextarea from "./MessageTextarea";
|
||||||
|
import MessageLoader from "../MessageLoader";
|
||||||
|
|
||||||
const ChatView = () => {
|
const ChatView = () => {
|
||||||
const connectionStore = useConnectionStore();
|
const connectionStore = useConnectionStore();
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const messageStore = useMessageStore();
|
const messageStore = useMessageStore();
|
||||||
const [isRequesting, setIsRequesting] = useState<boolean>(false);
|
const [isRequesting, setIsRequesting] = useState<boolean>(false);
|
||||||
|
const [showHeaderShadow, setShowHeaderShadow] = useState<boolean>(false);
|
||||||
const chatViewRef = useRef<HTMLDivElement>(null);
|
const chatViewRef = useRef<HTMLDivElement>(null);
|
||||||
const currentChat = chatStore.currentChat;
|
const currentChat = chatStore.currentChat;
|
||||||
const messageList = messageStore.messageList.filter((message) => message.chatId === currentChat?.id);
|
const messageList = messageStore.messageList.filter((message) => message.chatId === currentChat?.id);
|
||||||
|
const lastMessage = last(messageList);
|
||||||
|
|
||||||
|
// Toggle header shadow.
|
||||||
|
useEffect(() => {
|
||||||
|
const handleChatViewScroll = () => {
|
||||||
|
setShowHeaderShadow((chatViewRef.current?.scrollTop || 0) > 0);
|
||||||
|
};
|
||||||
|
chatViewRef.current?.addEventListener("scroll", handleChatViewScroll);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
chatViewRef.current?.removeEventListener("scroll", handleChatViewScroll);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -26,7 +40,22 @@ const ChatView = () => {
|
|||||||
}
|
}
|
||||||
chatViewRef.current.scrollTop = chatViewRef.current.scrollHeight;
|
chatViewRef.current.scrollTop = chatViewRef.current.scrollHeight;
|
||||||
});
|
});
|
||||||
}, [currentChat, isRequesting]);
|
}, [currentChat, messageList.length, lastMessage?.isGenerated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!chatViewRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!lastMessage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastMessage.isGenerated) {
|
||||||
|
chatViewRef.current.scrollTop = chatViewRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [lastMessage?.isGenerated, lastMessage?.content]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!connectionStore.currentConnectionCtx) {
|
if (!connectionStore.currentConnectionCtx) {
|
||||||
@ -91,6 +120,7 @@ const ChatView = () => {
|
|||||||
creatorRole: CreatorRole.Assistant,
|
creatorRole: CreatorRole.Assistant,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
content: "",
|
content: "",
|
||||||
|
isGenerated: false,
|
||||||
};
|
};
|
||||||
messageStore.addMessage(message);
|
messageStore.addMessage(message);
|
||||||
|
|
||||||
@ -103,11 +133,16 @@ const ChatView = () => {
|
|||||||
const char = decoder.decode(value);
|
const char = decoder.decode(value);
|
||||||
if (char) {
|
if (char) {
|
||||||
message.content = message.content + char;
|
message.content = message.content + char;
|
||||||
messageStore.updateMessageContent(message.id, message.content);
|
messageStore.updateMessage(message.id, {
|
||||||
|
content: message.content,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
done = readerDone;
|
done = readerDone;
|
||||||
}
|
}
|
||||||
|
messageStore.updateMessage(message.id, {
|
||||||
|
isGenerated: true,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -115,18 +150,14 @@ const ChatView = () => {
|
|||||||
ref={chatViewRef}
|
ref={chatViewRef}
|
||||||
className="drawer-content relative w-full h-full max-h-full flex flex-col justify-start items-start overflow-y-auto bg-white"
|
className="drawer-content relative w-full h-full max-h-full flex flex-col justify-start items-start overflow-y-auto bg-white"
|
||||||
>
|
>
|
||||||
<Header />
|
<Header className={showHeaderShadow ? "shadow" : ""} />
|
||||||
<div className="p-2 w-full h-auto grow max-w-3xl py-1 px-4 sm:px-8 mx-auto">
|
<div className="p-2 w-full h-auto grow max-w-3xl py-1 px-4 sm:px-8 mx-auto">
|
||||||
{messageList.length === 0 ? (
|
{messageList.length === 0 ? (
|
||||||
<EmptyView className="mt-16" />
|
<EmptyView className="mt-16" sendMessage={sendMessageToCurrentChat} />
|
||||||
) : (
|
) : (
|
||||||
messageList.map((message) => <MessageView key={message.id} message={message} />)
|
messageList.map((message) => <MessageView key={message.id} message={message} />)
|
||||||
)}
|
)}
|
||||||
{isRequesting && (
|
{isRequesting && <MessageLoader />}
|
||||||
<div className="w-full pt-4 pb-8 flex justify-center items-center text-gray-600">
|
|
||||||
<Icon.BiLoader className="w-5 h-auto mr-2 animate-spin" /> Requesting...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="sticky bottom-0 w-full max-w-3xl py-2 px-4 sm:px-8 mx-auto bg-white bg-opacity-80 backdrop-blur">
|
<div className="sticky bottom-0 w-full max-w-3xl py-2 px-4 sm:px-8 mx-auto bg-white bg-opacity-80 backdrop-blur">
|
||||||
<MessageTextarea disabled={isRequesting} sendMessage={sendMessageToCurrentChat} />
|
<MessageTextarea disabled={isRequesting} sendMessage={sendMessageToCurrentChat} />
|
||||||
|
@ -1,11 +1,45 @@
|
|||||||
|
import { useChatStore, useConnectionStore, useMessageStore, useUserStore } from "@/store";
|
||||||
|
import { CreatorRole } from "@/types";
|
||||||
|
import { generateUUID } from "@/utils";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
// examples are used to show some examples to the user.
|
||||||
|
const examples = ["Give me an example schema about employee", "How to create a view in MySQL?"];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
sendMessage: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EmptyView = (props: Props) => {
|
const EmptyView = (props: Props) => {
|
||||||
const { className } = props;
|
const { className, sendMessage } = props;
|
||||||
|
const connectionStore = useConnectionStore();
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const messageStore = useMessageStore();
|
||||||
|
|
||||||
|
const handleExampleClick = async (content: string) => {
|
||||||
|
let chat = chatStore.currentChat;
|
||||||
|
if (!chat) {
|
||||||
|
const currentConnectionCtx = connectionStore.currentConnectionCtx;
|
||||||
|
if (!currentConnectionCtx) {
|
||||||
|
chat = chatStore.createChat();
|
||||||
|
} else {
|
||||||
|
chat = chatStore.createChat(currentConnectionCtx.connection.id, currentConnectionCtx.database?.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messageStore.addMessage({
|
||||||
|
id: generateUUID(),
|
||||||
|
chatId: chat.id,
|
||||||
|
creatorId: userStore.currentUser.id,
|
||||||
|
creatorRole: CreatorRole.User,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
content: content,
|
||||||
|
isGenerated: true,
|
||||||
|
});
|
||||||
|
await sendMessage();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${className || ""} w-full h-full flex flex-col justify-start items-center`}>
|
<div className={`${className || ""} w-full h-full flex flex-col justify-start items-center`}>
|
||||||
@ -14,8 +48,15 @@ const EmptyView = (props: Props) => {
|
|||||||
<div className="w-full flex flex-col justify-start items-center">
|
<div className="w-full flex flex-col justify-start items-center">
|
||||||
<Icon.BsSun className="w-8 h-auto opacity-80" />
|
<Icon.BsSun className="w-8 h-auto opacity-80" />
|
||||||
<span className="mt-2 mb-4">Examples</span>
|
<span className="mt-2 mb-4">Examples</span>
|
||||||
<div className="w-full bg-gray-50 rounded-lg px-4 py-3 text-sm mb-4">This is the latest placeholder</div>
|
{examples.map((example) => (
|
||||||
<div className="w-full bg-gray-50 rounded-lg px-4 py-3 text-sm mb-4">Another example</div>
|
<div
|
||||||
|
key={example}
|
||||||
|
className="w-full rounded-lg px-4 py-3 text-sm mb-4 leading-6 cursor-pointer bg-gray-50 hover:bg-gray-100"
|
||||||
|
onClick={() => handleExampleClick(example)}
|
||||||
|
>
|
||||||
|
{`"${example}"`} →
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-col justify-start items-center">
|
<div className="w-full flex flex-col justify-start items-center">
|
||||||
<Icon.BsLightning className="w-8 h-auto opacity-80" />
|
<Icon.BsLightning className="w-8 h-auto opacity-80" />
|
||||||
|
27
components/MessageLoader.tsx
Normal file
27
components/MessageLoader.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
const MessageLoader = () => {
|
||||||
|
const [loader, setLoader] = useState<string>(".");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (loader.length === 3) {
|
||||||
|
setLoader(".");
|
||||||
|
} else {
|
||||||
|
setLoader(loader + ".");
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}, [loader]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`w-full max-w-full flex flex-row justify-start items-start my-4 pr-8 sm:pr-24`}>
|
||||||
|
<div className="w-10 h-10 p-1 border rounded-full flex justify-center items-center mr-2 shrink-0">
|
||||||
|
<Icon.AiOutlineRobot className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 w-12 bg-gray-100 px-4 py-2 rounded-lg rounded-tl-none shadow">{loader}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MessageLoader;
|
@ -7,7 +7,7 @@ interface MessageState {
|
|||||||
messageList: Message[];
|
messageList: Message[];
|
||||||
getState: () => MessageState;
|
getState: () => MessageState;
|
||||||
addMessage: (message: Message) => void;
|
addMessage: (message: Message) => void;
|
||||||
updateMessageContent: (messageId: Id, content: string) => void;
|
updateMessage: (messageId: Id, message: Partial<Message>) => void;
|
||||||
clearMessage: (filter: (message: Message) => boolean) => void;
|
clearMessage: (filter: (message: Message) => boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -17,13 +17,23 @@ export const useMessageStore = create<MessageState>()(
|
|||||||
messageList: [],
|
messageList: [],
|
||||||
getState: () => get(),
|
getState: () => get(),
|
||||||
addMessage: (message: Message) => set((state) => ({ messageList: [...state.messageList, message] })),
|
addMessage: (message: Message) => set((state) => ({ messageList: [...state.messageList, message] })),
|
||||||
updateMessageContent: (messageId: Id, content: string) => {
|
updateMessage: (messageId: Id, message: Partial<Message>) => {
|
||||||
const message = get().messageList.find((message) => message.id === messageId);
|
const rawMessage = get().messageList.find((message) => message.id === messageId);
|
||||||
if (!message) {
|
if (!rawMessage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
message.content = content;
|
set((state) => ({
|
||||||
set((state) => ({ messageList: uniqBy([...state.messageList, message], (message) => message.id) }));
|
messageList: uniqBy(
|
||||||
|
[
|
||||||
|
...state.messageList,
|
||||||
|
{
|
||||||
|
...rawMessage,
|
||||||
|
...message,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
(message) => message.id
|
||||||
|
),
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
clearMessage: (filter: (message: Message) => boolean) => set((state) => ({ messageList: state.messageList.filter(filter) })),
|
clearMessage: (filter: (message: Message) => boolean) => set((state) => ({ messageList: state.messageList.filter(filter) })),
|
||||||
}),
|
}),
|
||||||
|
@ -13,4 +13,5 @@ export interface Message {
|
|||||||
creatorRole: CreatorRole;
|
creatorRole: CreatorRole;
|
||||||
createdAt: Timestamp;
|
createdAt: Timestamp;
|
||||||
content: string;
|
content: string;
|
||||||
|
isGenerated: boolean;
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user