diff --git a/app/components/button.module.scss b/app/components/button.module.scss index b882a0c1..88da9748 100644 --- a/app/components/button.module.scss +++ b/app/components/button.module.scss @@ -6,19 +6,21 @@ justify-content: center; padding: 10px; - box-shadow: var(--card-shadow); cursor: pointer; transition: all 0.3s ease; overflow: hidden; user-select: none; } +.shadow { + box-shadow: var(--card-shadow); +} + .border { border: var(--border-in-light); } .icon-button:hover { - filter: brightness(0.9); border-color: var(--primary); } @@ -36,25 +38,7 @@ } } -@mixin dark-button { - div:not(:global(.no-dark))>.icon-button-icon { - filter: invert(0.5); - } - - .icon-button:hover { - filter: brightness(1.2); - } -} - -:global(.dark) { - @include dark-button; -} - -@media (prefers-color-scheme: dark) { - @include dark-button; -} - .icon-button-text { margin-left: 5px; font-size: 12px; -} \ No newline at end of file +} diff --git a/app/components/button.tsx b/app/components/button.tsx index 43b699b6..f40a4e8f 100644 --- a/app/components/button.tsx +++ b/app/components/button.tsx @@ -7,6 +7,7 @@ export function IconButton(props: { icon: JSX.Element; text?: string; bordered?: boolean; + shadow?: boolean; className?: string; title?: string; }) { @@ -14,10 +15,13 @@ export function IconButton(props: {
{props.icon}
{props.text && ( diff --git a/app/components/chat-list.tsx b/app/components/chat-list.tsx new file mode 100644 index 00000000..8ad2b7dc --- /dev/null +++ b/app/components/chat-list.tsx @@ -0,0 +1,73 @@ +import { useState, useRef, useEffect, useLayoutEffect } from "react"; +import DeleteIcon from "../icons/delete.svg"; +import styles from "./home.module.scss"; + +import { + Message, + SubmitKey, + useChatStore, + ChatSession, + BOT_HELLO, +} from "../store"; + +import Locale from "../locales"; +import { isMobileScreen } from "../utils"; + +export function ChatItem(props: { + onClick?: () => void; + onDelete?: () => void; + title: string; + count: number; + time: string; + selected: boolean; +}) { + return ( +
+
{props.title}
+
+
+ {Locale.ChatItem.ChatItemCount(props.count)} +
+
{props.time}
+
+
+ +
+
+ ); +} + +export function ChatList() { + const [sessions, selectedIndex, selectSession, removeSession] = useChatStore( + (state) => [ + state.sessions, + state.currentSessionIndex, + state.selectSession, + state.removeSession, + ], + ); + + return ( +
+ {sessions.map((item, i) => ( + selectSession(i)} + onDelete={() => + (!isMobileScreen() || confirm(Locale.Home.DeleteChat)) && + removeSession(i) + } + /> + ))} +
+ ); +} diff --git a/app/components/chat.module.scss b/app/components/chat.module.scss new file mode 100644 index 00000000..b52baa12 --- /dev/null +++ b/app/components/chat.module.scss @@ -0,0 +1,71 @@ +.prompt-toast { + position: absolute; + bottom: -50px; + z-index: 999; + display: flex; + justify-content: center; + width: calc(100% - 40px); + + .prompt-toast-inner { + display: flex; + justify-content: center; + align-items: center; + font-size: 12px; + background-color: var(--white); + color: var(--black); + + border: var(--border-in-light); + box-shadow: var(--card-shadow); + padding: 10px 20px; + border-radius: 100px; + + .prompt-toast-content { + margin-left: 10px; + } + } +} + +.context-prompt { + .context-prompt-row { + display: flex; + justify-content: center; + width: 100%; + margin-bottom: 10px; + + .context-role { + margin-right: 10px; + } + + .context-content { + flex: 1; + max-width: 100%; + text-align: left; + } + + .context-delete-button { + margin-left: 10px; + } + } + + .context-prompt-button { + flex: 1; + } +} + +.memory-prompt { + margin-top: 20px; + + .memory-prompt-title { + font-size: 12px; + font-weight: bold; + margin-bottom: 10px; + } + + .memory-prompt-content { + background-color: var(--gray); + border-radius: 6px; + padding: 10px; + font-size: 12px; + user-select: text; + } +} diff --git a/app/components/chat.tsx b/app/components/chat.tsx new file mode 100644 index 00000000..4a80fe14 --- /dev/null +++ b/app/components/chat.tsx @@ -0,0 +1,622 @@ +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, + ChatSession, + 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 === "assistant") { + 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: ( +
+
{mdText}
+
+ ), + actions: [ + } + bordered + text={Locale.Export.Copy} + onClick={() => copyToClipboard(mdText)} + />, + } + bordered + text={Locale.Export.Download} + onClick={() => downloadAs(mdText, filename)} + />, + ], + }); +} + +function PromptToast(props: { + 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.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)} + /> +
+ ))} + +
+ } + 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), 500); + } + }); + + 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(); + + // 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([]); + inputRef.current?.focus(); + }; + + // 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: false, + }, + ] + : [], + ); + + const [showPromptModal, setShowPromptModal] = useState(false); + + 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, session.topic); + }} + /> +
+
+ + +
+ +
+ {messages.map((message, i) => { + const isUser = message.role === "user"; + + return ( +
+
+
+ +
+ {(message.preview || message.streaming) && ( +
+ {Locale.Chat.Typing} +
+ )} +
inputRef.current?.blur()} + > + {!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()} +
+
+ )} +
+
+ ); + })} +
+ +
+ +
+