feat: chat panel UE done

This commit is contained in:
butterfly
2024-04-18 12:27:44 +08:00
parent 51a1d9f92a
commit b3559f99a2
39 changed files with 953 additions and 447 deletions

View File

@@ -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}

View File

@@ -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">&nbsp;</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>

View File

@@ -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 />}

View 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>
)
);
}

View File

@@ -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={

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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}

View File

@@ -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>
);
}

View File

@@ -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