From 0a8e5d6734b65ea1967587eb5be436e489724ae7 Mon Sep 17 00:00:00 2001 From: butterfly Date: Fri, 12 Apr 2024 10:57:57 +0800 Subject: [PATCH] feat: seperate chat page --- .babelrc | 2 + .eslintignore | 4 +- .eslintrc.json | 10 +- .prettierrc.js | 2 + app/components/Search/index.module.scss | 24 + app/components/Search/index.tsx | 30 + app/components/TabActions/index.tsx | 70 ++ app/components/home.tsx | 34 +- app/containers/Chat/ChatAction.tsx | 52 ++ app/containers/Chat/ChatActions.tsx | 190 ++++++ app/containers/Chat/ChatHeader.tsx | 76 +++ app/containers/Chat/ChatInputPanel.tsx | 308 +++++++++ app/containers/Chat/ChatMessagePanel.tsx | 404 +++++++++++ app/containers/Chat/ClearContextDivider.tsx | 24 + app/containers/Chat/EditMessageModal.tsx | 72 ++ app/containers/Chat/PromptHint.tsx | 77 +++ app/containers/Chat/PromptToast.tsx | 38 ++ app/containers/Chat/SessionConfigModal.tsx | 75 +++ app/containers/Chat/index.module.scss | 627 ++++++++++++++++++ app/containers/Chat/index.tsx | 297 +++++++++ app/containers/Sidebar/SessionList.tsx | 214 ++++++ app/containers/Sidebar/SettingList.tsx | 5 + app/containers/Sidebar/index.tsx | 148 +++++ app/containers/Sidebar/types.ts | 4 + .../discoverAssistant/index.module.scss | 6 + app/containers/discoverAssistant/index.tsx | 38 ++ app/hooks/useDragSideBar.ts | 83 +++ app/hooks/useHotKey.ts | 21 + app/hooks/useMobileScreen.ts | 12 + app/hooks/usePaste.ts | 72 ++ app/hooks/useScrollToBottom.ts | 33 + app/hooks/useSubmitHandler.ts | 49 ++ app/hooks/useUploadImage.ts | 69 ++ app/icons/addIcon.svg | 3 + app/icons/assistantActive.svg | 9 + app/icons/assistantInactive.svg | 3 + app/icons/background.svg | 36 + app/icons/deleteIcon.svg | 4 + app/icons/discoverActive.svg | 9 + app/icons/discoverInactive.svg | 3 + app/icons/githubIcon.svg | 3 + app/icons/logIcon.svg | 61 ++ app/icons/nextchatTitle.svg | 9 + app/icons/search.svg | 4 + app/icons/settingActive.svg | 9 + app/icons/settingInactive.svg | 3 + app/layout.tsx | 4 +- app/locales/cn.ts | 3 + app/locales/en.ts | 4 + app/styles/globals.css | 5 + app/utils.ts | 10 + next.config.mjs | 4 +- package.json | 5 + postcss.config.js | 6 + tailwind.config.js | 40 ++ yarn.lock | 486 +++++++++++++- 56 files changed, 3868 insertions(+), 25 deletions(-) create mode 100644 app/components/Search/index.module.scss create mode 100644 app/components/Search/index.tsx create mode 100644 app/components/TabActions/index.tsx create mode 100644 app/containers/Chat/ChatAction.tsx create mode 100644 app/containers/Chat/ChatActions.tsx create mode 100644 app/containers/Chat/ChatHeader.tsx create mode 100644 app/containers/Chat/ChatInputPanel.tsx create mode 100644 app/containers/Chat/ChatMessagePanel.tsx create mode 100644 app/containers/Chat/ClearContextDivider.tsx create mode 100644 app/containers/Chat/EditMessageModal.tsx create mode 100644 app/containers/Chat/PromptHint.tsx create mode 100644 app/containers/Chat/PromptToast.tsx create mode 100644 app/containers/Chat/SessionConfigModal.tsx create mode 100644 app/containers/Chat/index.module.scss create mode 100644 app/containers/Chat/index.tsx create mode 100644 app/containers/Sidebar/SessionList.tsx create mode 100644 app/containers/Sidebar/SettingList.tsx create mode 100644 app/containers/Sidebar/index.tsx create mode 100644 app/containers/Sidebar/types.ts create mode 100644 app/containers/discoverAssistant/index.module.scss create mode 100644 app/containers/discoverAssistant/index.tsx create mode 100644 app/hooks/useDragSideBar.ts create mode 100644 app/hooks/useHotKey.ts create mode 100644 app/hooks/useMobileScreen.ts create mode 100644 app/hooks/usePaste.ts create mode 100644 app/hooks/useScrollToBottom.ts create mode 100644 app/hooks/useSubmitHandler.ts create mode 100644 app/hooks/useUploadImage.ts create mode 100644 app/icons/addIcon.svg create mode 100644 app/icons/assistantActive.svg create mode 100644 app/icons/assistantInactive.svg create mode 100644 app/icons/background.svg create mode 100644 app/icons/deleteIcon.svg create mode 100644 app/icons/discoverActive.svg create mode 100644 app/icons/discoverInactive.svg create mode 100644 app/icons/githubIcon.svg create mode 100644 app/icons/logIcon.svg create mode 100644 app/icons/nextchatTitle.svg create mode 100644 app/icons/search.svg create mode 100644 app/icons/settingActive.svg create mode 100644 app/icons/settingInactive.svg create mode 100644 app/styles/globals.css create mode 100644 postcss.config.js create mode 100644 tailwind.config.js diff --git a/.babelrc b/.babelrc index 53e4d9b24..32e0a1de8 100644 --- a/.babelrc +++ b/.babelrc @@ -10,5 +10,7 @@ } } ] + ], + "plugins": [ ] } diff --git a/.eslintignore b/.eslintignore index 089752554..f9554580c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,3 @@ -public/serviceWorker.js \ No newline at end of file +public/serviceWorker.js + +./app/styles/globals.css \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index d229e86f2..f9989922d 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,4 +1,12 @@ { "extends": "next/core-web-vitals", - "plugins": ["prettier"] + "plugins": [ + "prettier" + ], + "parserOptions": { + "ecmaFeatures": { + "legacyDecorators": true + } + }, + "ignorePatterns": ["globals.css"] } diff --git a/.prettierrc.js b/.prettierrc.js index 95cc75ffa..756a2fab1 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -7,4 +7,6 @@ module.exports = { trailingComma: 'all', bracketSpacing: true, arrowParens: 'always', + plugins: [ + ], }; diff --git a/app/components/Search/index.module.scss b/app/components/Search/index.module.scss new file mode 100644 index 000000000..922746e29 --- /dev/null +++ b/app/components/Search/index.module.scss @@ -0,0 +1,24 @@ +.search { + display: flex; + max-width: 460px; + height: 50px; + padding: 16px; + align-items: center; + gap: 8px; + flex-shrink: 0; + + border-radius: 16px; + border: 1px solid var(--Light-Text-Black, #18182A); + background: var(--light-opacity-white-70, rgba(255, 255, 255, 0.70)); + box-shadow: 0px 8px 40px 0px rgba(60, 68, 255, 0.12); + + .icon { + height: 20px; + width: 20px; + flex: 0 0; + } + .input { + height: 18px; + flex: 1 1; + } +} \ No newline at end of file diff --git a/app/components/Search/index.tsx b/app/components/Search/index.tsx new file mode 100644 index 000000000..681c7ce2a --- /dev/null +++ b/app/components/Search/index.tsx @@ -0,0 +1,30 @@ +import styles from "./index.module.scss"; +import SearchIcon from "@/app/icons/search.svg"; + +export interface SearchProps { + value?: string; + onSearch?: (v: string) => void; + placeholder?: string; +} + +const Search = (props: SearchProps) => { + const { placeholder = "", value, onSearch } = props; + return ( +
+
+ +
+ { + e.preventDefault(); + onSearch?.(e.target.value); + }} + /> +
+ ); +}; + +export default Search; diff --git a/app/components/TabActions/index.tsx b/app/components/TabActions/index.tsx new file mode 100644 index 000000000..1e04c0270 --- /dev/null +++ b/app/components/TabActions/index.tsx @@ -0,0 +1,70 @@ +import { isValidElement } from "react"; + +type IconMap = { + active: JSX.Element; + inactive: JSX.Element; +}; +interface Action { + id: string; + icons: JSX.Element | IconMap; + className?: string; +} + +export interface TabActionsProps { + actionsShema: Action[]; + onSelect: (id: string) => void; + selected: string; + groups: string[][]; + className?: string; +} + +export default function TabActions(props: TabActionsProps) { + const { actionsShema, onSelect, selected, groups, className } = props; + + const content = groups.reduce((res, group, ind, arr) => { + res.push( + ...group.map((i) => { + const action = actionsShema.find((a) => a.id === i); + if (!action) { + return <>; + } + + const { icons } = action; + let activeIcon, inactiveIcon; + + if (isValidElement(icons)) { + activeIcon = icons; + inactiveIcon = icons; + } else { + activeIcon = (icons as IconMap).active; + inactiveIcon = (icons as IconMap).inactive; + } + + return ( +
{ + e.preventDefault(); + if (selected !== action.id) { + onSelect?.(action.id); + } + }} + > + {selected === action.id ? activeIcon : inactiveIcon} +
+ ); + }), + ); + if (ind < arr.length - 1) { + res.push(
); + } + return res; + }, [] as JSX.Element[]); + + return ( +
{content}
+ ); +} diff --git a/app/components/home.tsx b/app/components/home.tsx index ffac64fda..d6dd63c9a 100644 --- a/app/components/home.tsx +++ b/app/components/home.tsx @@ -9,7 +9,7 @@ import styles from "./home.module.scss"; import BotIcon from "../icons/bot.svg"; import LoadingIcon from "../icons/three-dots.svg"; -import { getCSSVar, useMobileScreen } from "../utils"; +import { getCSSVar } from "../utils"; import dynamic from "next/dynamic"; import { ModelProvider, Path, SlotID } from "../constant"; @@ -23,13 +23,15 @@ import { Route, useLocation, } from "react-router-dom"; -import { SideBar } from "./sidebar"; +import { SideBar } from "@/app/containers/Sidebar"; import { useAppConfig } from "../store/config"; import { AuthPage } from "./auth"; import { getClientConfig } from "../config/client"; import { ClientApi } from "../client/api"; import { useAccessStore } from "../store"; import { identifyDefaultClaudeModel } from "../utils/checkers"; +import useMobileScreen from "@/app/hooks/useMobileScreen"; +import backgroundUrl from "!url-loader!@/app/icons/background.svg"; export function Loading(props: { noLogo?: boolean }) { return ( @@ -44,7 +46,7 @@ const Settings = dynamic(async () => (await import("./settings")).Settings, { loading: () => , }); -const Chat = dynamic(async () => (await import("./chat")).Chat, { +const Chat = dynamic(async () => await import("@/app/containers/Chat"), { loading: () => , }); @@ -129,8 +131,6 @@ function Screen() { const isHome = location.pathname === Path.Home; const isAuth = location.pathname === Path.Auth; const isMobileScreen = useMobileScreen(); - const shouldTightBorder = - getClientConfig()?.isApp || (config.tightBorder && !isMobileScreen); useEffect(() => { loadAsyncGoogleFont(); @@ -140,10 +140,11 @@ function Screen() {
{isAuth ? ( <> @@ -153,14 +154,19 @@ function Screen() { <> -
- - } /> - } /> - } /> - } /> - } /> - +
+ + + } /> + } /> + } /> + } /> + } /> + +
)} diff --git a/app/containers/Chat/ChatAction.tsx b/app/containers/Chat/ChatAction.tsx new file mode 100644 index 000000000..25c69954c --- /dev/null +++ b/app/containers/Chat/ChatAction.tsx @@ -0,0 +1,52 @@ +import { useRef, useState } from "react"; + +import styles from "./index.module.scss"; + +export default function ChatAction(props: { + text: string; + icon: JSX.Element; + onClick: () => void; +}) { + const iconRef = useRef(null); + const textRef = useRef(null); + const [width, setWidth] = useState({ + full: 16, + icon: 16, + }); + + function updateWidth() { + if (!iconRef.current || !textRef.current) return; + const getWidth = (dom: HTMLDivElement) => dom.getBoundingClientRect().width; + const textWidth = getWidth(textRef.current); + const iconWidth = getWidth(iconRef.current); + setWidth({ + full: textWidth + iconWidth, + icon: iconWidth, + }); + } + + return ( +
{ + props.onClick(); + setTimeout(updateWidth, 1); + }} + onMouseEnter={updateWidth} + onTouchStart={updateWidth} + style={ + { + "--icon-width": `${width.icon}px`, + "--full-width": `${width.full}px`, + } as React.CSSProperties + } + > +
+ {props.icon} +
+
+ {props.text} +
+
+ ); +} diff --git a/app/containers/Chat/ChatActions.tsx b/app/containers/Chat/ChatActions.tsx new file mode 100644 index 000000000..3be55f054 --- /dev/null +++ b/app/containers/Chat/ChatActions.tsx @@ -0,0 +1,190 @@ +import { useNavigate } from "react-router-dom"; + +import { ModelType, Theme, useAppConfig } from "@/app/store/config"; +import { useChatStore } from "@/app/store/chat"; +import { ChatControllerPool } from "@/app/client/controller"; +import { useAllModels } from "@/app/utils/hooks"; +import { useEffect, useMemo, useState } from "react"; +import { isVisionModel } from "@/app/utils"; +import { Selector, showToast } from "@/app/components/ui-lib"; +import Locale from "@/app/locales"; +import { Path } from "@/app/constant"; + +import LightIcon from "@/app/icons/light.svg"; +import DarkIcon from "@/app/icons/dark.svg"; +import AutoIcon from "@/app/icons/auto.svg"; +import BottomIcon from "@/app/icons/bottom.svg"; +import StopIcon from "@/app/icons/pause.svg"; +import RobotIcon from "@/app/icons/robot.svg"; +import LoadingButtonIcon from "@/app/icons/loading.svg"; +import PromptIcon from "@/app/icons/prompt.svg"; +import MaskIcon from "@/app/icons/mask.svg"; +import BreakIcon from "@/app/icons/break.svg"; +import SettingsIcon from "@/app/icons/chat-settings.svg"; +import ImageIcon from "@/app/icons/image.svg"; + +import ChatAction from "./ChatAction"; + +import styles from "./index.module.scss"; + +export function ChatActions(props: { + uploadImage: () => void; + setAttachImages: (images: string[]) => void; + setUploading: (uploading: boolean) => void; + showPromptModal: () => void; + scrollToBottom: () => void; + showPromptHints: () => void; + hitBottom: boolean; + uploading: boolean; +}) { + const config = useAppConfig(); + const navigate = useNavigate(); + const chatStore = useChatStore(); + + // switch themes + const theme = 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]; + config.update((config) => (config.theme = nextTheme)); + } + + // stop all responses + const couldStop = ChatControllerPool.hasPending(); + const stopAll = () => ChatControllerPool.stopAll(); + + // switch model + const currentModel = chatStore.currentSession().mask.modelConfig.model; + const allModels = useAllModels(); + const models = useMemo( + () => allModels.filter((m) => m.available), + [allModels], + ); + const [showModelSelector, setShowModelSelector] = useState(false); + const [showUploadImage, setShowUploadImage] = useState(false); + + useEffect(() => { + const show = isVisionModel(currentModel); + setShowUploadImage(show); + if (!show) { + props.setAttachImages([]); + props.setUploading(false); + } + + // if current model is not available + // switch to first available model + const isUnavaliableModel = !models.some((m) => m.name === currentModel); + if (isUnavaliableModel && models.length > 0) { + const nextModel = models[0].name as ModelType; + chatStore.updateCurrentSession( + (session) => (session.mask.modelConfig.model = nextModel), + ); + showToast(nextModel); + } + }, [chatStore, currentModel, models]); + + return ( +
+ {couldStop && ( + } + /> + )} + {!props.hitBottom && ( + } + /> + )} + {props.hitBottom && ( + } + /> + )} + + {showUploadImage && ( + : } + /> + )} + + {theme === Theme.Auto ? ( + + ) : theme === Theme.Light ? ( + + ) : theme === Theme.Dark ? ( + + ) : null} + + } + /> + + } + /> + + { + navigate(Path.Masks); + }} + text={Locale.Chat.InputActions.Masks} + icon={} + /> + + } + onClick={() => { + chatStore.updateCurrentSession((session) => { + if (session.clearContextIndex === session.messages.length) { + session.clearContextIndex = undefined; + } else { + session.clearContextIndex = session.messages.length; + session.memoryPrompt = ""; // will clear memory + } + }); + }} + /> + + setShowModelSelector(true)} + text={currentModel} + icon={} + /> + + {showModelSelector && ( + ({ + title: m.displayName, + value: m.name, + }))} + onClose={() => setShowModelSelector(false)} + onSelection={(s) => { + if (s.length === 0) return; + chatStore.updateCurrentSession((session) => { + session.mask.modelConfig.model = s[0] as ModelType; + session.mask.syncGlobalConfig = false; + }); + showToast(s[0]); + }} + /> + )} +
+ ); +} diff --git a/app/containers/Chat/ChatHeader.tsx b/app/containers/Chat/ChatHeader.tsx new file mode 100644 index 000000000..ac21afc37 --- /dev/null +++ b/app/containers/Chat/ChatHeader.tsx @@ -0,0 +1,76 @@ +import { useNavigate } from "react-router-dom"; +import { IconButton } from "@/app/components/button"; +import Locale from "@/app/locales"; +import { Path } from "@/app/constant"; +import { DEFAULT_TOPIC, useChatStore } from "@/app/store/chat"; + +import RenameIcon from "@/app/icons/rename.svg"; +import ExportIcon from "@/app/icons/share.svg"; +import ReturnIcon from "@/app/icons/return.svg"; + +import styles from "./index.module.scss"; + +export interface ChatHeaderProps { + isMobileScreen: boolean; + setIsEditingMessage: (v: boolean) => void; + setShowExport: (v: boolean) => void; +} + +export default function ChatHeader(props: ChatHeaderProps) { + const { isMobileScreen, setIsEditingMessage, setShowExport } = props; + + const navigate = useNavigate(); + + const chatStore = useChatStore(); + const session = chatStore.currentSession(); + + return ( +
+ {isMobileScreen && ( +
+
+ } + bordered + title={Locale.Chat.Actions.ChatList} + onClick={() => navigate(Path.Home)} + /> +
+
+ )} + +
+
setIsEditingMessage(true)} + > + {!session.topic ? DEFAULT_TOPIC : session.topic} +
+
+ {Locale.Chat.SubTitle(session.messages.length)} +
+
+
+ {!isMobileScreen && ( +
+ } + bordered + onClick={() => setIsEditingMessage(true)} + /> +
+ )} +
+ } + bordered + title={Locale.Chat.Actions.Export} + onClick={() => { + setShowExport(true); + }} + /> +
+
+
+ ); +} diff --git a/app/containers/Chat/ChatInputPanel.tsx b/app/containers/Chat/ChatInputPanel.tsx new file mode 100644 index 000000000..09b35bbef --- /dev/null +++ b/app/containers/Chat/ChatInputPanel.tsx @@ -0,0 +1,308 @@ +import { forwardRef, useImperativeHandle, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useDebouncedCallback } from "use-debounce"; +import useUploadImage from "@/app/hooks/useUploadImage"; +import { IconButton } from "@/app/components/button"; +import Locale from "@/app/locales"; + +import useSubmitHandler from "@/app/hooks/useSubmitHandler"; +import { CHAT_PAGE_SIZE, LAST_INPUT_KEY, Path } from "@/app/constant"; +import { ChatCommandPrefix, useChatCommand } from "@/app/command"; +import { useChatStore } from "@/app/store/chat"; +import { usePromptStore } from "@/app/store/prompt"; +import { useAppConfig } from "@/app/store/config"; +import useScrollToBottom from "@/app/hooks/useScrollToBottom"; +import usePaste from "@/app/hooks/usePaste"; + +import { ChatActions } from "./ChatActions"; +import PromptHints, { RenderPompt } from "./PromptHint"; + +import SendWhiteIcon from "@/app/icons/send-white.svg"; +import DeleteIcon from "@/app/icons/clear.svg"; + +import styles from "./index.module.scss"; + +export interface ChatInputPanelProps { + scrollRef: React.RefObject; + inputRef: React.RefObject; + isMobileScreen: boolean; + renderMessages: any[]; + attachImages: string[]; + userInput: string; + hitBottom: boolean; + inputRows: number; + setAttachImages: (imgs: string[]) => void; + setUserInput: (v: string) => void; + setIsLoading: (value: boolean) => void; + setShowPromptModal: (value: boolean) => void; + _setMsgRenderIndex: (value: number) => void; +} + +export interface ChatInputPanelInstance { + setUploading: (v: boolean) => void; + doSubmit: (userInput: string) => void; + setAutoScroll: (v: boolean) => void; + setMsgRenderIndex: (v: number) => void; +} + +export function DeleteImageButton(props: { deleteImage: () => void }) { + return ( +
+ +
+ ); +} + +// only search prompts when user input is short +const SEARCH_TEXT_LIMIT = 30; + +export default forwardRef( + function ChatInputPanel(props, ref) { + const { + attachImages, + inputRef, + setAttachImages, + userInput, + isMobileScreen, + setUserInput, + setIsLoading, + setShowPromptModal, + renderMessages, + scrollRef, + _setMsgRenderIndex, + hitBottom, + inputRows, + } = props; + + const [uploading, setUploading] = useState(false); + const [promptHints, setPromptHints] = useState([]); + + const chatStore = useChatStore(); + const navigate = useNavigate(); + const config = useAppConfig(); + + const { uploadImage } = useUploadImage(attachImages, { + emitImages: setAttachImages, + setUploading, + }); + const { submitKey, shouldSubmit } = useSubmitHandler(); + + const autoFocus = !isMobileScreen; // wont auto focus on mobile screen + + const isScrolledToBottom = scrollRef?.current + ? Math.abs( + scrollRef.current.scrollHeight - + (scrollRef.current.scrollTop + scrollRef.current.clientHeight), + ) <= 1 + : false; + + const { setAutoScroll, scrollDomToBottom } = useScrollToBottom( + scrollRef, + isScrolledToBottom, + ); + + // chat commands shortcuts + const chatCommands = useChatCommand({ + new: () => chatStore.newSession(), + newm: () => navigate(Path.NewChat), + prev: () => chatStore.nextSession(-1), + next: () => chatStore.nextSession(1), + clear: () => + chatStore.updateCurrentSession( + (session) => (session.clearContextIndex = session.messages.length), + ), + del: () => chatStore.deleteSession(chatStore.currentSessionIndex), + }); + + // prompt hints + const promptStore = usePromptStore(); + const onSearch = useDebouncedCallback( + (text: string) => { + const matchedPrompts = promptStore.search(text); + setPromptHints(matchedPrompts); + }, + 100, + { leading: true, trailing: true }, + ); + + // check if should send message + const onInputKeyDown = (e: React.KeyboardEvent) => { + // if ArrowUp and no userInput, fill with last input + if ( + e.key === "ArrowUp" && + userInput.length <= 0 && + !(e.metaKey || e.altKey || e.ctrlKey) + ) { + setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? ""); + e.preventDefault(); + return; + } + if (shouldSubmit(e) && promptHints.length === 0) { + doSubmit(userInput); + e.preventDefault(); + } + }; + + const onPromptSelect = (prompt: RenderPompt) => { + setTimeout(() => { + setPromptHints([]); + + const matchedChatCommand = chatCommands.match(prompt.content); + if (matchedChatCommand.matched) { + // if user is selecting a chat command, just trigger it + matchedChatCommand.invoke(); + setUserInput(""); + } else { + // or fill the prompt + setUserInput(prompt.content); + } + inputRef.current?.focus(); + }, 30); + }; + + const doSubmit = (userInput: string) => { + if (userInput.trim() === "") return; + const matchCommand = chatCommands.match(userInput); + if (matchCommand.matched) { + setUserInput(""); + setPromptHints([]); + matchCommand.invoke(); + return; + } + setIsLoading(true); + chatStore + .onUserInput(userInput, attachImages) + .then(() => setIsLoading(false)); + setAttachImages([]); + localStorage.setItem(LAST_INPUT_KEY, userInput); + setUserInput(""); + setPromptHints([]); + if (!isMobileScreen) inputRef.current?.focus(); + setAutoScroll(true); + }; + + useImperativeHandle(ref, () => ({ + setUploading, + doSubmit, + setAutoScroll, + setMsgRenderIndex, + })); + + const onInput = (text: string) => { + setUserInput(text); + const n = text.trim().length; + + // clear search results + if (n === 0) { + setPromptHints([]); + } else if (text.startsWith(ChatCommandPrefix)) { + setPromptHints(chatCommands.search(text)); + } else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) { + // check if need to trigger auto completion + if (text.startsWith("/")) { + let searchText = text.slice(1); + onSearch(searchText); + } + } + }; + + function setMsgRenderIndex(newIndex: number) { + newIndex = Math.min(renderMessages.length - CHAT_PAGE_SIZE, newIndex); + newIndex = Math.max(0, newIndex); + _setMsgRenderIndex(newIndex); + } + + function scrollToBottom() { + setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE); + scrollDomToBottom(); + } + + const { handlePaste } = usePaste(attachImages, { + emitImages: setAttachImages, + setUploading, + }); + + return ( +
+ + + setShowPromptModal(true)} + scrollToBottom={scrollToBottom} + hitBottom={hitBottom} + uploading={uploading} + showPromptHints={() => { + // Click again to close + if (promptHints.length > 0) { + setPromptHints([]); + return; + } + + inputRef.current?.focus(); + setUserInput("/"); + onSearch(""); + }} + /> +