diff --git a/app/components/chat.tsx b/app/components/chat.tsx
new file mode 100644
index 000000000..7aa8bfec6
--- /dev/null
+++ b/app/components/chat.tsx
@@ -0,0 +1,643 @@
+import { useDebouncedCallback } from "use-debounce";
+import { useState, useRef, useEffect, useLayoutEffect } from "react";
+
+import SendWhiteIcon from "../icons/send-white.svg";
+import BrainIcon from "../icons/brain.svg";
+import ExportIcon from "../icons/export.svg";
+import MenuIcon from "../icons/menu.svg";
+import CopyIcon from "../icons/copy.svg";
+import DownloadIcon from "../icons/download.svg";
+import LoadingIcon from "../icons/three-dots.svg";
+import BotIcon from "../icons/bot.svg";
+import AddIcon from "../icons/add.svg";
+import DeleteIcon from "../icons/delete.svg";
+
+import { Message, SubmitKey, useChatStore, BOT_HELLO, ROLES } from "../store";
+
+import {
+ copyToClipboard,
+ downloadAs,
+ isMobileScreen,
+ selectOrCopy,
+} from "../utils";
+
+import dynamic from "next/dynamic";
+
+import { ControllerPool } from "../requests";
+import { Prompt, usePromptStore } from "../store/prompt";
+import Locale from "../locales";
+
+import { IconButton } from "./button";
+import styles from "./home.module.scss";
+import chatStyle from "./chat.module.scss";
+
+import { Modal, showModal, showToast } from "./ui-lib";
+
+const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
+ loading: () => ,
+});
+
+const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, {
+ loading: () => ,
+});
+
+export function Avatar(props: { role: Message["role"] }) {
+ const config = useChatStore((state) => state.config);
+
+ if (props.role !== "user") {
+ return ;
+ }
+
+ return (
+
+
+
+ );
+}
+
+function exportMessages(messages: Message[], topic: string) {
+ const mdText =
+ `# ${topic}\n\n` +
+ messages
+ .map((m) => {
+ return m.role === "user" ? `## ${m.content}` : m.content.trim();
+ })
+ .join("\n\n");
+ const filename = `${topic}.md`;
+
+ showModal({
+ title: Locale.Export.Title,
+ children: (
+
+ ),
+ actions: [
+ }
+ bordered
+ text={Locale.Export.Copy}
+ onClick={() => copyToClipboard(mdText)}
+ />,
+ }
+ bordered
+ text={Locale.Export.Download}
+ onClick={() => downloadAs(mdText, filename)}
+ />,
+ ],
+ });
+}
+
+function PromptToast(props: {
+ showToast?: boolean;
+ showModal?: boolean;
+ setShowModal: (_: boolean) => void;
+}) {
+ const chatStore = useChatStore();
+ const session = chatStore.currentSession();
+ const context = session.context;
+
+ const addContextPrompt = (prompt: Message) => {
+ chatStore.updateCurrentSession((session) => {
+ session.context.push(prompt);
+ });
+ };
+
+ const removeContextPrompt = (i: number) => {
+ chatStore.updateCurrentSession((session) => {
+ session.context.splice(i, 1);
+ });
+ };
+
+ const updateContextPrompt = (i: number, prompt: Message) => {
+ chatStore.updateCurrentSession((session) => {
+ session.context[i] = prompt;
+ });
+ };
+
+ return (
+
+ {props.showToast && (
+
props.setShowModal(true)}
+ >
+
+
+ {Locale.Context.Toast(context.length)}
+
+
+ )}
+ {props.showModal && (
+
+
props.setShowModal(false)}
+ actions={[
+ }
+ bordered
+ text={Locale.Memory.Copy}
+ onClick={() => copyToClipboard(session.memoryPrompt)}
+ />,
+ ]}
+ >
+ <>
+ {" "}
+
+ {context.map((c, i) => (
+
+
+
+ updateContextPrompt(i, {
+ ...c,
+ content: e.target.value as any,
+ })
+ }
+ >
+ }
+ className={chatStyle["context-delete-button"]}
+ onClick={() => removeContextPrompt(i)}
+ bordered
+ />
+
+ ))}
+
+
+ }
+ text={Locale.Context.Add}
+ bordered
+ className={chatStyle["context-prompt-button"]}
+ onClick={() =>
+ addContextPrompt({
+ role: "system",
+ content: "",
+ date: "",
+ })
+ }
+ />
+
+
+
+
+ {Locale.Memory.Title} ({session.lastSummarizeIndex} of{" "}
+ {session.messages.length})
+
+
+ {session.memoryPrompt || Locale.Memory.EmptyContent}
+
+
+ >
+
+
+ )}
+
+ );
+}
+
+function useSubmitHandler() {
+ const config = useChatStore((state) => state.config);
+ const submitKey = config.submitKey;
+
+ const shouldSubmit = (e: React.KeyboardEvent) => {
+ if (e.key !== "Enter") return false;
+ if (e.key === "Enter" && e.nativeEvent.isComposing) return false;
+ return (
+ (config.submitKey === SubmitKey.AltEnter && e.altKey) ||
+ (config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) ||
+ (config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) ||
+ (config.submitKey === SubmitKey.MetaEnter && e.metaKey) ||
+ (config.submitKey === SubmitKey.Enter &&
+ !e.altKey &&
+ !e.ctrlKey &&
+ !e.shiftKey &&
+ !e.metaKey)
+ );
+ };
+
+ return {
+ submitKey,
+ shouldSubmit,
+ };
+}
+
+export function PromptHints(props: {
+ prompts: Prompt[];
+ onPromptSelect: (prompt: Prompt) => void;
+}) {
+ if (props.prompts.length === 0) return null;
+
+ return (
+
+ {props.prompts.map((prompt, i) => (
+
props.onPromptSelect(prompt)}
+ >
+
{prompt.title}
+
{prompt.content}
+
+ ))}
+
+ );
+}
+
+function useScrollToBottom() {
+ // for auto-scroll
+ const scrollRef = useRef(null);
+ const [autoScroll, setAutoScroll] = useState(true);
+
+ // auto scroll
+ useLayoutEffect(() => {
+ const dom = scrollRef.current;
+ if (dom && autoScroll) {
+ setTimeout(() => (dom.scrollTop = dom.scrollHeight), 1);
+ }
+ });
+
+ return {
+ scrollRef,
+ autoScroll,
+ setAutoScroll,
+ };
+}
+
+export function Chat(props: {
+ showSideBar?: () => void;
+ sideBarShowing?: boolean;
+}) {
+ type RenderMessage = Message & { preview?: boolean };
+
+ const chatStore = useChatStore();
+ const [session, sessionIndex] = useChatStore((state) => [
+ state.currentSession(),
+ state.currentSessionIndex,
+ ]);
+ const fontSize = useChatStore((state) => state.config.fontSize);
+
+ const inputRef = useRef(null);
+ const [userInput, setUserInput] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+ const { submitKey, shouldSubmit } = useSubmitHandler();
+ const { scrollRef, setAutoScroll } = useScrollToBottom();
+ const [hitBottom, setHitBottom] = useState(false);
+
+ const onChatBodyScroll = (e: HTMLElement) => {
+ const isTouchBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 20;
+ setHitBottom(isTouchBottom);
+ };
+
+ // prompt hints
+ const promptStore = usePromptStore();
+ const [promptHints, setPromptHints] = useState([]);
+ const onSearch = useDebouncedCallback(
+ (text: string) => {
+ setPromptHints(promptStore.search(text));
+ },
+ 100,
+ { leading: true, trailing: true },
+ );
+
+ const onPromptSelect = (prompt: Prompt) => {
+ setUserInput(prompt.content);
+ setPromptHints([]);
+ inputRef.current?.focus();
+ };
+
+ const scrollInput = () => {
+ const dom = inputRef.current;
+ if (!dom) return;
+ const paddingBottomNum: number = parseInt(
+ window.getComputedStyle(dom).paddingBottom,
+ 10,
+ );
+ dom.scrollTop = dom.scrollHeight - dom.offsetHeight + paddingBottomNum;
+ };
+
+ // only search prompts when user input is short
+ const SEARCH_TEXT_LIMIT = 30;
+ const onInput = (text: string) => {
+ scrollInput();
+ setUserInput(text);
+ const n = text.trim().length;
+
+ // clear search results
+ if (n === 0) {
+ setPromptHints([]);
+ } else if (!chatStore.config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
+ // check if need to trigger auto completion
+ if (text.startsWith("/")) {
+ let searchText = text.slice(1);
+ if (searchText.length === 0) {
+ searchText = " ";
+ }
+ onSearch(searchText);
+ }
+ }
+ };
+
+ // submit user input
+ const onUserSubmit = () => {
+ if (userInput.length <= 0) return;
+ setIsLoading(true);
+ chatStore.onUserInput(userInput).then(() => setIsLoading(false));
+ setUserInput("");
+ setPromptHints([]);
+ if (!isMobileScreen()) inputRef.current?.focus();
+ setAutoScroll(true);
+ };
+
+ // stop response
+ const onUserStop = (messageIndex: number) => {
+ ControllerPool.stop(sessionIndex, messageIndex);
+ };
+
+ // check if should send message
+ const onInputKeyDown = (e: React.KeyboardEvent) => {
+ if (shouldSubmit(e)) {
+ onUserSubmit();
+ e.preventDefault();
+ }
+ };
+ const onRightClick = (e: any, message: Message) => {
+ // auto fill user input
+ if (message.role === "user") {
+ setUserInput(message.content);
+ }
+
+ // copy to clipboard
+ if (selectOrCopy(e.currentTarget, message.content)) {
+ e.preventDefault();
+ }
+ };
+
+ const onResend = (botIndex: number) => {
+ // find last user input message and resend
+ for (let i = botIndex; i >= 0; i -= 1) {
+ if (messages[i].role === "user") {
+ setIsLoading(true);
+ chatStore
+ .onUserInput(messages[i].content)
+ .then(() => setIsLoading(false));
+ inputRef.current?.focus();
+ return;
+ }
+ }
+ };
+
+ const config = useChatStore((state) => state.config);
+
+ const context: RenderMessage[] = session.context.slice();
+
+ if (
+ context.length === 0 &&
+ session.messages.at(0)?.content !== BOT_HELLO.content
+ ) {
+ context.push(BOT_HELLO);
+ }
+
+ // preview messages
+ const messages = context
+ .concat(session.messages as RenderMessage[])
+ .concat(
+ isLoading
+ ? [
+ {
+ role: "assistant",
+ content: "……",
+ date: new Date().toLocaleString(),
+ preview: true,
+ },
+ ]
+ : [],
+ )
+ .concat(
+ userInput.length > 0 && config.sendPreviewBubble
+ ? [
+ {
+ role: "user",
+ content: userInput,
+ date: new Date().toLocaleString(),
+ preview: true,
+ },
+ ]
+ : [],
+ );
+
+ const [showPromptModal, setShowPromptModal] = useState(false);
+
+ // Auto focus
+ useEffect(() => {
+ if (props.sideBarShowing && isMobileScreen()) return;
+ inputRef.current?.focus();
+ }, []);
+
+ return (
+
+
+
+
{
+ const newTopic = prompt(Locale.Chat.Rename, session.topic);
+ if (newTopic && newTopic !== session.topic) {
+ chatStore.updateCurrentSession(
+ (session) => (session.topic = newTopic!),
+ );
+ }
+ }}
+ >
+ {session.topic}
+
+
+ {Locale.Chat.SubTitle(session.messages.length)}
+
+
+
+
+ }
+ bordered
+ title={Locale.Chat.Actions.ChatList}
+ onClick={props?.showSideBar}
+ />
+
+
+ }
+ bordered
+ title={Locale.Chat.Actions.CompressedHistory}
+ onClick={() => {
+ setShowPromptModal(true);
+ }}
+ />
+
+
+ }
+ bordered
+ title={Locale.Chat.Actions.Export}
+ onClick={() => {
+ exportMessages(
+ session.messages.filter((msg) => !msg.isError),
+ session.topic,
+ );
+ }}
+ />
+
+
+
+
+
+
+
onChatBodyScroll(e.currentTarget)}
+ onWheel={(e) => setAutoScroll(hitBottom && e.deltaY > 0)}
+ onTouchStart={() => {
+ inputRef.current?.blur();
+ setAutoScroll(false);
+ }}
+ >
+ {messages.map((message, i) => {
+ const isUser = message.role === "user";
+
+ return (
+
+
+
+ {(message.preview || message.streaming) && (
+
+ {Locale.Chat.Typing}
+
+ )}
+
+ {!isUser &&
+ !(message.preview || message.content.length === 0) && (
+
+ {message.streaming ? (
+
onUserStop(i)}
+ >
+ {Locale.Chat.Actions.Stop}
+
+ ) : (
+
onResend(i)}
+ >
+ {Locale.Chat.Actions.Retry}
+
+ )}
+
+
copyToClipboard(message.content)}
+ >
+ {Locale.Chat.Actions.Copy}
+
+
+ )}
+ {(message.preview || message.content.length === 0) &&
+ !isUser ? (
+
+ ) : (
+
onRightClick(e, message)}
+ onDoubleClickCapture={() => {
+ if (!isMobileScreen()) return;
+ setUserInput(message.content);
+ }}
+ >
+
+
+ )}
+
+ {!isUser && !message.preview && (
+
+
+ {message.date.toLocaleString()}
+
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+ );
+}