feat: add mask page

This commit is contained in:
Yidadaa
2023-04-25 00:49:27 +08:00
parent 708c6829f7
commit ffa7302571
22 changed files with 460 additions and 232 deletions

View File

@@ -4,7 +4,7 @@ import styles from "./button.module.scss";
export function IconButton(props: {
onClick?: () => void;
icon: JSX.Element;
icon?: JSX.Element;
text?: string;
bordered?: boolean;
shadow?: boolean;
@@ -26,11 +26,16 @@ export function IconButton(props: {
disabled={props.disabled}
role="button"
>
<div
className={styles["icon-button-icon"] + ` ${props.noDark && "no-dark"}`}
>
{props.icon}
</div>
{props.icon && (
<div
className={
styles["icon-button-icon"] + ` ${props.noDark && "no-dark"}`
}
>
{props.icon}
</div>
)}
{props.text && (
<div className={styles["icon-button-text"]}>{props.text}</div>
)}

View File

@@ -57,6 +57,8 @@ import { useNavigate } from "react-router-dom";
import { Path } from "../constant";
import { ModelConfigList } from "./model-config";
import { Avatar, AvatarPicker } from "./emoji";
import { MaskConfig } from "./mask";
import { DEFAULT_MASK_ID } from "../store/mask";
const Markdown = dynamic(
async () => memo((await import("./markdown")).Markdown),
@@ -103,103 +105,10 @@ function exportMessages(messages: Message[], topic: string) {
});
}
function ContextPrompts() {
const chatStore = useChatStore();
const session = chatStore.currentSession();
const context = session.context;
const addContextPrompt = (prompt: Message) => {
chatStore.updateCurrentSession((session) => {
session.context.push(prompt);
});
};
const removeContextPrompt = (i: number) => {
chatStore.updateCurrentSession((session) => {
session.context.splice(i, 1);
});
};
const updateContextPrompt = (i: number, prompt: Message) => {
chatStore.updateCurrentSession((session) => {
session.context[i] = prompt;
});
};
return (
<>
<div className={chatStyle["context-prompt"]} style={{ marginBottom: 20 }}>
{context.map((c, i) => (
<div className={chatStyle["context-prompt-row"]} key={i}>
<select
value={c.role}
className={chatStyle["context-role"]}
onChange={(e) =>
updateContextPrompt(i, {
...c,
role: e.target.value as any,
})
}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<Input
value={c.content}
type="text"
className={chatStyle["context-content"]}
rows={1}
onInput={(e) =>
updateContextPrompt(i, {
...c,
content: e.currentTarget.value as any,
})
}
/>
<IconButton
icon={<DeleteIcon />}
className={chatStyle["context-delete-button"]}
onClick={() => removeContextPrompt(i)}
bordered
/>
</div>
))}
<div className={chatStyle["context-prompt-row"]}>
<IconButton
icon={<AddIcon />}
text={Locale.Context.Add}
bordered
className={chatStyle["context-prompt-button"]}
onClick={() =>
addContextPrompt({
role: "system",
content: "",
date: "",
})
}
/>
</div>
</div>
</>
);
}
export function SessionConfigModel(props: { onClose: () => void }) {
const chatStore = useChatStore();
const session = chatStore.currentSession();
const [showPicker, setShowPicker] = useState(false);
const updateConfig = (updater: (config: ModelConfig) => void) => {
const config = { ...session.modelConfig };
updater(config);
chatStore.updateCurrentSession((session) => (session.modelConfig = config));
};
return (
<div className="modal-mask">
<Modal
@@ -210,7 +119,7 @@ export function SessionConfigModel(props: { onClose: () => void }) {
key="reset"
icon={<CopyIcon />}
bordered
text="重置预设"
text="重置"
onClick={() =>
confirm(Locale.Memory.ResetConfirm) && chatStore.resetSession()
}
@@ -219,69 +128,29 @@ export function SessionConfigModel(props: { onClose: () => void }) {
key="copy"
icon={<CopyIcon />}
bordered
text="保存预设"
text="保存为面具"
onClick={() => copyToClipboard(session.memoryPrompt)}
/>,
]}
>
<ContextPrompts />
<List>
<ListItem title={"角色头像"}>
<Popover
content={
<AvatarPicker
onEmojiClick={(emoji) =>
chatStore.updateCurrentSession(
(session) => (session.avatar = emoji),
)
}
></AvatarPicker>
}
open={showPicker}
onClose={() => setShowPicker(false)}
>
<div
onClick={() => setShowPicker(true)}
style={{ cursor: "pointer" }}
>
{session.avatar ? (
<Avatar avatar={session.avatar} />
) : (
<Avatar model={session.modelConfig.model} />
)}
</div>
</Popover>
</ListItem>
<ListItem title={"对话标题"}>
<input
type="text"
value={session.topic}
onInput={(e) =>
chatStore.updateCurrentSession(
(session) => (session.topic = e.currentTarget.value),
)
}
></input>
</ListItem>
</List>
<List>
<ModelConfigList
modelConfig={session.modelConfig}
updateConfig={updateConfig}
/>
{session.modelConfig.sendMemory ? (
<ListItem
title={`${Locale.Memory.Title} (${session.lastSummarizeIndex} of
${session.messages.length})`}
subTitle={session.memoryPrompt || Locale.Memory.EmptyContent}
></ListItem>
) : (
<></>
)}
</List>
<MaskConfig
mask={session.mask}
updateMask={(updater) => {
const mask = { ...session.mask };
updater(mask);
chatStore.updateCurrentSession((session) => (session.mask = mask));
}}
extraListItems={
session.mask.modelConfig.sendMemory ? (
<ListItem
title={`${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`}
subTitle={session.memoryPrompt || Locale.Memory.EmptyContent}
></ListItem>
) : (
<></>
)
}
></MaskConfig>
</Modal>
</div>
);
@@ -294,7 +163,7 @@ function PromptToast(props: {
}) {
const chatStore = useChatStore();
const session = chatStore.currentSession();
const context = session.context;
const context = session.mask.context;
return (
<div className={chatStyle["prompt-toast"]} key="prompt-toast">
@@ -617,7 +486,7 @@ export function Chat() {
inputRef.current?.focus();
};
const context: RenderMessage[] = session.context.slice();
const context: RenderMessage[] = session.mask.context.slice();
const accessStore = useAccessStore();
@@ -680,20 +549,20 @@ export function Chat() {
return (
<div className={styles.chat} key={session.id}>
<div className={styles["window-header"]}>
<div className={styles["window-header-title"]}>
<div className="window-header">
<div className="window-header-title">
<div
className={`${styles["window-header-main-title"]} ${styles["chat-body-title"]}`}
className={`window-header-main-title " ${styles["chat-body-title"]}`}
onClickCapture={renameSession}
>
{!session.topic ? DEFAULT_TOPIC : session.topic}
</div>
<div className={styles["window-header-sub-title"]}>
<div className="window-header-sub-title">
{Locale.Chat.SubTitle(session.messages.length)}
</div>
</div>
<div className={styles["window-actions"]}>
<div className={styles["window-action-button"] + " " + styles.mobile}>
<div className="window-actions">
<div className={"window-action-button" + " " + styles.mobile}>
<IconButton
icon={<ReturnIcon />}
bordered
@@ -701,14 +570,14 @@ export function Chat() {
onClick={() => navigate(Path.Home)}
/>
</div>
<div className={styles["window-action-button"]}>
<div className="window-action-button">
<IconButton
icon={<RenameIcon />}
bordered
onClick={renameSession}
/>
</div>
<div className={styles["window-action-button"]}>
<div className="window-action-button">
<IconButton
icon={<ExportIcon />}
bordered
@@ -722,7 +591,7 @@ export function Chat() {
/>
</div>
{!isMobileScreen && (
<div className={styles["window-action-button"]}>
<div className="window-action-button">
<IconButton
icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
bordered
@@ -773,10 +642,10 @@ export function Chat() {
<div className={styles["chat-message-avatar"]}>
{message.role === "user" ? (
<Avatar avatar={config.avatar} />
) : session.avatar ? (
<Avatar avatar={session.avatar} />
) : (
) : session.mask.id === DEFAULT_MASK_ID ? (
<Avatar model={message.model ?? "gpt-3.5-turbo"} />
) : (
<Avatar avatar={session.mask.avatar} />
)}
</div>
{showTyping && (

View File

@@ -1,6 +1,3 @@
@import "./window.scss";
@import "../styles/animation.scss";
@mixin container {
background-color: var(--white);
border: var(--border-in-light);

View File

@@ -45,6 +45,10 @@ const NewChat = dynamic(async () => (await import("./new-chat")).NewChat, {
loading: () => <Loading noLogo />,
});
const MaskPage = dynamic(async () => (await import("./mask")).MaskPage, {
loading: () => <Loading noLogo />,
});
export function useSwitchTheme() {
const config = useAppConfig();
@@ -109,6 +113,7 @@ function Screen() {
<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>

View File

@@ -0,0 +1,33 @@
.mask-page {
height: 100%;
display: flex;
flex-direction: column;
.mask-page-body {
padding: 20px;
overflow-y: auto;
.search-bar {
width: 100%;
max-width: 100%;
margin-bottom: 20px;
}
.mask-item {
.mask-icon {
display: flex;
align-items: center;
justify-content: center;
border: var(--border-in-light);
border-radius: 10px;
padding: 6px;
}
.mask-actions {
display: flex;
flex-wrap: nowrap;
transition: all ease 0.3s;
}
}
}
}

258
app/components/mask.tsx Normal file
View File

@@ -0,0 +1,258 @@
import { IconButton } from "./button";
import { ErrorBoundary } from "./error";
import styles from "./mask.module.scss";
import DownloadIcon from "../icons/download.svg";
import EditIcon from "../icons/edit.svg";
import AddIcon from "../icons/add.svg";
import CloseIcon from "../icons/close.svg";
import DeleteIcon from "../icons/delete.svg";
import CopyIcon from "../icons/copy.svg";
import { DEFAULT_MASK_AVATAR, DEFAULT_MASK_ID, Mask } from "../store/mask";
import {
Message,
ModelConfig,
ROLES,
useAppConfig,
useChatStore,
} from "../store";
import { Input, List, ListItem, Modal, Popover } from "./ui-lib";
import { Avatar, AvatarPicker, EmojiAvatar } from "./emoji";
import Locale from "../locales";
import { useNavigate } from "react-router-dom";
import chatStyle from "./chat.module.scss";
import { useState } from "react";
import { copyToClipboard } from "../utils";
import { Updater } from "../api/openai/typing";
import { ModelConfigList } from "./model-config";
export function MaskConfig(props: {
mask: Mask;
updateMask: Updater<Mask>;
extraListItems?: JSX.Element;
}) {
const [showPicker, setShowPicker] = useState(false);
const updateConfig = (updater: (config: ModelConfig) => void) => {
const config = { ...props.mask.modelConfig };
updater(config);
props.updateMask((mask) => (mask.modelConfig = config));
};
return (
<>
<ContextPrompts
context={props.mask.context}
updateContext={(updater) => {
const context = props.mask.context.slice();
updater(context);
props.updateMask((mask) => (mask.context = context));
}}
/>
<List>
<ListItem title={"角色头像"}>
<Popover
content={
<AvatarPicker
onEmojiClick={(emoji) => {
props.updateMask((mask) => (mask.avatar = emoji));
setShowPicker(false);
}}
></AvatarPicker>
}
open={showPicker}
onClose={() => setShowPicker(false)}
>
<div
onClick={() => setShowPicker(true)}
style={{ cursor: "pointer" }}
>
{props.mask.avatar !== DEFAULT_MASK_AVATAR ? (
<Avatar avatar={props.mask.avatar} />
) : (
<Avatar model={props.mask.modelConfig.model} />
)}
</div>
</Popover>
</ListItem>
<ListItem title={"角色名称"}>
<input
type="text"
value={props.mask.name}
onInput={(e) =>
props.updateMask((mask) => (mask.name = e.currentTarget.value))
}
></input>
</ListItem>
</List>
<List>
<ModelConfigList
modelConfig={{ ...props.mask.modelConfig }}
updateConfig={updateConfig}
/>
{props.extraListItems}
</List>
</>
);
}
export function ContextPrompts(props: {
context: Message[];
updateContext: (updater: (context: Message[]) => void) => void;
}) {
const context = props.context;
const addContextPrompt = (prompt: Message) => {
props.updateContext((context) => context.push(prompt));
};
const removeContextPrompt = (i: number) => {
props.updateContext((context) => context.splice(i, 1));
};
const updateContextPrompt = (i: number, prompt: Message) => {
props.updateContext((context) => (context[i] = prompt));
};
return (
<>
<div className={chatStyle["context-prompt"]} style={{ marginBottom: 20 }}>
{context.map((c, i) => (
<div className={chatStyle["context-prompt-row"]} key={i}>
<select
value={c.role}
className={chatStyle["context-role"]}
onChange={(e) =>
updateContextPrompt(i, {
...c,
role: e.target.value as any,
})
}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<Input
value={c.content}
type="text"
className={chatStyle["context-content"]}
rows={1}
onInput={(e) =>
updateContextPrompt(i, {
...c,
content: e.currentTarget.value as any,
})
}
/>
<IconButton
icon={<DeleteIcon />}
className={chatStyle["context-delete-button"]}
onClick={() => removeContextPrompt(i)}
bordered
/>
</div>
))}
<div className={chatStyle["context-prompt-row"]}>
<IconButton
icon={<AddIcon />}
text={Locale.Context.Add}
bordered
className={chatStyle["context-prompt-button"]}
onClick={() =>
addContextPrompt({
role: "system",
content: "",
date: "",
})
}
/>
</div>
</div>
</>
);
}
export function MaskPage() {
const config = useAppConfig();
const navigate = useNavigate();
const masks: Mask[] = new Array(10).fill(0).map((m, i) => ({
id: i,
avatar: "1f606",
name: "预设角色 " + i.toString(),
context: [
{ role: "assistant", content: "你好,有什么可以帮忙的吗", date: "" },
],
modelConfig: config.modelConfig,
lang: "cn",
}));
return (
<ErrorBoundary>
<div className={styles["mask-page"]}>
<div className="window-header">
<div className="window-header-title">
<div className="window-header-main-title"></div>
<div className="window-header-submai-title"></div>
</div>
<div className="window-actions">
<div className="window-action-button">
<IconButton icon={<AddIcon />} bordered />
</div>
<div className="window-action-button">
<IconButton icon={<DownloadIcon />} bordered />
</div>
<div className="window-action-button">
<IconButton
icon={<CloseIcon />}
bordered
onClick={() => navigate(-1)}
/>
</div>
</div>
</div>
<div className={styles["mask-page-body"]}>
<input
type="text"
className={styles["search-bar"]}
placeholder="搜索面具"
/>
<List>
{masks.map((m) => (
<ListItem
title={m.name}
key={m.id}
subTitle={`包含 ${m.context.length} 条预设对话 / ${
Locale.Settings.Lang.Options[m.lang]
} / ${m.modelConfig.model}`}
icon={
<div className={styles["mask-icon"]}>
<EmojiAvatar avatar={m.avatar} size={20} />
</div>
}
className={styles["mask-item"]}
>
<div className={styles["mask-actions"]}>
<IconButton icon={<AddIcon />} text="对话" />
<IconButton icon={<EditIcon />} text="编辑" />
<IconButton icon={<DeleteIcon />} text="删除" />
</div>
</ListItem>
))}
</List>
</div>
</div>
</ErrorBoundary>
);
}

View File

@@ -97,7 +97,7 @@ export function ModelConfigList(props: {
title={props.modelConfig.historyMessageCount.toString()}
value={props.modelConfig.historyMessageCount}
min="0"
max="25"
max="32"
step="1"
onChange={(e) =>
props.updateConfig(

View File

@@ -1,3 +1,5 @@
@import "../styles/animation.scss";
.new-chat {
height: 100%;
width: 100%;
@@ -5,11 +7,21 @@
align-items: center;
justify-content: center;
flex-direction: column;
padding-top: 80px;
.mask-header {
display: flex;
justify-content: space-between;
width: 100%;
padding: 10px;
box-sizing: border-box;
animation: slide-in-from-top ease 0.3s;
}
.mask-cards {
display: flex;
margin-top: 5vh;
margin-bottom: 20px;
animation: slide-in ease 0.3s;
.mask-card {
padding: 20px 10px;
@@ -32,15 +44,18 @@
.title {
font-size: 32px;
font-weight: bolder;
animation: slide-in ease 0.3s;
margin-bottom: 1vh;
animation: slide-in ease 0.35s;
}
.sub-title {
animation: slide-in ease 0.3s;
animation: slide-in ease 0.4s;
}
.search-bar {
margin-top: 20px;
margin-top: 5vh;
margin-bottom: 5vh;
animation: slide-in ease 0.45s;
}
.masks {
@@ -50,7 +65,7 @@
align-items: center;
padding-top: 20px;
animation: slide-in ease 0.3s;
animation: slide-in ease 0.5s;
.mask-row {
margin-bottom: 10px;

View File

@@ -1,7 +1,10 @@
import { useEffect, useRef } from "react";
import { SlotID } from "../constant";
import { IconButton } from "./button";
import { EmojiAvatar } from "./emoji";
import styles from "./new-chat.module.scss";
import LeftIcon from "../icons/left.svg";
import { useNavigate } from "react-router-dom";
function getIntersectionArea(aRect: DOMRect, bRect: DOMRect) {
const xmin = Math.max(aRect.x, bRect.x);
@@ -59,8 +62,18 @@ export function NewChat() {
})),
);
const navigate = useNavigate();
return (
<div className={styles["new-chat"]}>
<div className={styles["mask-header"]}>
<IconButton
icon={<LeftIcon />}
text="返回"
onClick={() => navigate(-1)}
></IconButton>
<IconButton text="跳过"></IconButton>
</div>
<div className={styles["mask-cards"]}>
<div className={styles["mask-card"]}>
<EmojiAvatar avatar="1f606" size={24} />
@@ -74,7 +87,9 @@ export function NewChat() {
</div>
<div className={styles["title"]}></div>
<div className={styles["sub-title"]}></div>
<div className={styles["sub-title"]}>
</div>
<input className={styles["search-bar"]} placeholder="搜索" type="text" />

View File

@@ -1,5 +1,3 @@
@import "./window.scss";
.settings {
padding: 20px;
overflow: auto;

View File

@@ -202,17 +202,17 @@ export function Settings() {
return (
<ErrorBoundary>
<div className={styles["window-header"]}>
<div className={styles["window-header-title"]}>
<div className={styles["window-header-main-title"]}>
<div className="window-header">
<div className="window-header-title">
<div className="window-header-main-title">
{Locale.Settings.Title}
</div>
<div className={styles["window-header-sub-title"]}>
<div className="window-header-sub-title">
{Locale.Settings.SubTitle}
</div>
</div>
<div className={styles["window-actions"]}>
<div className={styles["window-action-button"]}>
<div className="window-actions">
<div className="window-action-button">
<IconButton
icon={<ClearIcon />}
onClick={() => {
@@ -227,7 +227,7 @@ export function Settings() {
title={Locale.Settings.Actions.ClearAll}
/>
</div>
<div className={styles["window-action-button"]}>
<div className="window-action-button">
<IconButton
icon={<ResetIcon />}
onClick={() => {
@@ -242,7 +242,7 @@ export function Settings() {
title={Locale.Settings.Actions.ResetAll}
/>
</div>
<div className={styles["window-action-button"]}>
<div className="window-action-button">
<IconButton
icon={<CloseIcon />}
onClick={() => navigate(Path.Home)}

View File

@@ -36,14 +36,23 @@
padding: 10px 20px;
animation: slide-in ease 0.6s;
.list-item-title {
font-size: 14px;
font-weight: bolder;
}
.list-header {
display: flex;
align-items: center;
.list-item-sub-title {
font-size: 12px;
font-weight: normal;
.list-icon {
margin-right: 10px;
}
.list-item-title {
font-size: 14px;
font-weight: bolder;
}
.list-item-sub-title {
font-size: 12px;
font-weight: normal;
}
}
}

View File

@@ -37,21 +37,34 @@ export function ListItem(props: {
title: string;
subTitle?: string;
children?: JSX.Element | JSX.Element[];
icon?: JSX.Element;
className?: string;
}) {
return (
<div className={styles["list-item"]}>
<div className={styles["list-item-title"]}>
<div>{props.title}</div>
{props.subTitle && (
<div className={styles["list-item-sub-title"]}>{props.subTitle}</div>
)}
<div className={styles["list-item"] + ` ${props.className}`}>
<div className={styles["list-header"]}>
{props.icon && <div className={styles["list-icon"]}>{props.icon}</div>}
<div className={styles["list-item-title"]}>
<div>{props.title}</div>
{props.subTitle && (
<div className={styles["list-item-sub-title"]}>
{props.subTitle}
</div>
)}
</div>
</div>
{props.children}
</div>
);
}
export function List(props: { children: JSX.Element[] | JSX.Element }) {
export function List(props: {
children:
| Array<JSX.Element | null | undefined>
| JSX.Element
| null
| undefined;
}) {
return <div className={styles.list}>{props.children}</div>;
}

View File

@@ -1,37 +0,0 @@
.window-header {
padding: 14px 20px;
border-bottom: rgba(0, 0, 0, 0.1) 1px solid;
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
}
.window-header-title {
max-width: calc(100% - 100px);
overflow: hidden;
.window-header-main-title {
font-size: 20px;
font-weight: bolder;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
max-width: 50vw;
}
.window-header-sub-title {
font-size: 14px;
margin-top: 5px;
}
}
.window-actions {
display: inline-flex;
}
.window-action-button {
margin-left: 10px;
}