From b3559f99a21a730a1429af8f6d694d7b97ba3b81 Mon Sep 17 00:00:00 2001 From: butterfly Date: Thu, 18 Apr 2024 12:27:44 +0800 Subject: [PATCH] feat: chat panel UE done --- .../ActionsBar/index.tsx} | 47 ++- app/components/Btn/index.tsx | 60 ++++ app/components/Loading/index.module.scss | 8 - app/components/Loading/index.tsx | 29 +- app/components/Popover/index.tsx | 77 +++-- app/components/Screen/index.tsx | 6 +- app/components/ThumbnailImg/index.tsx | 27 ++ app/components/home.tsx | 7 +- app/containers/Chat/ChatActions.tsx | 31 +- app/containers/Chat/ChatInputPanel.tsx | 123 ++++---- app/containers/Chat/ChatMessagePanel.tsx | 290 +++++------------- app/containers/Chat/MessageActions.tsx | 265 ++++++++++++++++ app/containers/Chat/PromptHint.tsx | 25 +- app/containers/Chat/PromptToast.tsx | 5 - app/containers/Chat/index.module.scss | 33 +- app/containers/Chat/index.tsx | 45 ++- app/containers/Sidebar/index.tsx | 8 +- app/hooks/useRelativePosition.ts | 103 +++++++ app/hooks/useRows.ts | 34 ++ app/hooks/useScrollToBottom.ts | 17 +- app/hooks/useShowPromptHint.ts | 29 ++ app/hooks/useWindowSize.ts | 16 +- app/icons/command&enterIcon.svg | 6 + app/icons/copyRequestIcon.svg | 4 + app/icons/deleteRequestIcon.svg | 4 + app/icons/editRequestIcon.svg | 4 + app/icons/enterIcon.svg | 5 + app/icons/imgDeleteIcon.svg | 3 + app/icons/imgRetryUploadIcon.svg | 6 + app/icons/imgUploadFailedIcon.svg | 6 + app/icons/imgUploadIcon.svg | 4 + app/icons/pinRequestIcon.svg | 4 + app/icons/popoverArrowIcon.svg | 3 + app/icons/retryRequestIcon.svg | 4 + app/icons/sendIcon.svg | 3 + app/layout.tsx | 2 +- app/locales/cn.ts | 4 +- app/styles/globals.css | 5 + tailwind.config.js | 48 ++- 39 files changed, 953 insertions(+), 447 deletions(-) rename app/{containers/Sidebar/TabActions.tsx => components/ActionsBar/index.tsx} (76%) create mode 100644 app/components/Btn/index.tsx delete mode 100644 app/components/Loading/index.module.scss create mode 100644 app/components/ThumbnailImg/index.tsx create mode 100644 app/containers/Chat/MessageActions.tsx create mode 100644 app/hooks/useRelativePosition.ts create mode 100644 app/hooks/useRows.ts create mode 100644 app/hooks/useShowPromptHint.ts create mode 100644 app/icons/command&enterIcon.svg create mode 100644 app/icons/copyRequestIcon.svg create mode 100644 app/icons/deleteRequestIcon.svg create mode 100644 app/icons/editRequestIcon.svg create mode 100644 app/icons/enterIcon.svg create mode 100644 app/icons/imgDeleteIcon.svg create mode 100644 app/icons/imgRetryUploadIcon.svg create mode 100644 app/icons/imgUploadFailedIcon.svg create mode 100644 app/icons/imgUploadIcon.svg create mode 100644 app/icons/pinRequestIcon.svg create mode 100644 app/icons/popoverArrowIcon.svg create mode 100644 app/icons/retryRequestIcon.svg create mode 100644 app/icons/sendIcon.svg diff --git a/app/containers/Sidebar/TabActions.tsx b/app/components/ActionsBar/index.tsx similarity index 76% rename from app/containers/Sidebar/TabActions.tsx rename to app/components/ActionsBar/index.tsx index 2594882dd..c121e1915 100644 --- a/app/containers/Sidebar/TabActions.tsx +++ b/app/components/ActionsBar/index.tsx @@ -11,6 +11,7 @@ interface Action { title?: string; icons: JSX.Element | IconMap; className?: string; + onClick?: () => void; } type Groups = { @@ -18,25 +19,29 @@ type Groups = { mobile: string[][]; }; -export interface TabActionsProps { +export interface ActionsBarProps { actionsShema: Action[]; - onSelect: (id: string) => void; - selected: string; + onSelect?: (id: string) => void; + selected?: string; groups: string[][] | Groups; className?: string; - inMobile: boolean; + inMobile?: boolean; } -export default function TabActions(props: TabActionsProps) { +export default function ActionsBar(props: ActionsBarProps) { const { actionsShema, onSelect, selected, groups, className, inMobile } = props; - const handlerClick = (id: string) => (e: { preventDefault: () => void }) => { - e.preventDefault(); - if (selected !== id) { - onSelect?.(id); - } - }; + const handlerClick = + (action: Action) => (e: { preventDefault: () => void }) => { + e.preventDefault(); + if (action.onClick) { + action.onClick(); + } + if (selected !== action.id) { + onSelect?.(action.id); + } + }; const internalGroup = Array.isArray(groups) ? groups @@ -80,7 +85,7 @@ export default function TabActions(props: TabActionsProps) { : "text-gray-400" } `} - onClick={handlerClick(action.id)} + onClick={handlerClick(action)} > {selected === action.id ? mobileActiveIcon : mobileInactiveIcon}
@@ -93,10 +98,10 @@ export default function TabActions(props: TabActionsProps) { return (
{selected === action.id ? activeIcon : inactiveIcon}
@@ -104,18 +109,10 @@ export default function TabActions(props: TabActionsProps) { }), ); if (ind < arr.length - 1) { - res.push(
); + res.push(
); } return res; }, [] as JSX.Element[]); - return ( -
- {content} -
- ); + return
{content}
; } diff --git a/app/components/Btn/index.tsx b/app/components/Btn/index.tsx new file mode 100644 index 000000000..d05919d9c --- /dev/null +++ b/app/components/Btn/index.tsx @@ -0,0 +1,60 @@ +import * as React from "react"; + +export type ButtonType = "primary" | "danger" | null; + +export default function IconButton(props: { + onClick?: () => void; + icon?: JSX.Element; + type?: ButtonType; + text?: string; + bordered?: boolean; + shadow?: boolean; + className?: string; + title?: string; + disabled?: boolean; + tabIndex?: number; + autoFocus?: boolean; +}) { + const { + onClick, + icon, + type, + text, + bordered, + shadow, + className, + title, + disabled, + tabIndex, + autoFocus, + } = props; + + return ( + + ); +} diff --git a/app/components/Loading/index.module.scss b/app/components/Loading/index.module.scss deleted file mode 100644 index 98f568e29..000000000 --- a/app/components/Loading/index.module.scss +++ /dev/null @@ -1,8 +0,0 @@ -.loading-content { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - height: 100%; - width: 100%; - } \ No newline at end of file diff --git a/app/components/Loading/index.tsx b/app/components/Loading/index.tsx index 2ae0ef2a4..781d1dceb 100644 --- a/app/components/Loading/index.tsx +++ b/app/components/Loading/index.tsx @@ -1,12 +1,33 @@ +import useMobileScreen from "@/app/hooks/useMobileScreen"; import BotIcon from "@/app/icons/bot.svg"; import LoadingIcon from "@/app/icons/three-dots.svg"; -import styles from "./index.module.scss"; +import { getCSSVar } from "@/app/utils"; + +export default function Loading({ + noLogo, + useSkeleton = true, +}: { + noLogo?: boolean; + useSkeleton?: boolean; +}) { + let theme; + if (typeof window !== "undefined") { + theme = getCSSVar("--chat-panel-bg"); + } + + const isMobileScreen = useMobileScreen(); -export default function Loading(props: { noLogo?: boolean }) { return ( -
- {!props.noLogo && } +
+ {!noLogo && }
); diff --git a/app/components/Popover/index.tsx b/app/components/Popover/index.tsx index dcfb59c83..77c343614 100644 --- a/app/components/Popover/index.tsx +++ b/app/components/Popover/index.tsx @@ -1,4 +1,24 @@ -import { useState } from "react"; +import { getCSSVar } from "@/app/utils"; +import { useMemo, useState } from "react"; + +const ArrowIcon = ({ color }: { color: string }) => { + return ( + + + + ); +}; + +const baseZIndex = 100; export default function Popover(props: { content?: JSX.Element | string; @@ -10,6 +30,7 @@ export default function Popover(props: { trigger?: "hover" | "click"; placement?: "t" | "lt" | "rt" | "lb" | "rb" | "b"; noArrow?: boolean; + bgcolor?: string; }) { const { content, @@ -21,6 +42,7 @@ export default function Popover(props: { trigger = "hover", placement = "t", noArrow = false, + bgcolor, } = props; const [internalShow, setShow] = useState(false); @@ -28,14 +50,15 @@ export default function Popover(props: { const mergedShow = show ?? internalShow; let placementClassName; - let arrowClassName = - "rotate-45 w-[8.5px] h-[8.5px] left-[50%] translate-x-[calc(-50%)] bg-black rounded-[1px] "; + let arrowClassName = "absolute left-[50%] translate-x-[calc(-50%)]"; + // "absolute rotate-45 w-[8.5px] h-[8.5px] left-[50%] translate-x-[calc(-50%)] bg-black rounded-[1px] "; + arrowClassName += " "; switch (placement) { case "b": placementClassName = - "bottom-[calc(-100%-0.5rem)] left-[50%] translate-x-[calc(-50%)]"; - arrowClassName += "bottom-[-5px] "; + "top-[calc(100%+0.5rem)] left-[50%] translate-x-[calc(-50%)]"; + arrowClassName += "top-[calc(100%+0.5rem)] translate-y-[calc(-100%)]"; break; // case 'l': // placementClassName = ''; @@ -44,28 +67,28 @@ export default function Popover(props: { // placementClassName = ''; // break; case "rb": - placementClassName = "bottom-[calc(-100%-0.5rem)]"; - arrowClassName += "bottom-[-5px] "; + placementClassName = "top-[calc(100%+0.5rem)] translate-x-[calc(-2%)]"; + arrowClassName += "top-[calc(100%+0.5rem)] translate-y-[calc(-100%)]"; break; case "lt": placementClassName = - "top-[calc(-100%-0.5rem)] left-[100%] translate-x-[calc(-100%)]"; - arrowClassName += "top-[-5px] "; + "bottom-[calc(100%+0.5rem)] left-[100%] translate-x-[calc(-98%)]"; + arrowClassName += "bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)]"; break; case "lb": placementClassName = - "bottom-[calc(-100%-0.5rem)] left-[100%] translate-x-[calc(-100%)]"; - arrowClassName += "bottom-[-5px] "; + "top-[calc(100%+0.5rem)] left-[100%] translate-x-[calc(-98%)]"; + arrowClassName += "top-[calc(100%+0.5rem)] translate-y-[calc(-100%)]"; break; case "rt": - placementClassName = "top-[calc(-100%-0.5rem)]"; - arrowClassName += "top-[-5px] "; + placementClassName = "bottom-[calc(100%+0.5rem)] translate-x-[calc(-2%)]"; + arrowClassName += "bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)]"; break; case "t": default: placementClassName = - "top-[calc(-100%-0.5rem)] left-[50%] translate-x-[calc(-50%)]"; - arrowClassName += "top-[-5px] "; + "bottom-[calc(100%+0.5rem)] left-[50%] translate-x-[calc(-50%)]"; + arrowClassName += "bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)]"; } const popoverCommonClass = "absolute p-2 box-border"; @@ -74,6 +97,10 @@ export default function Popover(props: { arrowClassName = "hidden"; } + const internalBgColor = useMemo(() => { + return bgcolor ?? getCSSVar("--tip-popover-color"); + }, [bgcolor]); + if (trigger === "click") { return (
{!noArrow && ( -
 
+
+ +
)}
{content}
+
{ + e.preventDefault(); + onShow?.(!mergedShow); + setShow(!mergedShow); + }} + > +   +
)}
@@ -105,8 +146,8 @@ export default function Popover(props: {
{children} {!noArrow && ( -
-   +
+
)}
void; +} + +export default function Thumbnail(props: ThumbnailProps) { + const { image, deleteImage } = props; + return ( +
+
+
+ +
+
+
+ ); +} diff --git a/app/components/home.tsx b/app/components/home.tsx index 4749e25d9..767869f41 100644 --- a/app/components/home.tsx +++ b/app/components/home.tsx @@ -45,7 +45,7 @@ const Settings = dynamic(async () => (await import("./settings")).Settings, { loading: () => , }); -const Chat = dynamic(async () => await import("@/app/containers/Chat"), { +const Chat = dynamic(async () => (await import("./chat")).Chat, { loading: () => , }); @@ -151,10 +151,7 @@ function Screen() { <> -
+
} /> diff --git a/app/containers/Chat/ChatActions.tsx b/app/containers/Chat/ChatActions.tsx index fd8a05088..262451884 100644 --- a/app/containers/Chat/ChatActions.tsx +++ b/app/containers/Chat/ChatActions.tsx @@ -24,16 +24,13 @@ import SettingsIcon from "@/app/icons/chat-settings.svg"; import ImageIcon from "@/app/icons/image.svg"; import AddCircleIcon from "@/app/icons/addCircle.svg"; -import ChatAction from "./ChatAction"; - -import styles from "./index.module.scss"; import Popover from "@/app/components/Popover"; export function ChatActions(props: { uploadImage: () => void; setAttachImages: (images: string[]) => void; setUploading: (uploading: boolean) => void; - showPromptModal: () => void; + showChatSetting: () => void; scrollToBottom: () => void; showPromptHints: () => void; showModelSelector: (show: boolean) => void; @@ -105,9 +102,9 @@ export function ChatActions(props: { placement: "left", }, { - onClick: props.showPromptModal, + onClick: props.showChatSetting, text: Locale.Chat.InputActions.Settings, - isShow: props.hitBottom, + isShow: true, icon: , placement: "right", }, @@ -178,28 +175,36 @@ export function ChatActions(props: { if (props.isMobileScreen) { const content = ( -
+
{actions.map((act) => { return (
{act.icon} - {act.text} +
+ {act.text} +
); })}
); return ( - + ); } - const popoverClassName = `bg-gray-800 whitespace-nowrap px-3 py-2.5 text-white text-sm-title rounded-md`; + const popoverClassName = `bg-chat-actions-popover-color whitespace-nowrap px-3 py-2.5 text-white text-sm-title rounded-md`; return (
@@ -214,7 +219,7 @@ export function ChatActions(props: { placement={ind ? "t" : "rt"} >
{act.icon} @@ -234,7 +239,7 @@ export function ChatActions(props: { placement={ind === arr.length - 1 ? "lt" : "t"} >
{act.icon} diff --git a/app/containers/Chat/ChatInputPanel.tsx b/app/containers/Chat/ChatInputPanel.tsx index 58638db6a..9d783165d 100644 --- a/app/containers/Chat/ChatInputPanel.tsx +++ b/app/containers/Chat/ChatInputPanel.tsx @@ -2,7 +2,6 @@ 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"; @@ -17,13 +16,14 @@ 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 CEIcon from "@/app/icons/command&enterIcon.svg"; +// import EnterIcon from "@/app/icons/enterIcon.svg"; +import SendIcon from "@/app/icons/sendIcon.svg"; -import styles from "./index.module.scss"; +import Btn from "@/app/components/Btn"; +import Thumbnail from "@/app/components/ThumbnailImg"; export interface ChatInputPanelProps { - scrollRef: React.RefObject; inputRef: React.RefObject; isMobileScreen: boolean; renderMessages: any[]; @@ -34,26 +34,19 @@ export interface ChatInputPanelProps { setAttachImages: (imgs: string[]) => void; setUserInput: (v: string) => void; setIsLoading: (value: boolean) => void; - setShowPromptModal: (value: boolean) => void; + showChatSetting: (value: boolean) => void; _setMsgRenderIndex: (value: number) => void; showModelSelector: (value: boolean) => void; + setAutoScroll: (value: boolean) => void; + scrollDomToBottom: () => 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; @@ -67,13 +60,14 @@ export default forwardRef( isMobileScreen, setUserInput, setIsLoading, - setShowPromptModal, + showChatSetting, renderMessages, - scrollRef, _setMsgRenderIndex, hitBottom, inputRows, showModelSelector, + setAutoScroll, + scrollDomToBottom, } = props; const [uploading, setUploading] = useState(false); @@ -91,18 +85,6 @@ export default forwardRef( 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(), @@ -226,12 +208,14 @@ export default forwardRef( let inputClassName = " flex flex-col px-5 pb-5"; let actionsClassName = "py-2.5"; - let inputTextAreaClassName = ""; + let labelClassName = "rounded-md p-4 gap-4"; + let textarea = "min-h-chat-input"; if (isMobileScreen) { inputClassName = "flex flex-row-reverse items-center gap-2 p-3"; actionsClassName = ""; - inputTextAreaClassName = ""; + labelClassName = " rounded-chat-input p-3 gap-3 flex-1"; + textarea = "h-chat-input-mobile"; } return ( @@ -241,7 +225,7 @@ export default forwardRef(
@@ -250,7 +234,7 @@ export default forwardRef( uploadImage={uploadImage} setAttachImages={setAttachImages} setUploading={setUploading} - showPromptModal={() => setShowPromptModal(true)} + showChatSetting={() => showChatSetting(true)} scrollToBottom={scrollToBottom} hitBottom={hitBottom} uploading={uploading} @@ -269,18 +253,35 @@ export default forwardRef( isMobileScreen={isMobileScreen} />