import { useDebounce, useDebouncedCallback } from "use-debounce";
import { memo, useState, useRef, useEffect, useLayoutEffect } from "react";
import SendWhiteIcon from "../icons/send-white.svg";
import BrainIcon from "../icons/brain.svg";
import ExportIcon from "../icons/share.svg";
import ReturnIcon from "../icons/return.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 MaxIcon from "../icons/max.svg";
import MinIcon from "../icons/min.svg";
import LightIcon from "../icons/light.svg";
import DarkIcon from "../icons/dark.svg";
import AutoIcon from "../icons/auto.svg";
import BottomIcon from "../icons/bottom.svg";
import {
Message,
SubmitKey,
useChatStore,
BOT_HELLO,
ROLES,
createMessage,
useAccessStore,
Theme,
} from "../store";
import {
copyToClipboard,
downloadAs,
getEmojiUrl,
isMobileScreen,
selectOrCopy,
autoGrowTextArea,
getCSSVar,
} 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 { Input, Modal, showModal } from "./ui-lib";
const Markdown = dynamic(
async () => memo((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"
? `## ${Locale.Export.MessageFromYou}:\n${m.content}`
: `## ${Locale.Export.MessageFromChatGPT}:\n${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.Reset}
onClick={() =>
confirm(Locale.Memory.ResetConfirm) &&
chatStore.resetSession()
}
/>,
}
bordered
text={Locale.Memory.Copy}
onClick={() => copyToClipboard(session.memoryPrompt)}
/>,
]}
>
<>
{context.map((c, i) => (
updateContextPrompt(i, {
...c,
content: e.currentTarget.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: "",
})
}
/>
>
)}
);
}
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);
const scrollToBottom = () => {
const dom = scrollRef.current;
if (dom) {
setTimeout(() => (dom.scrollTop = dom.scrollHeight), 1);
}
};
// auto scroll
useLayoutEffect(() => {
autoScroll && scrollToBottom();
});
return {
scrollRef,
autoScroll,
setAutoScroll,
scrollToBottom,
};
}
export function ChatActions(props: {
showPromptModal: () => void;
scrollToBottom: () => void;
hitBottom: boolean;
}) {
const chatStore = useChatStore();
const theme = chatStore.config.theme;
function nextTheme() {
const themes = [Theme.Auto, Theme.Light, Theme.Dark];
const themeIndex = themes.indexOf(theme);
const nextIndex = (themeIndex + 1) % themes.length;
const nextTheme = themes[nextIndex];
chatStore.updateConfig((config) => (config.theme = nextTheme));
}
return (
{!props.hitBottom && (
)}
{props.hitBottom && (
)}
{theme === Theme.Auto ? (
) : theme === Theme.Light ? (
) : theme === Theme.Dark ? (
) : null}
);
}
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 [beforeInput, setBeforeInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { submitKey, shouldSubmit } = useSubmitHandler();
const { scrollRef, setAutoScroll, scrollToBottom } = 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();
};
// auto grow input
const [inputRows, setInputRows] = useState(2);
const measure = useDebouncedCallback(
() => {
const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1;
const inputRows = Math.min(
5,
Math.max(2 + Number(!isMobileScreen()), rows),
);
setInputRows(inputRows);
},
100,
{
leading: true,
trailing: true,
},
);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(measure, [userInput]);
// only search prompts when user input is short
const SEARCH_TEXT_LIMIT = 30;
const onInput = (text: string) => {
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);
onSearch(searchText);
}
}
};
// submit user input
const onUserSubmit = () => {
if (userInput.length <= 0) return;
setIsLoading(true);
chatStore.onUserInput(userInput).then(() => setIsLoading(false));
setBeforeInput(userInput);
setUserInput("");
setPromptHints([]);
if (!isMobileScreen()) inputRef.current?.focus();
setAutoScroll(true);
};
// stop response
const onUserStop = (messageId: number) => {
ControllerPool.stop(sessionIndex, messageId);
};
// check if should send message
const onInputKeyDown = (e: React.KeyboardEvent) => {
// if ArrowUp and no userInput
if (e.key === "ArrowUp" && userInput.length <= 0) {
setUserInput(beforeInput);
e.preventDefault();
return;
}
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));
chatStore.updateCurrentSession((session) =>
session.messages.splice(i, 2),
);
inputRef.current?.focus();
return;
}
}
};
const config = useChatStore((state) => state.config);
const context: RenderMessage[] = session.context.slice();
const accessStore = useAccessStore();
if (
context.length === 0 &&
session.messages.at(0)?.content !== BOT_HELLO.content
) {
const copiedHello = Object.assign({}, BOT_HELLO);
if (!accessStore.isAuthorized()) {
copiedHello.content = Locale.Error.Unauthorized;
}
context.push(copiedHello);
}
// preview messages
const messages = context
.concat(session.messages as RenderMessage[])
.concat(
isLoading
? [
{
...createMessage({
role: "assistant",
content: "……",
}),
preview: true,
},
]
: [],
)
.concat(
userInput.length > 0 && config.sendPreviewBubble
? [
{
...createMessage({
role: "user",
content: userInput,
}),
preview: true,
},
]
: [],
);
const [showPromptModal, setShowPromptModal] = useState(false);
// Auto focus
useEffect(() => {
if (props.sideBarShowing && isMobileScreen()) return;
inputRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
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,
);
}}
/>
{!isMobileScreen() && (
: }
bordered
onClick={() => {
chatStore.updateConfig(
(config) => (config.tightBorder = !config.tightBorder),
);
}}
/>
)}
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(message.id ?? i)}
>
{Locale.Chat.Actions.Stop}
) : (
onResend(i)}
>
{Locale.Chat.Actions.Retry}
)}
copyToClipboard(message.content)}
>
{Locale.Chat.Actions.Copy}
)}
onRightClick(e, message)}
onDoubleClickCapture={() => {
if (!isMobileScreen()) return;
setUserInput(message.content);
}}
fontSize={fontSize}
parentRef={scrollRef}
/>
{!isUser && !message.preview && (
{message.date.toLocaleString()}
)}
);
})}
setShowPromptModal(true)}
scrollToBottom={scrollToBottom}
hitBottom={hitBottom}
/>
);
}