mirror of
https://github.com/Yidadaa/ChatGPT-Next-Web.git
synced 2025-09-04 22:46:55 +08:00
feat: chat panel UE done
This commit is contained in:
@@ -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: <SettingsIcon />,
|
||||
placement: "right",
|
||||
},
|
||||
@@ -178,28 +175,36 @@ export function ChatActions(props: {
|
||||
|
||||
if (props.isMobileScreen) {
|
||||
const content = (
|
||||
<div>
|
||||
<div className="w-[100%]">
|
||||
{actions.map((act) => {
|
||||
return (
|
||||
<div
|
||||
key={act.text}
|
||||
className={`flex p-3 bg-white hover:bg-select-btn rounded-action-btn`}
|
||||
className={`flex items-center gap-3 p-3 bg-white hover:bg-select-btn rounded-action-btn leading-6`}
|
||||
>
|
||||
{act.icon}
|
||||
{act.text}
|
||||
<div className="flex-1 text-common text-actions-popover-menu-item">
|
||||
{act.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Popover content={content}>
|
||||
<Popover
|
||||
content={content}
|
||||
trigger="click"
|
||||
placement="lt"
|
||||
noArrow
|
||||
popoverClassName="border-actions-popover border-gray-200 rounded-md shadow-actions-popover w-actions-popover bg-white "
|
||||
>
|
||||
<AddCircleIcon />
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={`flex gap-2 item-center ${props.className}`}>
|
||||
@@ -214,7 +219,7 @@ export function ChatActions(props: {
|
||||
placement={ind ? "t" : "rt"}
|
||||
>
|
||||
<div
|
||||
className="h-[32px] w-[32px] flex items-center justify-center"
|
||||
className="h-[32px] w-[32px] flex items-center justify-center hover:bg-gray-200 hover:rounded-action-btn"
|
||||
onClick={act.onClick}
|
||||
>
|
||||
{act.icon}
|
||||
@@ -234,7 +239,7 @@ export function ChatActions(props: {
|
||||
placement={ind === arr.length - 1 ? "lt" : "t"}
|
||||
>
|
||||
<div
|
||||
className="h-[32px] w-[32px] flex items-center justify-center"
|
||||
className="h-[32px] w-[32px] flex items-center justify-center hover:bg-gray-200 hover:rounded-action-btn"
|
||||
onClick={act.onClick}
|
||||
>
|
||||
{act.icon}
|
||||
|
@@ -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<HTMLDivElement>;
|
||||
inputRef: React.RefObject<HTMLTextAreaElement>;
|
||||
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 (
|
||||
<div className={styles["delete-image"]} onClick={props.deleteImage}>
|
||||
<DeleteIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// only search prompts when user input is short
|
||||
const SEARCH_TEXT_LIMIT = 30;
|
||||
|
||||
@@ -67,13 +60,14 @@ export default forwardRef<ChatInputPanelInstance, ChatInputPanelProps>(
|
||||
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<ChatInputPanelInstance, ChatInputPanelProps>(
|
||||
|
||||
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<ChatInputPanelInstance, ChatInputPanelProps>(
|
||||
|
||||
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<ChatInputPanelInstance, ChatInputPanelProps>(
|
||||
<PromptHints
|
||||
prompts={promptHints}
|
||||
onPromptSelect={onPromptSelect}
|
||||
className=""
|
||||
className=" border-gray-200"
|
||||
/>
|
||||
|
||||
<div className={`${inputClassName}`}>
|
||||
@@ -250,7 +234,7 @@ export default forwardRef<ChatInputPanelInstance, ChatInputPanelProps>(
|
||||
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<ChatInputPanelInstance, ChatInputPanelProps>(
|
||||
isMobileScreen={isMobileScreen}
|
||||
/>
|
||||
<label
|
||||
className={`${styles["chat-input-panel-inner"]} ${
|
||||
attachImages.length != 0
|
||||
? styles["chat-input-panel-inner-attach"]
|
||||
: ""
|
||||
} ${inputTextAreaClassName}`}
|
||||
className={`cursor-text flex flex-col bg-white border-[1px] border-white focus-within:border-blue-300 focus-within:shadow-chat-input ${labelClassName}`}
|
||||
htmlFor="chat-input"
|
||||
>
|
||||
{attachImages.length != 0 && (
|
||||
<div className={`flex gap-2`}>
|
||||
{attachImages.map((image, index) => {
|
||||
return (
|
||||
<Thumbnail
|
||||
key={index}
|
||||
deleteImage={() => {
|
||||
setAttachImages(
|
||||
attachImages.filter((_, i) => i !== index),
|
||||
);
|
||||
}}
|
||||
image={image}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
id="chat-input"
|
||||
ref={inputRef}
|
||||
className={styles["chat-input"]}
|
||||
placeholder={Locale.Chat.Input(submitKey)}
|
||||
className={`leading-[19px] flex-1 focus:outline-none focus:shadow-none focus:border-none ${textarea} resize-none`}
|
||||
placeholder={
|
||||
isMobileScreen
|
||||
? Locale.Chat.Input(submitKey, isMobileScreen)
|
||||
: undefined
|
||||
}
|
||||
onInput={(e) => onInput(e.currentTarget.value)}
|
||||
value={userInput}
|
||||
onKeyDown={onInputKeyDown}
|
||||
@@ -293,36 +294,22 @@ export default forwardRef<ChatInputPanelInstance, ChatInputPanelProps>(
|
||||
fontSize: config.fontSize,
|
||||
}}
|
||||
/>
|
||||
{attachImages.length != 0 && (
|
||||
<div className={styles["attach-images"]}>
|
||||
{attachImages.map((image, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={styles["attach-image"]}
|
||||
style={{ backgroundImage: `url("${image}")` }}
|
||||
>
|
||||
<div className={styles["attach-image-mask"]}>
|
||||
<DeleteImageButton
|
||||
deleteImage={() => {
|
||||
setAttachImages(
|
||||
attachImages.filter((_, i) => i !== index),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!isMobileScreen && (
|
||||
<div className="flex items-center justify-center text-sm gap-3">
|
||||
<div className="flex-1"> </div>
|
||||
<div className="text-gray-500 text-time line-clamp-1">
|
||||
{Locale.Chat.Input(submitKey)}
|
||||
</div>
|
||||
<Btn
|
||||
className="min-w-[77px]"
|
||||
icon={<SendIcon />}
|
||||
text={Locale.Chat.Send}
|
||||
// className={styles["chat-input-send"]}
|
||||
type="primary"
|
||||
onClick={() => doSubmit(userInput)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<IconButton
|
||||
icon={<SendWhiteIcon />}
|
||||
text={Locale.Chat.Send}
|
||||
className={styles["chat-input-send"]}
|
||||
type="primary"
|
||||
onClick={() => doSubmit(userInput)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,35 +1,27 @@
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { Fragment, useEffect, useMemo } from "react";
|
||||
import { ChatMessage, useChatStore } from "@/app/store/chat";
|
||||
import { CHAT_PAGE_SIZE } from "@/app/constant";
|
||||
import Locale from "@/app/locales";
|
||||
|
||||
import styles from "./index.module.scss";
|
||||
import {
|
||||
copyToClipboard,
|
||||
getMessageImages,
|
||||
getMessageTextContent,
|
||||
selectOrCopy,
|
||||
} from "@/app/utils";
|
||||
import { showPrompt, showToast } from "@/app/components/ui-lib";
|
||||
|
||||
import CopyIcon from "@/app/icons/copy.svg";
|
||||
import ResetIcon from "@/app/icons/reload.svg";
|
||||
import DeleteIcon from "@/app/icons/clear.svg";
|
||||
import PinIcon from "@/app/icons/pin.svg";
|
||||
import EditIcon from "@/app/icons/rename.svg";
|
||||
import StopIcon from "@/app/icons/pause.svg";
|
||||
import LoadingIcon from "@/app/icons/three-dots.svg";
|
||||
|
||||
import { MultimodalContent } from "@/app/client/api";
|
||||
import { Avatar } from "@/app/components/emoji";
|
||||
import { MaskAvatar } from "@/app/components/mask";
|
||||
import { useAppConfig } from "@/app/store/config";
|
||||
import ChatAction from "./ChatAction";
|
||||
import { ChatControllerPool } from "@/app/client/controller";
|
||||
import ClearContextDivider from "./ClearContextDivider";
|
||||
import dynamic from "next/dynamic";
|
||||
import useRelativePosition, {
|
||||
Orientation,
|
||||
} from "@/app/hooks/useRelativePosition";
|
||||
import MessageActions, { RenderMessage } from "./MessageActions";
|
||||
|
||||
export type RenderMessage = ChatMessage & { preview?: boolean };
|
||||
export type { RenderMessage };
|
||||
|
||||
export interface ChatMessagePanelProps {
|
||||
scrollRef: React.RefObject<HTMLDivElement>;
|
||||
@@ -39,6 +31,7 @@ export interface ChatMessagePanelProps {
|
||||
userInput: string;
|
||||
context: any[];
|
||||
renderMessages: RenderMessage[];
|
||||
scrollDomToBottom: () => void;
|
||||
setAutoScroll?: (value: boolean) => void;
|
||||
setMsgRenderIndex?: (newIndex: number) => void;
|
||||
setHitBottom?: (value: boolean) => void;
|
||||
@@ -47,8 +40,17 @@ export interface ChatMessagePanelProps {
|
||||
setShowPromptModal?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
let MarkdownLoadedCallback: () => void;
|
||||
|
||||
const Markdown = dynamic(
|
||||
async () => (await import("@/app/components/markdown")).Markdown,
|
||||
async () => {
|
||||
const bundle = await import("@/app/components/markdown");
|
||||
|
||||
if (MarkdownLoadedCallback) {
|
||||
MarkdownLoadedCallback();
|
||||
}
|
||||
return bundle.Markdown;
|
||||
},
|
||||
{
|
||||
loading: () => <LoadingIcon />,
|
||||
},
|
||||
@@ -69,6 +71,7 @@ export default function ChatMessagePanel(props: ChatMessagePanelProps) {
|
||||
renderMessages,
|
||||
setIsLoading,
|
||||
setShowPromptModal,
|
||||
scrollDomToBottom,
|
||||
} = props;
|
||||
|
||||
const chatStore = useChatStore();
|
||||
@@ -76,6 +79,32 @@ export default function ChatMessagePanel(props: ChatMessagePanelProps) {
|
||||
const config = useAppConfig();
|
||||
const fontSize = config.fontSize;
|
||||
|
||||
const { position, getRelativePosition } = useRelativePosition({
|
||||
containerRef: scrollRef,
|
||||
delay: 0,
|
||||
offsetDistance: 20,
|
||||
});
|
||||
|
||||
// clear context index = context length + index in messages
|
||||
const clearContextIndex =
|
||||
(session.clearContextIndex ?? -1) >= 0
|
||||
? session.clearContextIndex! + context.length - msgRenderIndex
|
||||
: -1;
|
||||
|
||||
if (!MarkdownLoadedCallback) {
|
||||
MarkdownLoadedCallback = () => {
|
||||
window.setTimeout(scrollDomToBottom, 100);
|
||||
};
|
||||
}
|
||||
|
||||
const messages = useMemo(() => {
|
||||
const endRenderIndex = Math.min(
|
||||
msgRenderIndex + 3 * CHAT_PAGE_SIZE,
|
||||
renderMessages.length,
|
||||
);
|
||||
return renderMessages.slice(msgRenderIndex, endRenderIndex);
|
||||
}, [msgRenderIndex, renderMessages]);
|
||||
|
||||
const onChatBodyScroll = (e: HTMLElement) => {
|
||||
const bottomHeight = e.scrollTop + e.clientHeight;
|
||||
const edgeThreshold = e.clientHeight;
|
||||
@@ -109,110 +138,9 @@ export default function ChatMessagePanel(props: ChatMessagePanelProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMessage = (msgId?: string) => {
|
||||
chatStore.updateCurrentSession(
|
||||
(session) =>
|
||||
(session.messages = session.messages.filter((m) => m.id !== msgId)),
|
||||
);
|
||||
};
|
||||
|
||||
const onDelete = (msgId: string) => {
|
||||
deleteMessage(msgId);
|
||||
};
|
||||
|
||||
const onResend = (message: ChatMessage) => {
|
||||
// when it is resending a message
|
||||
// 1. for a user's message, find the next bot response
|
||||
// 2. for a bot's message, find the last user's input
|
||||
// 3. delete original user input and bot's message
|
||||
// 4. resend the user's input
|
||||
|
||||
const resendingIndex = session.messages.findIndex(
|
||||
(m) => m.id === message.id,
|
||||
);
|
||||
|
||||
if (resendingIndex < 0 || resendingIndex >= session.messages.length) {
|
||||
console.error("[Chat] failed to find resending message", message);
|
||||
return;
|
||||
}
|
||||
|
||||
let userMessage: ChatMessage | undefined;
|
||||
let botMessage: ChatMessage | undefined;
|
||||
|
||||
if (message.role === "assistant") {
|
||||
// if it is resending a bot's message, find the user input for it
|
||||
botMessage = message;
|
||||
for (let i = resendingIndex; i >= 0; i -= 1) {
|
||||
if (session.messages[i].role === "user") {
|
||||
userMessage = session.messages[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (message.role === "user") {
|
||||
// if it is resending a user's input, find the bot's response
|
||||
userMessage = message;
|
||||
for (let i = resendingIndex; i < session.messages.length; i += 1) {
|
||||
if (session.messages[i].role === "assistant") {
|
||||
botMessage = session.messages[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userMessage === undefined) {
|
||||
console.error("[Chat] failed to resend", message);
|
||||
return;
|
||||
}
|
||||
|
||||
// delete the original messages
|
||||
deleteMessage(userMessage.id);
|
||||
deleteMessage(botMessage?.id);
|
||||
|
||||
// resend the message
|
||||
setIsLoading?.(true);
|
||||
const textContent = getMessageTextContent(userMessage);
|
||||
const images = getMessageImages(userMessage);
|
||||
chatStore
|
||||
.onUserInput(textContent, images)
|
||||
.then(() => setIsLoading?.(false));
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const onPinMessage = (message: ChatMessage) => {
|
||||
chatStore.updateCurrentSession((session) =>
|
||||
session.mask.context.push(message),
|
||||
);
|
||||
|
||||
showToast(Locale.Chat.Actions.PinToastContent, {
|
||||
text: Locale.Chat.Actions.PinToastAction,
|
||||
onClick: () => {
|
||||
setShowPromptModal?.(true);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// clear context index = context length + index in messages
|
||||
const clearContextIndex =
|
||||
(session.clearContextIndex ?? -1) >= 0
|
||||
? session.clearContextIndex! + context.length - msgRenderIndex
|
||||
: -1;
|
||||
|
||||
const messages = useMemo(() => {
|
||||
const endRenderIndex = Math.min(
|
||||
msgRenderIndex + 3 * CHAT_PAGE_SIZE,
|
||||
renderMessages.length,
|
||||
);
|
||||
return renderMessages.slice(msgRenderIndex, endRenderIndex);
|
||||
}, [msgRenderIndex, renderMessages]);
|
||||
|
||||
// stop response
|
||||
const onUserStop = (messageId: string) => {
|
||||
ChatControllerPool.stop(session.id, messageId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex-1 overscroll-y-none overflow-x-hidden px-3 pb-5`}
|
||||
className={`relative flex-1 overscroll-y-none overflow-y-auto overflow-x-hidden px-3 pb-6`}
|
||||
ref={scrollRef}
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
onMouseDown={() => inputRef.current?.blur()}
|
||||
@@ -228,10 +156,15 @@ export default function ChatMessagePanel(props: ChatMessagePanelProps) {
|
||||
i > 0 &&
|
||||
!(message.preview || message.content.length === 0) &&
|
||||
!isContext;
|
||||
// const showTyping = message.preview || message.streaming;
|
||||
|
||||
const shouldShowClearContextDivider = i === clearContextIndex - 1;
|
||||
|
||||
const actionsBarPosition =
|
||||
position?.id === message.id &&
|
||||
position?.poi.overlapPositions[Orientation.bottom]
|
||||
? "bottom-[calc(100%-0.25rem)]"
|
||||
: "top-[calc(100%-0.25rem)]";
|
||||
|
||||
return (
|
||||
<Fragment key={message.id}>
|
||||
<div
|
||||
@@ -253,11 +186,6 @@ export default function ChatMessagePanel(props: ChatMessagePanelProps) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* {showTyping && (
|
||||
<div className={styles["chat-message-status"]}>
|
||||
{Locale.Chat.Typing}
|
||||
</div>
|
||||
)} */}
|
||||
<div className={`group relative max-w-message-width`}>
|
||||
<div
|
||||
className={` pointer-events-none text-gray-500 text-right text-time whitespace-nowrap transition-all duration-500 text-sm absolute z-1 ${
|
||||
@@ -269,9 +197,14 @@ export default function ChatMessagePanel(props: ChatMessagePanelProps) {
|
||||
: message.date.toLocaleString()}
|
||||
</div>
|
||||
<div
|
||||
className={`transition-all duration-300 select-text break-words font-common text-sm-title rounded-message box-border peer py-2 px-3 ${
|
||||
className={`transition-all duration-300 select-text break-words font-common text-sm-title ${
|
||||
isUser ? "rounded-user-message" : "rounded-bot-message"
|
||||
} box-border peer py-2 px-3 ${
|
||||
isUser ? "text-right bg-message-bg" : " bg-white"
|
||||
}`}
|
||||
onPointerMoveCapture={(e) =>
|
||||
getRelativePosition(e.currentTarget, message.id)
|
||||
}
|
||||
>
|
||||
<Markdown
|
||||
content={getMessageTextContent(message)}
|
||||
@@ -290,26 +223,12 @@ export default function ChatMessagePanel(props: ChatMessagePanelProps) {
|
||||
defaultShow={i >= messages.length - 6}
|
||||
className={isUser ? " text-white" : "text-black"}
|
||||
/>
|
||||
{getMessageImages(message).length == 1 && (
|
||||
<img
|
||||
className={` w-[100%] mt-2.5`}
|
||||
src={getMessageImages(message)[0]}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
{getMessageImages(message).length > 1 && (
|
||||
<div
|
||||
className={`styles["chat-message-item-images"] w-[100%]`}
|
||||
style={
|
||||
{
|
||||
"--image-count": getMessageImages(message).length,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{getMessageImages(message).length > 0 && (
|
||||
<div className={`w-[100%]`}>
|
||||
{getMessageImages(message).map((image, index) => {
|
||||
return (
|
||||
<img
|
||||
className={styles["chat-message-item-image-multi"]}
|
||||
className={`w-[100%] mt-2.5 rounded-chat-img`}
|
||||
key={index}
|
||||
src={image}
|
||||
alt=""
|
||||
@@ -319,86 +238,15 @@ export default function ChatMessagePanel(props: ChatMessagePanelProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showActions && (
|
||||
<div
|
||||
className={` absolute ${
|
||||
isUser ? "right-0" : "left-0"
|
||||
} top-[100%] hidden group-hover:block`}
|
||||
>
|
||||
<div className={styles["chat-input-actions"]}>
|
||||
{message.streaming ? (
|
||||
<ChatAction
|
||||
text={Locale.Chat.Actions.Stop}
|
||||
icon={<StopIcon />}
|
||||
onClick={() => onUserStop(message.id ?? i)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ChatAction
|
||||
text={Locale.Chat.Actions.Retry}
|
||||
icon={<ResetIcon />}
|
||||
onClick={() => onResend(message)}
|
||||
/>
|
||||
|
||||
<ChatAction
|
||||
text={Locale.Chat.Actions.Delete}
|
||||
icon={<DeleteIcon />}
|
||||
onClick={() => onDelete(message.id ?? i)}
|
||||
/>
|
||||
|
||||
<ChatAction
|
||||
text={Locale.Chat.Actions.Pin}
|
||||
icon={<PinIcon />}
|
||||
onClick={() => onPinMessage(message)}
|
||||
/>
|
||||
<ChatAction
|
||||
text={Locale.Chat.Actions.Copy}
|
||||
icon={<CopyIcon />}
|
||||
onClick={() =>
|
||||
copyToClipboard(getMessageTextContent(message))
|
||||
}
|
||||
/>
|
||||
<ChatAction
|
||||
text={Locale.Chat.Actions.Copy}
|
||||
icon={<EditIcon />}
|
||||
onClick={async () => {
|
||||
const newMessage = await showPrompt(
|
||||
Locale.Chat.Actions.Edit,
|
||||
getMessageTextContent(message),
|
||||
10,
|
||||
);
|
||||
let newContent: string | MultimodalContent[] =
|
||||
newMessage;
|
||||
const images = getMessageImages(message);
|
||||
if (images.length > 0) {
|
||||
newContent = [
|
||||
{ type: "text", text: newMessage },
|
||||
];
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
newContent.push({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: images[i],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
chatStore.updateCurrentSession((session) => {
|
||||
const m = session.mask.context
|
||||
.concat(session.messages)
|
||||
.find((m) => m.id === message.id);
|
||||
if (m) {
|
||||
m.content = newContent;
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<MessageActions
|
||||
className={actionsBarPosition}
|
||||
message={message}
|
||||
inputRef={inputRef}
|
||||
isUser={isUser}
|
||||
showActions={showActions}
|
||||
setIsLoading={setIsLoading}
|
||||
setShowPromptModal={setShowPromptModal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{shouldShowClearContextDivider && <ClearContextDivider />}
|
||||
|
265
app/containers/Chat/MessageActions.tsx
Normal file
265
app/containers/Chat/MessageActions.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import Locale from "@/app/locales";
|
||||
|
||||
import StopIcon from "@/app/icons/pause.svg";
|
||||
import DeleteRequestIcon from "@/app/icons/deleteRequestIcon.svg";
|
||||
import RetryRequestIcon from "@/app/icons/retryRequestIcon.svg";
|
||||
import CopyRequestIcon from "@/app/icons/copyRequestIcon.svg";
|
||||
import EditRequestIcon from "@/app/icons/editRequestIcon.svg";
|
||||
import PinRequestIcon from "@/app/icons/pinRequestIcon.svg";
|
||||
import { showPrompt, showToast } from "@/app/components/ui-lib";
|
||||
import {
|
||||
copyToClipboard,
|
||||
getMessageImages,
|
||||
getMessageTextContent,
|
||||
} from "@/app/utils";
|
||||
import { MultimodalContent } from "@/app/client/api";
|
||||
import { ChatMessage, useChatStore } from "@/app/store/chat";
|
||||
import ActionsBar from "@/app/components/ActionsBar";
|
||||
import { ChatControllerPool } from "@/app/client/controller";
|
||||
import { RefObject } from "react";
|
||||
|
||||
export type RenderMessage = ChatMessage & { preview?: boolean };
|
||||
|
||||
export interface MessageActionsProps {
|
||||
message: RenderMessage;
|
||||
isUser: boolean;
|
||||
showActions: boolean;
|
||||
inputRef: RefObject<HTMLTextAreaElement>;
|
||||
className?: string;
|
||||
setIsLoading?: (value: boolean) => void;
|
||||
setShowPromptModal?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const genActionsShema = (
|
||||
message: RenderMessage,
|
||||
{
|
||||
onEdit,
|
||||
onCopy,
|
||||
onPinMessage,
|
||||
onDelete,
|
||||
onResend,
|
||||
onUserStop,
|
||||
}: Record<
|
||||
| "onEdit"
|
||||
| "onCopy"
|
||||
| "onPinMessage"
|
||||
| "onDelete"
|
||||
| "onResend"
|
||||
| "onUserStop",
|
||||
(message: RenderMessage) => void
|
||||
>,
|
||||
) => {
|
||||
const className = "!p-1 hover:bg-gray-100 !rounded-actions-bar-btn";
|
||||
return [
|
||||
{
|
||||
id: "Edit",
|
||||
icons: <EditRequestIcon />,
|
||||
title: "Edit",
|
||||
className,
|
||||
onClick: () => onEdit(message),
|
||||
},
|
||||
{
|
||||
id: Locale.Chat.Actions.Copy,
|
||||
icons: <CopyRequestIcon />,
|
||||
title: Locale.Chat.Actions.Copy,
|
||||
className,
|
||||
onClick: () => onCopy(message),
|
||||
},
|
||||
{
|
||||
id: Locale.Chat.Actions.Pin,
|
||||
icons: <PinRequestIcon />,
|
||||
title: Locale.Chat.Actions.Pin,
|
||||
className,
|
||||
onClick: () => onPinMessage(message),
|
||||
},
|
||||
{
|
||||
id: Locale.Chat.Actions.Delete,
|
||||
icons: <DeleteRequestIcon />,
|
||||
title: Locale.Chat.Actions.Delete,
|
||||
className,
|
||||
onClick: () => onDelete(message),
|
||||
},
|
||||
{
|
||||
id: Locale.Chat.Actions.Retry,
|
||||
icons: <RetryRequestIcon />,
|
||||
title: Locale.Chat.Actions.Retry,
|
||||
className,
|
||||
onClick: () => onResend(message),
|
||||
},
|
||||
{
|
||||
id: Locale.Chat.Actions.Stop,
|
||||
icons: <StopIcon />,
|
||||
title: Locale.Chat.Actions.Stop,
|
||||
className,
|
||||
onClick: () => onUserStop(message),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export default function MessageActions(props: MessageActionsProps) {
|
||||
const {
|
||||
className,
|
||||
message,
|
||||
isUser,
|
||||
showActions,
|
||||
setIsLoading,
|
||||
inputRef,
|
||||
setShowPromptModal,
|
||||
} = props;
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
|
||||
const deleteMessage = (msgId?: string) => {
|
||||
chatStore.updateCurrentSession(
|
||||
(session) =>
|
||||
(session.messages = session.messages.filter((m) => m.id !== msgId)),
|
||||
);
|
||||
};
|
||||
|
||||
const onDelete = (message: ChatMessage) => {
|
||||
deleteMessage(message.id);
|
||||
};
|
||||
|
||||
const onResend = (message: ChatMessage) => {
|
||||
// when it is resending a message
|
||||
// 1. for a user's message, find the next bot response
|
||||
// 2. for a bot's message, find the last user's input
|
||||
// 3. delete original user input and bot's message
|
||||
// 4. resend the user's input
|
||||
|
||||
const resendingIndex = session.messages.findIndex(
|
||||
(m) => m.id === message.id,
|
||||
);
|
||||
|
||||
if (resendingIndex < 0 || resendingIndex >= session.messages.length) {
|
||||
console.error("[Chat] failed to find resending message", message);
|
||||
return;
|
||||
}
|
||||
|
||||
let userMessage: ChatMessage | undefined;
|
||||
let botMessage: ChatMessage | undefined;
|
||||
|
||||
if (message.role === "assistant") {
|
||||
// if it is resending a bot's message, find the user input for it
|
||||
botMessage = message;
|
||||
for (let i = resendingIndex; i >= 0; i -= 1) {
|
||||
if (session.messages[i].role === "user") {
|
||||
userMessage = session.messages[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (message.role === "user") {
|
||||
// if it is resending a user's input, find the bot's response
|
||||
userMessage = message;
|
||||
for (let i = resendingIndex; i < session.messages.length; i += 1) {
|
||||
if (session.messages[i].role === "assistant") {
|
||||
botMessage = session.messages[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userMessage === undefined) {
|
||||
console.error("[Chat] failed to resend", message);
|
||||
return;
|
||||
}
|
||||
|
||||
// delete the original messages
|
||||
deleteMessage(userMessage.id);
|
||||
deleteMessage(botMessage?.id);
|
||||
|
||||
// resend the message
|
||||
setIsLoading?.(true);
|
||||
const textContent = getMessageTextContent(userMessage);
|
||||
const images = getMessageImages(userMessage);
|
||||
chatStore
|
||||
.onUserInput(textContent, images)
|
||||
.then(() => setIsLoading?.(false));
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const onPinMessage = (message: ChatMessage) => {
|
||||
chatStore.updateCurrentSession((session) =>
|
||||
session.mask.context.push(message),
|
||||
);
|
||||
|
||||
showToast(Locale.Chat.Actions.PinToastContent, {
|
||||
text: Locale.Chat.Actions.PinToastAction,
|
||||
onClick: () => {
|
||||
setShowPromptModal?.(true);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// stop response
|
||||
const onUserStop = (message: ChatMessage) => {
|
||||
ChatControllerPool.stop(session.id, message.id);
|
||||
};
|
||||
|
||||
const onEdit = async () => {
|
||||
const newMessage = await showPrompt(
|
||||
Locale.Chat.Actions.Edit,
|
||||
getMessageTextContent(message),
|
||||
10,
|
||||
);
|
||||
let newContent: string | MultimodalContent[] = newMessage;
|
||||
const images = getMessageImages(message);
|
||||
if (images.length > 0) {
|
||||
newContent = [{ type: "text", text: newMessage }];
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
newContent.push({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: images[i],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
chatStore.updateCurrentSession((session) => {
|
||||
const m = session.mask.context
|
||||
.concat(session.messages)
|
||||
.find((m) => m.id === message.id);
|
||||
if (m) {
|
||||
m.content = newContent;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onCopy = () => copyToClipboard(getMessageTextContent(message));
|
||||
|
||||
return (
|
||||
showActions && (
|
||||
<div
|
||||
className={`transition-all duration-500 absolute z-10 ${
|
||||
isUser ? "right-0" : "left-0"
|
||||
} opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-all bg-white rounded-md shadow-actions-bar ${className}`}
|
||||
>
|
||||
<ActionsBar
|
||||
actionsShema={genActionsShema(message, {
|
||||
onCopy,
|
||||
onDelete,
|
||||
onPinMessage,
|
||||
onEdit,
|
||||
onResend,
|
||||
onUserStop,
|
||||
})}
|
||||
groups={
|
||||
message.streaming
|
||||
? [[Locale.Chat.Actions.Stop]]
|
||||
: [
|
||||
[
|
||||
Locale.Chat.Actions.Retry,
|
||||
"Edit",
|
||||
Locale.Chat.Actions.Copy,
|
||||
Locale.Chat.Actions.Pin,
|
||||
Locale.Chat.Actions.Delete,
|
||||
],
|
||||
]
|
||||
}
|
||||
className="flex flex-row gap-1 p-1"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Prompt } from "@/app/store/prompt";
|
||||
|
||||
import styles from "./index.module.scss";
|
||||
import useShowPromptHint from "@/app/hooks/useShowPromptHint";
|
||||
|
||||
export type RenderPompt = Pick<Prompt, "title" | "content">;
|
||||
|
||||
@@ -11,9 +12,13 @@ export default function PromptHints(props: {
|
||||
className?: string;
|
||||
}) {
|
||||
const noPrompts = props.prompts.length === 0;
|
||||
|
||||
const [selectIndex, setSelectIndex] = useState(0);
|
||||
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { internalPrompts, notShowPrompt } = useShowPromptHint({ ...props });
|
||||
|
||||
useEffect(() => {
|
||||
setSelectIndex(0);
|
||||
}, [props.prompts.length]);
|
||||
@@ -55,10 +60,24 @@ export default function PromptHints(props: {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.prompts.length, selectIndex]);
|
||||
|
||||
if (noPrompts) return null;
|
||||
if (!internalPrompts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles["prompt-hints"]} ${props.className}`}>
|
||||
{props.prompts.map((prompt, i) => (
|
||||
<div
|
||||
className={`
|
||||
${styles["prompt-hints"]}
|
||||
transition-all duration-300 shadow-inner rounded-none w-[100%] flex flex-col-reverse overflow-auto
|
||||
${
|
||||
notShowPrompt
|
||||
? "max-h-[0vh] border-none"
|
||||
: "border-b-[1px] pt-2.5 max-h-[50vh]"
|
||||
}
|
||||
${props.className}
|
||||
`}
|
||||
>
|
||||
{internalPrompts.map((prompt, i) => (
|
||||
<div
|
||||
ref={i === selectIndex ? selectedRef : null}
|
||||
className={
|
||||
|
@@ -3,8 +3,6 @@ import Locale from "@/app/locales";
|
||||
|
||||
import BrainIcon from "@/app/icons/brain.svg";
|
||||
|
||||
import SessionConfigModel from "./SessionConfigModal";
|
||||
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
export default function PromptToast(props: {
|
||||
@@ -30,9 +28,6 @@ export default function PromptToast(props: {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{props.showModal && (
|
||||
<SessionConfigModel onClose={() => props.setShowModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -526,25 +526,12 @@
|
||||
}
|
||||
|
||||
.prompt-hints {
|
||||
min-height: 20px;
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
|
||||
background-color: var(--white);
|
||||
border: var(--border-in-light);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: var(--shadow);
|
||||
|
||||
.prompt-hint {
|
||||
color: var(--black);
|
||||
padding: 6px 10px;
|
||||
animation: slide-in ease 0.3s;
|
||||
cursor: pointer;
|
||||
transition: all ease 0.3s;
|
||||
// animation: slide-in ease 0.3s;
|
||||
// cursor: pointer;
|
||||
// transition: all ease 0.3s;
|
||||
border: transparent 1px solid;
|
||||
margin: 4px;
|
||||
border-radius: 8px;
|
||||
@@ -573,13 +560,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input-panel-inner {
|
||||
cursor: text;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
border-radius: 10px;
|
||||
border: var(--border-in-light);
|
||||
}
|
||||
// .chat-input-panel-inner {
|
||||
// cursor: text;
|
||||
// display: flex;
|
||||
// flex: 1;
|
||||
// border-radius: 10px;
|
||||
// border: var(--border-in-light);
|
||||
// }
|
||||
|
||||
.chat-input-panel-inner-attach {
|
||||
padding-bottom: 80px;
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import React, { useState, useRef, useEffect, useMemo } from "react";
|
||||
import {
|
||||
useChatStore,
|
||||
@@ -8,7 +7,6 @@ import {
|
||||
useAppConfig,
|
||||
ModelType,
|
||||
} from "@/app/store";
|
||||
import { autoGrowTextArea, useMobileScreen } from "@/app/utils";
|
||||
import Locale from "@/app/locales";
|
||||
import { Selector, showConfirm, showToast } from "@/app/components/ui-lib";
|
||||
import {
|
||||
@@ -26,6 +24,10 @@ import ChatHeader from "./ChatHeader";
|
||||
import ChatInputPanel, { ChatInputPanelInstance } from "./ChatInputPanel";
|
||||
import ChatMessagePanel, { RenderMessage } from "./ChatMessagePanel";
|
||||
import { useAllModels } from "@/app/utils/hooks";
|
||||
import useRows from "@/app/hooks/useRows";
|
||||
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||
import SessionConfigModel from "./SessionConfigModal";
|
||||
import useScrollToBottom from "@/app/hooks/useScrollToBottom";
|
||||
|
||||
function _Chat() {
|
||||
const chatStore = useChatStore();
|
||||
@@ -47,22 +49,11 @@ function _Chat() {
|
||||
const [attachImages, setAttachImages] = useState<string[]>([]);
|
||||
|
||||
// auto grow input
|
||||
const [inputRows, setInputRows] = useState(2);
|
||||
const measure = useDebouncedCallback(
|
||||
() => {
|
||||
const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1;
|
||||
const inputRows = Math.min(
|
||||
20,
|
||||
Math.max(2 + Number(!isMobileScreen), rows),
|
||||
);
|
||||
setInputRows(inputRows);
|
||||
},
|
||||
100,
|
||||
{
|
||||
leading: true,
|
||||
trailing: true,
|
||||
},
|
||||
);
|
||||
const { measure, inputRows } = useRows({
|
||||
inputRef,
|
||||
});
|
||||
|
||||
const { setAutoScroll, scrollDomToBottom } = useScrollToBottom(scrollRef);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(measure, [userInput]);
|
||||
@@ -224,7 +215,6 @@ function _Chat() {
|
||||
}, []);
|
||||
|
||||
const chatinputPanelProps = {
|
||||
scrollRef,
|
||||
inputRef,
|
||||
isMobileScreen,
|
||||
renderMessages,
|
||||
@@ -235,9 +225,11 @@ function _Chat() {
|
||||
setAttachImages,
|
||||
setUserInput,
|
||||
setIsLoading,
|
||||
setShowPromptModal,
|
||||
showChatSetting: setShowPromptModal,
|
||||
_setMsgRenderIndex,
|
||||
showModelSelector: setShowModelSelector,
|
||||
scrollDomToBottom,
|
||||
setAutoScroll,
|
||||
};
|
||||
|
||||
const chatMessagePanelProps = {
|
||||
@@ -248,12 +240,13 @@ function _Chat() {
|
||||
userInput,
|
||||
context,
|
||||
renderMessages,
|
||||
setAutoScroll: chatInputPanelRef.current?.setAutoScroll,
|
||||
setAutoScroll,
|
||||
setMsgRenderIndex: chatInputPanelRef.current?.setMsgRenderIndex,
|
||||
setHitBottom,
|
||||
setUserInput,
|
||||
setIsLoading,
|
||||
setShowPromptModal,
|
||||
scrollDomToBottom,
|
||||
};
|
||||
|
||||
const currentModel = chatStore.currentSession().mask.modelConfig.model;
|
||||
@@ -265,9 +258,11 @@ function _Chat() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col h-[100%] overflow-hidden ${
|
||||
className={`flex flex-col ${
|
||||
isMobileScreen ? "h-[100%]" : "h-[calc(100%-1.25rem)]"
|
||||
} overflow-hidden ${
|
||||
isMobileScreen ? "" : `my-2.5 ml-1 mr-2.5 rounded-md`
|
||||
} bg-gray-50`}
|
||||
} bg-chat-panel`}
|
||||
key={session.id}
|
||||
>
|
||||
<ChatHeader
|
||||
@@ -299,6 +294,10 @@ function _Chat() {
|
||||
setShowModal={setShowPromptModal}
|
||||
/>
|
||||
|
||||
{showPromptModal && (
|
||||
<SessionConfigModel onClose={() => setShowPromptModal(false)} />
|
||||
)}
|
||||
|
||||
{showModelSelector && (
|
||||
<Selector
|
||||
defaultSelectedValue={currentModel}
|
||||
|
@@ -1,121 +0,0 @@
|
||||
import { isValidElement } from "react";
|
||||
|
||||
type IconMap = {
|
||||
active?: JSX.Element;
|
||||
inactive?: JSX.Element;
|
||||
mobileActive?: JSX.Element;
|
||||
mobileInactive?: JSX.Element;
|
||||
};
|
||||
interface Action {
|
||||
id: string;
|
||||
title?: string;
|
||||
icons: JSX.Element | IconMap;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type Groups = {
|
||||
normal: string[][];
|
||||
mobile: string[][];
|
||||
};
|
||||
|
||||
export interface TabActionsProps {
|
||||
actionsShema: Action[];
|
||||
onSelect: (id: string) => void;
|
||||
selected: string;
|
||||
groups: string[][] | Groups;
|
||||
className?: string;
|
||||
inMobile: boolean;
|
||||
}
|
||||
|
||||
export default function TabActions(props: TabActionsProps) {
|
||||
const { actionsShema, onSelect, selected, groups, className, inMobile } =
|
||||
props;
|
||||
|
||||
const handlerClick = (id: string) => (e: { preventDefault: () => void }) => {
|
||||
e.preventDefault();
|
||||
if (selected !== id) {
|
||||
onSelect?.(id);
|
||||
}
|
||||
};
|
||||
|
||||
const internalGroup = Array.isArray(groups)
|
||||
? groups
|
||||
: inMobile
|
||||
? groups.mobile
|
||||
: groups.normal;
|
||||
|
||||
const content = internalGroup.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, mobileActiveIcon, mobileInactiveIcon;
|
||||
|
||||
if (isValidElement(icons)) {
|
||||
activeIcon = icons;
|
||||
inactiveIcon = icons;
|
||||
mobileActiveIcon = icons;
|
||||
mobileInactiveIcon = icons;
|
||||
} else {
|
||||
activeIcon = (icons as IconMap).active;
|
||||
inactiveIcon = (icons as IconMap).inactive;
|
||||
mobileActiveIcon = (icons as IconMap).mobileActive;
|
||||
mobileInactiveIcon = (icons as IconMap).mobileInactive;
|
||||
}
|
||||
|
||||
if (inMobile) {
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
className={` shrink-1 grow-0 basis-[${
|
||||
(100 - 1) / arr.length
|
||||
}%] flex flex-col items-center justify-center gap-0.5
|
||||
${
|
||||
selected === action.id
|
||||
? "text-blue-700"
|
||||
: "text-gray-400"
|
||||
}
|
||||
`}
|
||||
onClick={handlerClick(action.id)}
|
||||
>
|
||||
{selected === action.id ? mobileActiveIcon : mobileInactiveIcon}
|
||||
<div className=" leading-3 text-sm-mobile-tab h-3 font-common w-[100%]">
|
||||
{action.title || " "}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
className={` ${
|
||||
selected === action.id ? "bg-blue-900" : "bg-transparent"
|
||||
} p-3 rounded-md items-center ${action.className}`}
|
||||
onClick={handlerClick(action.id)}
|
||||
>
|
||||
{selected === action.id ? activeIcon : inactiveIcon}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
);
|
||||
if (ind < arr.length - 1) {
|
||||
res.push(<div className=" flex-1"></div>);
|
||||
}
|
||||
return res;
|
||||
}, [] as JSX.Element[]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex ${
|
||||
inMobile ? "justify-around" : "flex-col"
|
||||
} items-center ${className}`}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -18,8 +18,8 @@ import dynamic from "next/dynamic";
|
||||
import useHotKey from "@/app/hooks/useHotKey";
|
||||
import useDragSideBar from "@/app/hooks/useDragSideBar";
|
||||
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||
import TabActions from "./TabActions";
|
||||
import MenuWrapper from "./MenuWrapper";
|
||||
import ActionsBar from "@/app/components/ActionsBar";
|
||||
|
||||
const SessionList = MenuWrapper(
|
||||
dynamic(async () => await import("./SessionList"), {
|
||||
@@ -72,7 +72,7 @@ export function SideBar(props: { className?: string }) {
|
||||
|
||||
return (
|
||||
<div className={`${containerClassName}`}>
|
||||
<TabActions
|
||||
<ActionsBar
|
||||
inMobile={isMobileScreen}
|
||||
actionsShema={[
|
||||
{
|
||||
@@ -133,7 +133,9 @@ export function SideBar(props: { className?: string }) {
|
||||
mobile: [[Path.Chat, Path.Masks, Path.Settings]],
|
||||
}}
|
||||
selected={selectedTab}
|
||||
className={tabActionsClassName}
|
||||
className={`${
|
||||
isMobileScreen ? "justify-around" : "flex-col"
|
||||
} ${tabActionsClassName}`}
|
||||
/>
|
||||
|
||||
<SessionList
|
||||
|
Reference in New Issue
Block a user