feat: chat panel redesigned ui

This commit is contained in:
butterfly
2024-04-16 14:07:51 +08:00
parent 3fc9b91bf1
commit 51a1d9f92a
41 changed files with 1350 additions and 526 deletions

View File

@@ -6,7 +6,7 @@ import { ChatControllerPool } from "@/app/client/controller";
import { useAllModels } from "@/app/utils/hooks";
import { useEffect, useMemo, useState } from "react";
import { isVisionModel } from "@/app/utils";
import { Selector, showToast } from "@/app/components/ui-lib";
import { showToast } from "@/app/components/ui-lib";
import Locale from "@/app/locales";
import { Path } from "@/app/constant";
@@ -22,10 +22,12 @@ import MaskIcon from "@/app/icons/mask.svg";
import BreakIcon from "@/app/icons/break.svg";
import SettingsIcon from "@/app/icons/chat-settings.svg";
import ImageIcon from "@/app/icons/image.svg";
import 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;
@@ -34,8 +36,11 @@ export function ChatActions(props: {
showPromptModal: () => void;
scrollToBottom: () => void;
showPromptHints: () => void;
showModelSelector: (show: boolean) => void;
hitBottom: boolean;
uploading: boolean;
isMobileScreen: boolean;
className?: string;
}) {
const config = useAppConfig();
const navigate = useNavigate();
@@ -62,7 +67,6 @@ export function ChatActions(props: {
() => allModels.filter((m) => m.available),
[allModels],
);
const [showModelSelector, setShowModelSelector] = useState(false);
const [showUploadImage, setShowUploadImage] = useState(false);
useEffect(() => {
@@ -85,106 +89,159 @@ export function ChatActions(props: {
}
}, [chatStore, currentModel, models]);
const actions = [
{
onClick: stopAll,
text: Locale.Chat.InputActions.Stop,
isShow: couldStop,
icon: <StopIcon />,
placement: "left",
},
{
onClick: props.scrollToBottom,
text: Locale.Chat.InputActions.ToBottom,
isShow: !props.hitBottom,
icon: <BottomIcon />,
placement: "left",
},
{
onClick: props.showPromptModal,
text: Locale.Chat.InputActions.Settings,
isShow: props.hitBottom,
icon: <SettingsIcon />,
placement: "right",
},
{
onClick: props.uploadImage,
text: Locale.Chat.InputActions.UploadImage,
isShow: showUploadImage,
icon: props.uploading ? <LoadingButtonIcon /> : <ImageIcon />,
placement: "left",
},
{
onClick: nextTheme,
text: Locale.Chat.InputActions.Theme[theme],
isShow: true,
icon: (
<>
{theme === Theme.Auto ? (
<AutoIcon />
) : theme === Theme.Light ? (
<LightIcon />
) : theme === Theme.Dark ? (
<DarkIcon />
) : null}
</>
),
placement: "left",
},
{
onClick: props.showPromptHints,
text: Locale.Chat.InputActions.Prompt,
isShow: true,
icon: <PromptIcon />,
placement: "left",
},
{
onClick: () => {
navigate(Path.Masks);
},
text: Locale.Chat.InputActions.Masks,
isShow: true,
icon: <MaskIcon />,
placement: "left",
},
{
onClick: () => {
chatStore.updateCurrentSession((session) => {
if (session.clearContextIndex === session.messages.length) {
session.clearContextIndex = undefined;
} else {
session.clearContextIndex = session.messages.length;
session.memoryPrompt = ""; // will clear memory
}
});
},
text: Locale.Chat.InputActions.Clear,
isShow: true,
icon: <BreakIcon />,
placement: "right",
},
{
onClick: () => props.showModelSelector(true),
text: currentModel,
isShow: true,
icon: <RobotIcon />,
placement: "left",
},
] as const;
if (props.isMobileScreen) {
const content = (
<div>
{actions.map((act) => {
return (
<div
key={act.text}
className={`flex p-3 bg-white hover:bg-select-btn rounded-action-btn`}
>
{act.icon}
{act.text}
</div>
);
})}
</div>
);
return (
<Popover content={content}>
<AddCircleIcon />
</Popover>
);
}
const popoverClassName = `bg-gray-800 whitespace-nowrap px-3 py-2.5 text-white text-sm-title rounded-md`;
return (
<div className={styles["chat-input-actions"]}>
{couldStop && (
<ChatAction
onClick={stopAll}
text={Locale.Chat.InputActions.Stop}
icon={<StopIcon />}
/>
)}
{!props.hitBottom && (
<ChatAction
onClick={props.scrollToBottom}
text={Locale.Chat.InputActions.ToBottom}
icon={<BottomIcon />}
/>
)}
{props.hitBottom && (
<ChatAction
onClick={props.showPromptModal}
text={Locale.Chat.InputActions.Settings}
icon={<SettingsIcon />}
/>
)}
{showUploadImage && (
<ChatAction
onClick={props.uploadImage}
text={Locale.Chat.InputActions.UploadImage}
icon={props.uploading ? <LoadingButtonIcon /> : <ImageIcon />}
/>
)}
<ChatAction
onClick={nextTheme}
text={Locale.Chat.InputActions.Theme[theme]}
icon={
<>
{theme === Theme.Auto ? (
<AutoIcon />
) : theme === Theme.Light ? (
<LightIcon />
) : theme === Theme.Dark ? (
<DarkIcon />
) : null}
</>
}
/>
<ChatAction
onClick={props.showPromptHints}
text={Locale.Chat.InputActions.Prompt}
icon={<PromptIcon />}
/>
<ChatAction
onClick={() => {
navigate(Path.Masks);
}}
text={Locale.Chat.InputActions.Masks}
icon={<MaskIcon />}
/>
<ChatAction
text={Locale.Chat.InputActions.Clear}
icon={<BreakIcon />}
onClick={() => {
chatStore.updateCurrentSession((session) => {
if (session.clearContextIndex === session.messages.length) {
session.clearContextIndex = undefined;
} else {
session.clearContextIndex = session.messages.length;
session.memoryPrompt = ""; // will clear memory
}
});
}}
/>
<ChatAction
onClick={() => setShowModelSelector(true)}
text={currentModel}
icon={<RobotIcon />}
/>
{showModelSelector && (
<Selector
defaultSelectedValue={currentModel}
items={models.map((m) => ({
title: m.displayName,
value: m.name,
}))}
onClose={() => setShowModelSelector(false)}
onSelection={(s) => {
if (s.length === 0) return;
chatStore.updateCurrentSession((session) => {
session.mask.modelConfig.model = s[0] as ModelType;
session.mask.syncGlobalConfig = false;
});
showToast(s[0]);
}}
/>
)}
<div className={`flex gap-2 item-center ${props.className}`}>
{actions
.filter((v) => v.placement === "left" && v.isShow)
.map((act, ind) => {
return (
<Popover
key={act.text}
content={act.text}
popoverClassName={`${popoverClassName}`}
placement={ind ? "t" : "rt"}
>
<div
className="h-[32px] w-[32px] flex items-center justify-center"
onClick={act.onClick}
>
{act.icon}
</div>
</Popover>
);
})}
<div className="flex-1"></div>
{actions
.filter((v) => v.placement === "right" && v.isShow)
.map((act, ind, arr) => {
return (
<Popover
key={act.text}
content={act.text}
popoverClassName={`${popoverClassName}`}
placement={ind === arr.length - 1 ? "lt" : "t"}
>
<div
className="h-[32px] w-[32px] flex items-center justify-center"
onClick={act.onClick}
>
{act.icon}
</div>
</Popover>
);
})}
</div>
);
}

View File

@@ -1,75 +1,88 @@
import { useNavigate } from "react-router-dom";
import { IconButton } from "@/app/components/button";
import Locale from "@/app/locales";
import { Path } from "@/app/constant";
import { DEFAULT_TOPIC, useChatStore } from "@/app/store/chat";
import RenameIcon from "@/app/icons/rename.svg";
import ExportIcon from "@/app/icons/share.svg";
import ReturnIcon from "@/app/icons/return.svg";
import styles from "./index.module.scss";
import LogIcon from "@/app/icons/logIcon.svg";
import GobackIcon from "@/app/icons/goback.svg";
import ShareIcon from "@/app/icons/shareIcon.svg";
import BottomArrow from "@/app/icons/bottomArrow.svg";
export interface ChatHeaderProps {
isMobileScreen: boolean;
setIsEditingMessage: (v: boolean) => void;
setShowExport: (v: boolean) => void;
showModelSelector: (v: boolean) => void;
}
export default function ChatHeader(props: ChatHeaderProps) {
const { isMobileScreen, setIsEditingMessage, setShowExport } = props;
const {
isMobileScreen,
setIsEditingMessage,
setShowExport,
showModelSelector,
} = props;
const navigate = useNavigate();
const chatStore = useChatStore();
const session = chatStore.currentSession();
const currentModel = chatStore.currentSession().mask.modelConfig.model;
let containerClassName = "";
let titleClassName = "mr-4";
let mainTitleClassName = "";
let subTitleClassName = "";
if (isMobileScreen) {
containerClassName = "h-menu-title-mobile";
titleClassName = "flex flex-col items-center justify-center gap-0.5 text";
mainTitleClassName = "text-sm-title h-[19px] leading-5";
subTitleClassName = "text-sm-mobile-tab leading-4";
}
return (
<div className="window-header" data-tauri-drag-region>
{isMobileScreen && (
<div className="window-actions">
<div className={"window-action-button"}>
<IconButton
icon={<ReturnIcon />}
bordered
title={Locale.Chat.Actions.ChatList}
onClick={() => navigate(Path.Home)}
/>
</div>
<div
className={`flex flex-0 justify-between items-center px-6 py-4 gap-chat-header-gap border-b-[1px] border-gray-200 ${containerClassName}`}
data-tauri-drag-region
>
{isMobileScreen ? (
<div onClick={() => navigate(Path.Home)}>
<GobackIcon />
</div>
) : (
<LogIcon />
)}
<div className={`window-header-title ${styles["chat-body-title"]}`}>
<div className={`flex-1 ${titleClassName}`}>
<div
className={`window-header-main-title ${styles["chat-body-main-title"]}`}
className={`line-clamp-1 cursor-pointer text-black text-chat-header-title font-common ${mainTitleClassName}`}
onClickCapture={() => setIsEditingMessage(true)}
>
{!session.topic ? DEFAULT_TOPIC : session.topic}
</div>
<div className="window-header-sub-title">
{Locale.Chat.SubTitle(session.messages.length)}
<div className={`text-gray-500 text-sm ${subTitleClassName}`}>
{isMobileScreen ? (
<div
className="flex items-center gap-1"
onClick={() => showModelSelector(true)}
>
{currentModel}
<BottomArrow />
</div>
) : (
Locale.Chat.SubTitle(session.messages.length)
)}
</div>
</div>
<div className="window-actions">
{!isMobileScreen && (
<div className="window-action-button">
<IconButton
icon={<RenameIcon />}
bordered
onClick={() => setIsEditingMessage(true)}
/>
</div>
)}
<div className="window-action-button">
<IconButton
icon={<ExportIcon />}
bordered
title={Locale.Chat.Actions.Export}
onClick={() => {
setShowExport(true);
}}
/>
</div>
<div
onClick={() => {
setShowExport(true);
}}
>
<ShareIcon />
</div>
</div>
);

View File

@@ -36,6 +36,7 @@ export interface ChatInputPanelProps {
setIsLoading: (value: boolean) => void;
setShowPromptModal: (value: boolean) => void;
_setMsgRenderIndex: (value: number) => void;
showModelSelector: (value: boolean) => void;
}
export interface ChatInputPanelInstance {
@@ -72,6 +73,7 @@ export default forwardRef<ChatInputPanelInstance, ChatInputPanelProps>(
_setMsgRenderIndex,
hitBottom,
inputRows,
showModelSelector,
} = props;
const [uploading, setUploading] = useState(false);
@@ -222,86 +224,107 @@ export default forwardRef<ChatInputPanelInstance, ChatInputPanelProps>(
setUploading,
});
let inputClassName = " flex flex-col px-5 pb-5";
let actionsClassName = "py-2.5";
let inputTextAreaClassName = "";
if (isMobileScreen) {
inputClassName = "flex flex-row-reverse items-center gap-2 p-3";
actionsClassName = "";
inputTextAreaClassName = "";
}
return (
<div className={styles["chat-input-panel"]}>
<PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
<ChatActions
uploadImage={uploadImage}
setAttachImages={setAttachImages}
setUploading={setUploading}
showPromptModal={() => setShowPromptModal(true)}
scrollToBottom={scrollToBottom}
hitBottom={hitBottom}
uploading={uploading}
showPromptHints={() => {
// Click again to close
if (promptHints.length > 0) {
setPromptHints([]);
return;
}
inputRef.current?.focus();
setUserInput("/");
onSearch("");
}}
<div
className={`relative w-[100%] box-border border-gray-200 border-t-[1px]`}
>
<PromptHints
prompts={promptHints}
onPromptSelect={onPromptSelect}
className=""
/>
<label
className={`${styles["chat-input-panel-inner"]} ${
attachImages.length != 0
? styles["chat-input-panel-inner-attach"]
: ""
}`}
htmlFor="chat-input"
>
<textarea
id="chat-input"
ref={inputRef}
className={styles["chat-input"]}
placeholder={Locale.Chat.Input(submitKey)}
onInput={(e) => onInput(e.currentTarget.value)}
value={userInput}
onKeyDown={onInputKeyDown}
onFocus={scrollToBottom}
onClick={scrollToBottom}
onPaste={handlePaste}
rows={inputRows}
autoFocus={autoFocus}
style={{
fontSize: config.fontSize,
<div className={`${inputClassName}`}>
<ChatActions
showModelSelector={showModelSelector}
uploadImage={uploadImage}
setAttachImages={setAttachImages}
setUploading={setUploading}
showPromptModal={() => setShowPromptModal(true)}
scrollToBottom={scrollToBottom}
hitBottom={hitBottom}
uploading={uploading}
showPromptHints={() => {
// Click again to close
if (promptHints.length > 0) {
setPromptHints([]);
return;
}
inputRef.current?.focus();
setUserInput("/");
onSearch("");
}}
className={actionsClassName}
isMobileScreen={isMobileScreen}
/>
{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),
);
}}
/>
<label
className={`${styles["chat-input-panel-inner"]} ${
attachImages.length != 0
? styles["chat-input-panel-inner-attach"]
: ""
} ${inputTextAreaClassName}`}
htmlFor="chat-input"
>
<textarea
id="chat-input"
ref={inputRef}
className={styles["chat-input"]}
placeholder={Locale.Chat.Input(submitKey)}
onInput={(e) => onInput(e.currentTarget.value)}
value={userInput}
onKeyDown={onInputKeyDown}
onFocus={scrollToBottom}
onClick={scrollToBottom}
onPaste={handlePaste}
rows={inputRows}
autoFocus={autoFocus}
style={{
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>
</div>
);
})}
</div>
)}
<IconButton
icon={<SendWhiteIcon />}
text={Locale.Chat.Send}
className={styles["chat-input-send"]}
type="primary"
onClick={() => doSubmit(userInput)}
/>
</label>
);
})}
</div>
)}
<IconButton
icon={<SendWhiteIcon />}
text={Locale.Chat.Send}
className={styles["chat-input-send"]}
type="primary"
onClick={() => doSubmit(userInput)}
/>
</label>
</div>
</div>
);
},

View File

@@ -10,7 +10,6 @@ import {
getMessageTextContent,
selectOrCopy,
} from "@/app/utils";
import { IconButton } from "@/app/components/button";
import { showPrompt, showToast } from "@/app/components/ui-lib";
import CopyIcon from "@/app/icons/copy.svg";
@@ -213,7 +212,7 @@ export default function ChatMessagePanel(props: ChatMessagePanelProps) {
return (
<div
className={styles["chat-body"]}
className={`relative flex-1 overscroll-y-none overflow-x-hidden px-3 pb-5`}
ref={scrollRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
onMouseDown={() => inputRef.current?.blur()}
@@ -229,119 +228,51 @@ export default function ChatMessagePanel(props: ChatMessagePanelProps) {
i > 0 &&
!(message.preview || message.content.length === 0) &&
!isContext;
const showTyping = message.preview || message.streaming;
// const showTyping = message.preview || message.streaming;
const shouldShowClearContextDivider = i === clearContextIndex - 1;
return (
<Fragment key={message.id}>
<div
className={
isUser ? styles["chat-message-user"] : styles["chat-message"]
}
className={`flex mt-6 gap-2 ${isUser ? "flex-row-reverse" : ""}`}
>
<div className={styles["chat-message-container"]}>
<div className={styles["chat-message-header"]}>
<div className={styles["chat-message-avatar"]}>
<div className={styles["chat-message-edit"]}>
<IconButton
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;
}
});
}}
></IconButton>
</div>
{isUser ? (
<Avatar avatar={config.avatar} />
<div className={`relative flex-0`}>
{isUser ? (
<Avatar avatar={config.avatar} />
) : (
<>
{["system"].includes(message.role) ? (
<Avatar avatar="2699-fe0f" />
) : (
<>
{["system"].includes(message.role) ? (
<Avatar avatar="2699-fe0f" />
) : (
<MaskAvatar
avatar={session.mask.avatar}
model={
message.model || session.mask.modelConfig.model
}
/>
)}
</>
<MaskAvatar
avatar={session.mask.avatar}
model={message.model || session.mask.modelConfig.model}
/>
)}
</div>
{showActions && (
<div className={styles["chat-message-actions"]}>
<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))
}
/>
</>
)}
</div>
</div>
)}
</div>
{showTyping && (
<div className={styles["chat-message-status"]}>
{Locale.Chat.Typing}
</div>
</>
)}
<div className={styles["chat-message-item"]}>
</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 ${
isUser ? "right-0" : "left-0"
} bottom-[100%] hidden group-hover:block`}
>
{isContext
? Locale.Chat.IsContext
: 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 ${
isUser ? "text-right bg-message-bg" : " bg-white"
}`}
>
<Markdown
content={getMessageTextContent(message)}
loading={
@@ -357,17 +288,18 @@ export default function ChatMessagePanel(props: ChatMessagePanelProps) {
fontSize={fontSize}
parentRef={scrollRef}
defaultShow={i >= messages.length - 6}
className={isUser ? " text-white" : "text-black"}
/>
{getMessageImages(message).length == 1 && (
<img
className={styles["chat-message-item-image"]}
className={` w-[100%] mt-2.5`}
src={getMessageImages(message)[0]}
alt=""
/>
)}
{getMessageImages(message).length > 1 && (
<div
className={styles["chat-message-item-images"]}
className={`styles["chat-message-item-images"] w-[100%]`}
style={
{
"--image-count": getMessageImages(message).length,
@@ -388,11 +320,85 @@ export default function ChatMessagePanel(props: ChatMessagePanelProps) {
)}
</div>
<div className={styles["chat-message-action-date"]}>
{isContext
? Locale.Chat.IsContext
: message.date.toLocaleString()}
</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>
)}
</div>
</div>
{shouldShowClearContextDivider && <ClearContextDivider />}

View File

@@ -8,6 +8,7 @@ export type RenderPompt = Pick<Prompt, "title" | "content">;
export default function PromptHints(props: {
prompts: RenderPompt[];
onPromptSelect: (prompt: RenderPompt) => void;
className?: string;
}) {
const noPrompts = props.prompts.length === 0;
const [selectIndex, setSelectIndex] = useState(0);
@@ -56,7 +57,7 @@ export default function PromptHints(props: {
if (noPrompts) return null;
return (
<div className={styles["prompt-hints"]}>
<div className={`${styles["prompt-hints"]} ${props.className}`}>
{props.prompts.map((prompt, i) => (
<div
ref={i === selectIndex ? selectedRef : null}

View File

@@ -408,17 +408,17 @@
}
.chat-message-item {
box-sizing: border-box;
max-width: 100%;
margin-top: 10px;
border-radius: 10px;
background-color: rgba(0, 0, 0, 0.05);
padding: 10px;
font-size: 14px;
user-select: text;
word-break: break-word;
border: var(--border-in-light);
position: relative;
// box-sizing: border-box;
// max-width: 100%;
// margin-top: 10px;
// border-radius: 10px;
// background-color: rgba(0, 0, 0, 0.05);
// padding: 10px;
// font-size: 14px;
// user-select: text;
// word-break: break-word;
// border: var(--border-in-light);
// position: relative;
transition: all ease 0.3s;
}
@@ -480,19 +480,19 @@
}
}
.chat-message-action-date {
font-size: 12px;
opacity: 0.2;
white-space: nowrap;
transition: all ease 0.6s;
color: var(--black);
text-align: right;
width: 100%;
box-sizing: border-box;
padding-right: 10px;
pointer-events: none;
z-index: 1;
}
// .chat-message-action-date {
// // font-size: 12px;
// // opacity: 0.2;
// // white-space: nowrap;
// // transition: all ease 0.6s;
// // color: var(--black);
// // text-align: right;
// // width: 100%;
// // box-sizing: border-box;
// // padding-right: 10px;
// // pointer-events: none;
// // z-index: 1;
// }
.chat-message-user>.chat-message-container>.chat-message-item {
background-color: var(--second);
@@ -503,14 +503,14 @@
}
.chat-input-panel {
position: relative;
width: 100%;
padding: 20px;
padding-top: 10px;
box-sizing: border-box;
flex-direction: column;
border-top: var(--border-in-light);
box-shadow: var(--card-shadow);
// position: relative;
// width: 100%;
// padding: 20px;
// padding-top: 10px;
// box-sizing: border-box;
// flex-direction: column;
// border-top: var(--border-in-light);
// box-shadow: var(--card-shadow);
.chat-input-actions {
.chat-input-action {

View File

@@ -6,10 +6,11 @@ import {
createMessage,
useAccessStore,
useAppConfig,
ModelType,
} from "@/app/store";
import { autoGrowTextArea, useMobileScreen } from "@/app/utils";
import Locale from "@/app/locales";
import { showConfirm } from "@/app/components/ui-lib";
import { Selector, showConfirm, showToast } from "@/app/components/ui-lib";
import {
CHAT_PAGE_SIZE,
REQUEST_TIMEOUT_MS,
@@ -24,8 +25,7 @@ import { EditMessageModal } from "./EditMessageModal";
import ChatHeader from "./ChatHeader";
import ChatInputPanel, { ChatInputPanelInstance } from "./ChatInputPanel";
import ChatMessagePanel, { RenderMessage } from "./ChatMessagePanel";
import styles from "./index.module.scss";
import { useAllModels } from "@/app/utils/hooks";
function _Chat() {
const chatStore = useChatStore();
@@ -33,6 +33,7 @@ function _Chat() {
const config = useAppConfig();
const [showExport, setShowExport] = useState(false);
const [showModelSelector, setShowModelSelector] = useState(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const [userInput, setUserInput] = useState("");
@@ -236,6 +237,7 @@ function _Chat() {
setIsLoading,
setShowPromptModal,
_setMsgRenderIndex,
showModelSelector: setShowModelSelector,
};
const chatMessagePanelProps = {
@@ -254,15 +256,25 @@ function _Chat() {
setShowPromptModal,
};
const currentModel = chatStore.currentSession().mask.modelConfig.model;
const allModels = useAllModels();
const models = useMemo(
() => allModels.filter((m) => m.available),
[allModels],
);
return (
<div
className={`${styles.chat} my-2.5 ml-1 mr-2.5 rounded-md bg-gray-50`}
className={`flex flex-col h-[100%] overflow-hidden ${
isMobileScreen ? "" : `my-2.5 ml-1 mr-2.5 rounded-md`
} bg-gray-50`}
key={session.id}
>
<ChatHeader
setIsEditingMessage={setIsEditingMessage}
setShowExport={setShowExport}
isMobileScreen={isMobileScreen}
showModelSelector={setShowModelSelector}
/>
<ChatMessagePanel {...chatMessagePanelProps} />
@@ -286,6 +298,25 @@ function _Chat() {
showModal={showPromptModal}
setShowModal={setShowPromptModal}
/>
{showModelSelector && (
<Selector
defaultSelectedValue={currentModel}
items={models.map((m) => ({
title: m.displayName,
value: m.name,
}))}
onClose={() => setShowModelSelector(false)}
onSelection={(s) => {
if (s.length === 0) return;
chatStore.updateCurrentSession((session) => {
session.mask.modelConfig.model = s[0] as ModelType;
session.mask.syncGlobalConfig = false;
});
showToast(s[0]);
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { Path } from "@/app/constant";
import { ComponentType } from "react";
import { useNavigate } from "react-router-dom";
export interface MenuWrapperProps {
show: boolean;
wrapperClassName?: string;
}
export default function MenuWrapper<ComponentProps>(
Component: ComponentType<ComponentProps>,
) {
return function MenuHood(props: MenuWrapperProps & ComponentProps) {
const { show, wrapperClassName } = props;
const navigate = useNavigate();
if (!show) {
return null;
}
return (
<div
className={`flex flex-col px-6 pb-6 ${wrapperClassName}`}
onClick={(e) => {
if (e.target === e.currentTarget) {
navigate(Path.Home);
}
}}
>
<Component {...props} />
</div>
);
};
}

View File

@@ -134,11 +134,17 @@ export default function SessionList(props: ListHoodProps) {
moveSession(source.index, destination.index);
};
let layoutClassName = "py-7 px-0";
if (isMobileScreen) {
layoutClassName = "h-menu-title-mobile py-6";
}
return (
<>
<div data-tauri-drag-region>
<div
className="flex items-center justify-between py-7 px-0"
className={`flex items-center justify-between ${layoutClassName}`}
data-tauri-drag-region
>
<div className="">

View File

@@ -0,0 +1,121 @@
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

@@ -1,4 +1,3 @@
import { useMemo } from "react";
import DragIcon from "@/app/icons/drag.svg";
import DiscoverIcon from "@/app/icons/discoverActive.svg";
import AssistantActiveIcon from "@/app/icons/assistantActive.svg";
@@ -7,30 +6,34 @@ import SettingIcon from "@/app/icons/settingActive.svg";
import DiscoverInactiveIcon from "@/app/icons/discoverInactive.svg";
import AssistantInactiveIcon from "@/app/icons/assistantInactive.svg";
import SettingInactiveIcon from "@/app/icons/settingInactive.svg";
import SettingMobileActive from "@/app/icons/settingMobileActive.svg";
import DiscoverMobileActive from "@/app/icons/discoverMobileActive.svg";
import AssistantMobileActive from "@/app/icons/assistantMobileActive.svg";
import AssistantMobileInactive from "@/app/icons/assistantMobileInactive.svg";
import { useAppConfig, useChatStore } from "@/app/store";
import { useAppConfig } from "@/app/store";
import { Path, REPO_URL } from "@/app/constant";
import { useNavigate, useLocation } from "react-router-dom";
import { isIOS } from "@/app/utils";
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 "@/app/components/TabActions";
import TabActions from "./TabActions";
import MenuWrapper from "./MenuWrapper";
const SessionList = dynamic(async () => await import("./SessionList"), {
loading: () => null,
});
const SessionList = MenuWrapper(
dynamic(async () => await import("./SessionList"), {
loading: () => null,
}),
);
const SettingList = dynamic(async () => await import("./SettingList"), {
loading: () => null,
});
const SettingList = MenuWrapper(
dynamic(async () => await import("./SettingList"), {
loading: () => null,
}),
);
export function SideBar(props: { className?: string }) {
const chatStore = useChatStore();
// drag side bar
const { onDragStart } = useDragSideBar();
@@ -39,10 +42,6 @@ export function SideBar(props: { className?: string }) {
const config = useAppConfig();
const isMobileScreen = useMobileScreen();
const isIOSMobile = useMemo(
() => isIOS() && isMobileScreen,
[isMobileScreen],
);
useHotKey();
@@ -60,29 +59,41 @@ export function SideBar(props: { className?: string }) {
selectedTab = Path.Chat;
}
let containerClassName = "relative flex h-[100%] w-[100%]";
let tabActionsClassName = "2xl:px-5 xl:px-4 px-2 py-6";
let menuClassName =
"max-md:px-4 max-md:pb-4 rounded-md my-2.5 bg-gray-50 flex-1";
if (isMobileScreen) {
containerClassName = "flex flex-col-reverse w-[100%] h-[100%]";
tabActionsClassName = "bg-gray-100 rounded-tl-md rounded-tr-md h-mobile";
menuClassName = `flex-1 px-4`;
}
return (
<div
className={` inline-flex h-[100%] ${props.className} relative`}
style={{
// #3016 disable transition on ios mobile screen
transition: isMobileScreen && isIOSMobile ? "none" : undefined,
}}
>
<div className={`${containerClassName}`}>
<TabActions
inMobile={isMobileScreen}
actionsShema={[
{
id: Path.Masks,
icons: {
active: <DiscoverIcon />,
inactive: <DiscoverInactiveIcon />,
mobileActive: <DiscoverMobileActive />,
mobileInactive: <DiscoverInactiveIcon />,
},
title: "Discover",
},
{
id: Path.Chat,
icons: {
active: <AssistantActiveIcon />,
inactive: <AssistantInactiveIcon />,
mobileActive: <AssistantMobileActive />,
mobileInactive: <AssistantMobileInactive />,
},
title: "Assistant",
},
{
id: "github",
@@ -94,8 +105,11 @@ export function SideBar(props: { className?: string }) {
icons: {
active: <SettingIcon />,
inactive: <SettingInactiveIcon />,
mobileActive: <SettingMobileActive />,
mobileInactive: <SettingInactiveIcon />,
},
className: "p-2",
title: "Settrings",
},
]}
onSelect={(id) => {
@@ -111,27 +125,25 @@ export function SideBar(props: { className?: string }) {
navigate(Path.Masks, { state: { fromHome: true } });
}
}}
groups={[
[Path.Chat, Path.Masks],
["github", Path.Settings],
]}
groups={{
normal: [
[Path.Chat, Path.Masks],
["github", Path.Settings],
],
mobile: [[Path.Chat, Path.Masks, Path.Settings]],
}}
selected={selectedTab}
className="px-5 py-6"
className={tabActionsClassName}
/>
<div
className={`flex flex-col w-md lg:w-lg 2xl:w-2xl px-6 pb-6 max-md:px-4 max-md:pb-4 bg-gray-50 rounded-md my-2.5 ${
isMobileScreen && `bg-gray-300`
}`}
onClick={(e) => {
if (e.target === e.currentTarget) {
navigate(Path.Home);
}
}}
>
{selectedTab === Path.Chat && <SessionList />}
{loc.pathname === Path.Settings && <SettingList />}
</div>
<SessionList
show={selectedTab === Path.Chat}
wrapperClassName={menuClassName}
/>
<SettingList
show={selectedTab === Path.Settings}
wrapperClassName={menuClassName}
/>
{!isMobileScreen && (
<div

117
app/containers/index.tsx Normal file
View File

@@ -0,0 +1,117 @@
"use client";
require("../polyfill");
import { HashRouter as Router, Routes, Route } from "react-router-dom";
import { useState, useEffect } from "react";
import dynamic from "next/dynamic";
import { Path } from "@/app/constant";
import { ErrorBoundary } from "@/app/components/error";
import { getISOLang } from "@/app/locales";
import { useSwitchTheme } from "@/app/hooks/useSwitchTheme";
import { AuthPage } from "@/app/components/auth";
import { getClientConfig } from "@/app/config/client";
import { useAccessStore } from "@/app/store";
import { useLoadData } from "@/app/hooks/useLoadData";
import Loading from "@/app/components/Loading";
import Screen from "@/app/components/Screen";
import { SideBar } from "./Sidebar";
const Settings = dynamic(
async () => (await import("@/app/components/settings")).Settings,
{
loading: () => <Loading noLogo />,
},
);
const Chat = dynamic(async () => await import("@/app/containers/Chat"), {
loading: () => <Loading noLogo />,
});
const NewChat = dynamic(
async () => (await import("@/app/components/new-chat")).NewChat,
{
loading: () => <Loading noLogo />,
},
);
const MaskPage = dynamic(
async () => (await import("@/app/components/mask")).MaskPage,
{
loading: () => <Loading noLogo />,
},
);
function useHtmlLang() {
useEffect(() => {
const lang = getISOLang();
const htmlLang = document.documentElement.lang;
if (lang !== htmlLang) {
document.documentElement.lang = lang;
}
}, []);
}
const useHasHydrated = () => {
const [hasHydrated, setHasHydrated] = useState<boolean>(false);
useEffect(() => {
setHasHydrated(true);
}, []);
return hasHydrated;
};
const loadAsyncGoogleFont = () => {
const linkEl = document.createElement("link");
const proxyFontUrl = "/google-fonts";
const remoteFontUrl = "https://fonts.googleapis.com";
const googleFontUrl =
getClientConfig()?.buildMode === "export" ? remoteFontUrl : proxyFontUrl;
linkEl.rel = "stylesheet";
linkEl.href =
googleFontUrl +
"/css2?family=" +
encodeURIComponent("Noto Sans:wght@300;400;700;900") +
"&display=swap";
document.head.appendChild(linkEl);
};
export default function Home() {
useSwitchTheme();
useLoadData();
useHtmlLang();
useEffect(() => {
console.log("[Config] got config from build time", getClientConfig());
useAccessStore.getState().fetch();
}, []);
useEffect(() => {
loadAsyncGoogleFont();
}, []);
if (!useHasHydrated()) {
return <Loading />;
}
return (
<ErrorBoundary>
<Router>
<Screen noAuth={<AuthPage />} sidebar={<SideBar />}>
<ErrorBoundary>
<Routes>
<Route path={Path.Home} element={<Chat />} />
<Route path={Path.NewChat} element={<NewChat />} />
<Route path={Path.Masks} element={<MaskPage />} />
<Route path={Path.Chat} element={<Chat />} />
<Route path={Path.Settings} element={<Settings />} />
</Routes>
</ErrorBoundary>
</Screen>
</Router>
</ErrorBoundary>
);
}