This commit is contained in:
GH Action - Upstream Sync
2023-04-27 18:13:54 +00:00
64 changed files with 2615 additions and 714 deletions

View File

@@ -18,6 +18,15 @@
cursor: not-allowed;
opacity: 0.5;
}
&.primary {
background-color: var(--primary);
color: white;
path {
fill: white !important;
}
}
}
.shadow {

View File

@@ -4,11 +4,11 @@ import styles from "./button.module.scss";
export function IconButton(props: {
onClick?: () => void;
icon: JSX.Element;
icon?: JSX.Element;
type?: "primary" | "danger";
text?: string;
bordered?: boolean;
shadow?: boolean;
noDark?: boolean;
className?: string;
title?: string;
disabled?: boolean;
@@ -19,18 +19,24 @@ export function IconButton(props: {
styles["icon-button"] +
` ${props.bordered && styles.border} ${props.shadow && styles.shadow} ${
props.className ?? ""
} clickable`
} clickable ${styles[props.type ?? ""]}`
}
onClick={props.onClick}
title={props.title}
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.type === "primary" && "no-dark"}`
}
>
{props.icon}
</div>
)}
{props.text && (
<div className={styles["icon-button-text"]}>{props.text}</div>
)}

View File

@@ -1,4 +1,6 @@
import DeleteIcon from "../icons/delete.svg";
import BotIcon from "../icons/bot.svg";
import styles from "./home.module.scss";
import {
DragDropContext,
@@ -12,6 +14,8 @@ import { useChatStore } from "../store";
import Locale from "../locales";
import { Link, useNavigate } from "react-router-dom";
import { Path } from "../constant";
import { MaskAvatar } from "./mask";
import { Mask } from "../store/mask";
export function ChatItem(props: {
onClick?: () => void;
@@ -23,6 +27,7 @@ export function ChatItem(props: {
id: number;
index: number;
narrow?: boolean;
mask: Mask;
}) {
return (
<Draggable draggableId={`${props.id}`} index={props.index}>
@@ -35,9 +40,19 @@ export function ChatItem(props: {
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
title={`${props.title}\n${Locale.ChatItem.ChatItemCount(
props.count,
)}`}
>
{props.narrow ? (
<div className={styles["chat-item-narrow"]}>{props.count}</div>
<div className={styles["chat-item-narrow"]}>
<div className={styles["chat-item-avatar"] + " no-dark"}>
<MaskAvatar mask={props.mask} />
</div>
<div className={styles["chat-item-narrow-count"]}>
{props.count}
</div>
</div>
) : (
<>
<div className={styles["chat-item-title"]}>{props.title}</div>
@@ -45,7 +60,9 @@ export function ChatItem(props: {
<div className={styles["chat-item-count"]}>
{Locale.ChatItem.ChatItemCount(props.count)}
</div>
<div className={styles["chat-item-date"]}>{props.time}</div>
<div className={styles["chat-item-date"]}>
{new Date(props.time).toLocaleString()}
</div>
</div>
</>
)}
@@ -99,7 +116,7 @@ export function ChatList(props: { narrow?: boolean }) {
{sessions.map((item, i) => (
<ChatItem
title={item.topic}
time={item.lastUpdate}
time={new Date(item.lastUpdate).toLocaleString()}
count={item.messages.length}
key={item.id}
id={item.id}
@@ -115,6 +132,7 @@ export function ChatList(props: { narrow?: boolean }) {
}
}}
narrow={props.narrow}
mask={item.mask}
/>
))}
{provided.placeholder}

View File

@@ -53,6 +53,20 @@
}
}
.section-title {
font-size: 12px;
font-weight: bold;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
.section-title-action {
display: flex;
align-items: center;
}
}
.context-prompt {
.context-prompt-row {
display: flex;
@@ -81,25 +95,13 @@
}
.memory-prompt {
margin-top: 20px;
.memory-prompt-title {
font-size: 12px;
font-weight: bold;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
.memory-prompt-action {
display: flex;
align-items: center;
}
}
margin: 20px 0;
.memory-prompt-content {
background-color: var(--gray);
border-radius: 6px;
background-color: var(--white);
color: var(--black);
border: var(--border-in-light);
border-radius: 10px;
padding: 10px;
font-size: 12px;
user-select: text;

View File

@@ -1,4 +1,4 @@
import { useDebounce, useDebouncedCallback } from "use-debounce";
import { useDebouncedCallback } from "use-debounce";
import { memo, useState, useRef, useEffect, useLayoutEffect } from "react";
import SendWhiteIcon from "../icons/send-white.svg";
@@ -9,12 +9,11 @@ import ReturnIcon from "../icons/return.svg";
import CopyIcon from "../icons/copy.svg";
import DownloadIcon from "../icons/download.svg";
import LoadingIcon from "../icons/three-dots.svg";
import BotIcon from "../icons/bot.svg";
import BlackBotIcon from "../icons/black-bot.svg";
import AddIcon from "../icons/add.svg";
import DeleteIcon from "../icons/delete.svg";
import PromptIcon from "../icons/prompt.svg";
import MaskIcon from "../icons/mask.svg";
import MaxIcon from "../icons/max.svg";
import MinIcon from "../icons/min.svg";
import ResetIcon from "../icons/reload.svg";
import LightIcon from "../icons/light.svg";
import DarkIcon from "../icons/dark.svg";
@@ -31,14 +30,14 @@ import {
createMessage,
useAccessStore,
Theme,
ModelType,
useAppConfig,
ModelConfig,
DEFAULT_TOPIC,
} from "../store";
import {
copyToClipboard,
downloadAs,
getEmojiUrl,
selectOrCopy,
autoGrowTextArea,
useMobileScreen,
@@ -54,9 +53,16 @@ import { IconButton } from "./button";
import styles from "./home.module.scss";
import chatStyle from "./chat.module.scss";
import { Input, Modal, showModal } from "./ui-lib";
import { ListItem, Modal, showModal } from "./ui-lib";
import { useNavigate } from "react-router-dom";
import { Path } from "../constant";
import { Avatar } from "./emoji";
import { MaskAvatar, MaskConfig } from "./mask";
import {
DEFAULT_MASK_AVATAR,
DEFAULT_MASK_ID,
useMaskStore,
} from "../store/mask";
const Markdown = dynamic(
async () => memo((await import("./markdown")).Markdown),
@@ -65,32 +71,6 @@ const Markdown = dynamic(
},
);
const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, {
loading: () => <LoadingIcon />,
});
export function Avatar(props: { role: Message["role"]; model?: ModelType }) {
const config = useAppConfig();
if (props.role !== "user") {
return (
<div className="no-dark">
{props.model?.startsWith("gpt-4") ? (
<BlackBotIcon className={styles["user-avtar"]} />
) : (
<BotIcon className={styles["user-avtar"]} />
)}
</div>
);
}
return (
<div className={styles["user-avtar"]}>
<Emoji unified={config.avatar} size={18} getEmojiUrl={getEmojiUrl} />
</div>
);
}
function exportMessages(messages: Message[], topic: string) {
const mdText =
`# ${topic}\n\n` +
@@ -129,6 +109,64 @@ function exportMessages(messages: Message[], topic: string) {
});
}
export function SessionConfigModel(props: { onClose: () => void }) {
const chatStore = useChatStore();
const session = chatStore.currentSession();
const maskStore = useMaskStore();
const navigate = useNavigate();
return (
<div className="modal-mask">
<Modal
title={Locale.Context.Edit}
onClose={() => props.onClose()}
actions={[
<IconButton
key="reset"
icon={<ResetIcon />}
bordered
text={Locale.Chat.Config.Reset}
onClick={() =>
confirm(Locale.Memory.ResetConfirm) && chatStore.resetSession()
}
/>,
<IconButton
key="copy"
icon={<CopyIcon />}
bordered
text={Locale.Chat.Config.SaveAs}
onClick={() => {
navigate(Path.Masks);
setTimeout(() => {
maskStore.create(session.mask);
}, 500);
}}
/>,
]}
>
<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>
);
}
function PromptToast(props: {
showToast?: boolean;
showModal?: boolean;
@@ -136,25 +174,7 @@ function PromptToast(props: {
}) {
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;
});
};
const context = session.mask.context;
return (
<div className={chatStyle["prompt-toast"]} key="prompt-toast">
@@ -171,115 +191,7 @@ function PromptToast(props: {
</div>
)}
{props.showModal && (
<div className="modal-mask">
<Modal
title={Locale.Context.Edit}
onClose={() => props.setShowModal(false)}
actions={[
<IconButton
key="reset"
icon={<CopyIcon />}
bordered
text={Locale.Memory.Reset}
onClick={() =>
confirm(Locale.Memory.ResetConfirm) &&
chatStore.resetSession()
}
/>,
<IconButton
key="copy"
icon={<CopyIcon />}
bordered
text={Locale.Memory.Copy}
onClick={() => copyToClipboard(session.memoryPrompt)}
/>,
]}
>
<>
<div className={chatStyle["context-prompt"]}>
{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>
<div className={chatStyle["memory-prompt"]}>
<div className={chatStyle["memory-prompt-title"]}>
<span>
{Locale.Memory.Title} ({session.lastSummarizeIndex} of{" "}
{session.messages.length})
</span>
<label className={chatStyle["memory-prompt-action"]}>
{Locale.Memory.Send}
<input
type="checkbox"
checked={session.sendMemory}
onChange={() =>
chatStore.updateCurrentSession(
(session) =>
(session.sendMemory = !session.sendMemory),
)
}
></input>
</label>
</div>
<div className={chatStyle["memory-prompt-content"]}>
{session.memoryPrompt || Locale.Memory.EmptyContent}
</div>
</div>
</>
</Modal>
</div>
<SessionConfigModel onClose={() => props.setShowModal(false)} />
)}
</div>
);
@@ -360,9 +272,11 @@ function useScrollToBottom() {
export function ChatActions(props: {
showPromptModal: () => void;
scrollToBottom: () => void;
showPromptHints: () => void;
hitBottom: boolean;
}) {
const config = useAppConfig();
const navigate = useNavigate();
// switch themes
const theme = config.theme;
@@ -417,6 +331,22 @@ export function ChatActions(props: {
<DarkIcon />
) : null}
</div>
<div
className={`${chatStyle["chat-input-action"]} clickable`}
onClick={props.showPromptHints}
>
<PromptIcon />
</div>
<div
className={`${chatStyle["chat-input-action"]} clickable`}
onClick={() => {
navigate(Path.Masks);
}}
>
<MaskIcon />
</div>
</div>
);
}
@@ -459,9 +389,9 @@ export function Chat() {
);
const onPromptSelect = (prompt: Prompt) => {
setUserInput(prompt.content);
setPromptHints([]);
inputRef.current?.focus();
setUserInput(prompt.content);
};
// auto grow input
@@ -585,7 +515,7 @@ export function Chat() {
inputRef.current?.focus();
};
const context: RenderMessage[] = session.context.slice();
const context: RenderMessage[] = session.mask.context.slice();
const accessStore = useAccessStore();
@@ -648,20 +578,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}
{!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
@@ -669,14 +599,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
@@ -690,7 +620,7 @@ export function Chat() {
/>
</div>
{!isMobileScreen && (
<div className={styles["window-action-button"]}>
<div className="window-action-button">
<IconButton
icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
bordered
@@ -739,7 +669,11 @@ export function Chat() {
>
<div className={styles["chat-message-container"]}>
<div className={styles["chat-message-avatar"]}>
<Avatar role={message.role} model={message.model} />
{message.role === "user" ? (
<Avatar avatar={config.avatar} />
) : (
<MaskAvatar mask={session.mask} />
)}
</div>
{showTyping && (
<div className={styles["chat-message-status"]}>
@@ -816,6 +750,10 @@ export function Chat() {
showPromptModal={() => setShowPromptModal(true)}
scrollToBottom={scrollToBottom}
hitBottom={hitBottom}
showPromptHints={() => {
inputRef.current?.focus();
onSearch("");
}}
/>
<div className={styles["chat-input-panel-inner"]}>
<textarea
@@ -827,8 +765,12 @@ export function Chat() {
onKeyDown={onInputKeyDown}
onFocus={() => setAutoScroll(true)}
onBlur={() => {
setAutoScroll(false);
setTimeout(() => setPromptHints([]), 500);
setTimeout(() => {
if (document.activeElement !== inputRef.current) {
setAutoScroll(false);
setPromptHints([]);
}
}, 100);
}}
autoFocus
rows={inputRows}
@@ -837,7 +779,7 @@ export function Chat() {
icon={<SendWhiteIcon />}
text={Locale.Chat.Send}
className={styles["chat-input-send"]}
noDark
type="primary"
onClick={onUserSubmit}
/>
</div>

59
app/components/emoji.tsx Normal file
View File

@@ -0,0 +1,59 @@
import EmojiPicker, {
Emoji,
EmojiStyle,
Theme as EmojiTheme,
} from "emoji-picker-react";
import { ModelType } from "../store";
import BotIcon from "../icons/bot.svg";
import BlackBotIcon from "../icons/black-bot.svg";
export function getEmojiUrl(unified: string, style: EmojiStyle) {
return `https://cdn.staticfile.org/emoji-datasource-apple/14.0.0/img/${style}/64/${unified}.png`;
}
export function AvatarPicker(props: {
onEmojiClick: (emojiId: string) => void;
}) {
return (
<EmojiPicker
lazyLoadEmojis
theme={EmojiTheme.AUTO}
getEmojiUrl={getEmojiUrl}
onEmojiClick={(e) => {
props.onEmojiClick(e.unified);
}}
/>
);
}
export function Avatar(props: { model?: ModelType; avatar?: string }) {
if (props.model) {
return (
<div className="no-dark">
{props.model?.startsWith("gpt-4") ? (
<BlackBotIcon className="user-avatar" />
) : (
<BotIcon className="user-avatar" />
)}
</div>
);
}
return (
<div className="user-avatar">
{props.avatar && <EmojiAvatar avatar={props.avatar} />}
</div>
);
}
export function EmojiAvatar(props: { avatar: string; size?: number }) {
return (
<Emoji
unified={props.avatar}
size={props.size ?? 18}
getEmojiUrl={getEmojiUrl}
/>
);
}

View File

@@ -1,7 +1,10 @@
import React from "react";
import { IconButton } from "./button";
import GithubIcon from "../icons/github.svg";
import ResetIcon from "../icons/reload.svg";
import { ISSUE_URL } from "../constant";
import Locale from "../locales";
import { downloadAs } from "../utils";
interface IErrorBoundaryState {
hasError: boolean;
@@ -20,6 +23,18 @@ export class ErrorBoundary extends React.Component<any, IErrorBoundaryState> {
this.setState({ hasError: true, error, info });
}
clearAndSaveData() {
try {
downloadAs(
JSON.stringify(localStorage),
"chatgpt-next-web-snapshot.json",
);
} finally {
localStorage.clear();
location.reload();
}
}
render() {
if (this.state.hasError) {
// Render error message
@@ -31,13 +46,24 @@ export class ErrorBoundary extends React.Component<any, IErrorBoundaryState> {
<code>{this.state.info?.componentStack}</code>
</pre>
<a href={ISSUE_URL} className="report">
<div style={{ display: "flex", justifyContent: "space-between" }}>
<a href={ISSUE_URL} className="report">
<IconButton
text="Report This Error"
icon={<GithubIcon />}
bordered
/>
</a>
<IconButton
text="Report This Error"
icon={<GithubIcon />}
icon={<ResetIcon />}
text="Clear All Data"
onClick={() =>
confirm(Locale.Settings.Actions.ConfirmClearAll) &&
this.clearAndSaveData()
}
bordered
/>
</a>
</div>
</div>
);
}

View File

@@ -1,6 +1,3 @@
@import "./window.scss";
@import "../styles/animation.scss";
@mixin container {
background-color: var(--white);
border: var(--border-in-light);
@@ -51,6 +48,19 @@
box-shadow: inset -2px 0px 2px 0px rgb(0, 0, 0, 0.05);
position: relative;
transition: width ease 0.05s;
.sidebar-header-bar {
display: flex;
margin-bottom: 20px;
.sidebar-bar-button {
flex-grow: 1;
&:not(:last-child) {
margin-right: 10px;
}
}
}
}
.sidebar-drag {
@@ -138,9 +148,7 @@
.sidebar-body {
flex: 1;
overflow: auto;
}
.chat-list {
overflow-x: hidden;
}
.chat-item {
@@ -154,7 +162,6 @@
user-select: none;
border: 2px solid transparent;
position: relative;
overflow: hidden;
}
.chat-item:hover {
@@ -221,6 +228,17 @@
justify-content: center;
}
.sidebar-header-bar {
flex-direction: column;
.sidebar-bar-button {
&:not(:last-child) {
margin-right: 0;
margin-bottom: 10px;
}
}
}
.chat-item {
padding: 0;
min-height: 50px;
@@ -228,6 +246,7 @@
justify-content: center;
align-items: center;
transition: all ease 0.3s;
overflow: hidden;
&:hover {
.chat-item-narrow {
@@ -237,15 +256,31 @@
}
.chat-item-narrow {
font-weight: bolder;
font-size: 24px;
line-height: 0;
font-weight: lighter;
color: var(--black);
transform: translateX(0);
transition: all ease 0.3s;
opacity: 0.1;
padding: 4px;
display: flex;
flex-direction: column;
justify-content: center;
.chat-item-avatar {
display: flex;
justify-content: center;
opacity: 0.2;
position: absolute;
transform: scale(4);
}
.chat-item-narrow-count {
font-size: 24px;
font-weight: bolder;
text-align: center;
color: var(--primary);
opacity: 0.6;
}
}
.chat-item-delete {
@@ -258,16 +293,16 @@
}
.sidebar-tail {
flex-direction: column;
flex-direction: column-reverse;
align-items: center;
.sidebar-actions {
flex-direction: column;
flex-direction: column-reverse;
align-items: center;
.sidebar-action {
margin-right: 0;
margin-bottom: 15px;
margin-top: 15px;
}
}
}
@@ -354,17 +389,6 @@
margin-top: 5px;
}
.user-avtar {
height: 30px;
width: 30px;
display: flex;
align-items: center;
justify-content: center;
border: var(--border-in-light);
box-shadow: var(--card-shadow);
border-radius: 10px;
}
.chat-message-item {
box-sizing: border-box;
max-width: 100%;

View File

@@ -2,7 +2,7 @@
require("../polyfill");
import { useState, useEffect, StyleHTMLAttributes } from "react";
import { useState, useEffect } from "react";
import styles from "./home.module.scss";
@@ -10,10 +10,9 @@ import BotIcon from "../icons/bot.svg";
import LoadingIcon from "../icons/three-dots.svg";
import { getCSSVar, useMobileScreen } from "../utils";
import { Chat } from "./chat";
import dynamic from "next/dynamic";
import { Path } from "../constant";
import { Path, SlotID } from "../constant";
import { ErrorBoundary } from "./error";
import {
@@ -38,6 +37,18 @@ const Settings = dynamic(async () => (await import("./settings")).Settings, {
loading: () => <Loading noLogo />,
});
const Chat = dynamic(async () => (await import("./chat")).Chat, {
loading: () => <Loading noLogo />,
});
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();
@@ -79,39 +90,30 @@ const useHasHydrated = () => {
return hasHydrated;
};
function WideScreen() {
function Screen() {
const config = useAppConfig();
const location = useLocation();
const isHome = location.pathname === Path.Home;
const isMobileScreen = useMobileScreen();
return (
<div
className={`${
config.tightBorder ? styles["tight-container"] : styles.container
}`}
className={
styles.container +
` ${
config.tightBorder && !isMobileScreen
? styles["tight-container"]
: styles.container
}`
}
>
<SideBar />
<div className={styles["window-content"]}>
<Routes>
<Route path={Path.Home} element={<Chat />} />
<Route path={Path.Chat} element={<Chat />} />
<Route path={Path.Settings} element={<Settings />} />
</Routes>
</div>
</div>
);
}
function MobileScreen() {
const location = useLocation();
const isHome = location.pathname === Path.Home;
return (
<div className={styles.container}>
<SideBar className={isHome ? styles["sidebar-show"] : ""} />
<div className={styles["window-content"]}>
<div className={styles["window-content"]} id={SlotID.AppBody}>
<Routes>
<Route path={Path.Home} element={null} />
<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>
@@ -121,7 +123,6 @@ function MobileScreen() {
}
export function Home() {
const isMobileScreen = useMobileScreen();
useSwitchTheme();
if (!useHasHydrated()) {
@@ -130,7 +131,9 @@ export function Home() {
return (
<ErrorBoundary>
<Router>{isMobileScreen ? <MobileScreen /> : <WideScreen />}</Router>
<Router>
<Screen />
</Router>
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,124 @@
@import "../styles/animation.scss";
@keyframes search-in {
from {
opacity: 0;
transform: translateY(5vh) scaleX(0.5);
}
to {
opacity: 1;
transform: translateY(0) scaleX(1);
}
}
.mask-page {
height: 100%;
display: flex;
flex-direction: column;
.mask-page-body {
padding: 20px;
overflow-y: auto;
.mask-filter {
width: 100%;
max-width: 100%;
margin-bottom: 10px;
animation: search-in ease 0.3s;
display: flex;
.search-bar {
flex-grow: 1;
max-width: 100%;
min-width: 0;
margin-bottom: 20px;
animation: search-in ease 0.3s;
}
.mask-filter-lang {
height: 100%;
margin-left: 10px;
}
.mask-create {
height: 100%;
margin-left: 10px;
box-sizing: border-box;
button {
padding: 10px;
}
}
}
.mask-item {
display: flex;
justify-content: space-between;
padding: 20px;
border: var(--border-in-light);
animation: slide-in ease 0.3s;
&:not(:last-child) {
border-bottom: 0;
}
&:first-child {
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
&:last-child {
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
}
.mask-header {
display: flex;
align-items: center;
.mask-icon {
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
}
.mask-title {
.mask-name {
font-size: 14px;
font-weight: bold;
}
.mask-info {
font-size: 12px;
}
}
}
.mask-actions {
display: flex;
flex-wrap: nowrap;
transition: all ease 0.3s;
}
@media screen and (max-width: 600px) {
display: flex;
flex-direction: column;
padding-bottom: 10px;
border-radius: 10px;
margin-bottom: 20px;
box-shadow: var(--card-shadow);
&:not(:last-child) {
border-bottom: var(--border-in-light);
}
.mask-actions {
width: 100%;
justify-content: space-between;
padding-top: 10px;
}
}
}
}
}

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

@@ -0,0 +1,397 @@
import { IconButton } from "./button";
import { ErrorBoundary } from "./error";
import styles from "./mask.module.scss";
import DownloadIcon from "../icons/download.svg";
import UploadIcon from "../icons/upload.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 EyeIcon from "../icons/eye.svg";
import CopyIcon from "../icons/copy.svg";
import { DEFAULT_MASK_AVATAR, Mask, useMaskStore } from "../store/mask";
import { Message, ModelConfig, ROLES, useChatStore } from "../store";
import { Input, List, ListItem, Modal, Popover, showToast } from "./ui-lib";
import { Avatar, AvatarPicker } from "./emoji";
import Locale, { AllLangs, Lang } from "../locales";
import { useNavigate } from "react-router-dom";
import chatStyle from "./chat.module.scss";
import { useEffect, useState } from "react";
import { downloadAs } from "../utils";
import { Updater } from "../api/openai/typing";
import { ModelConfigList } from "./model-config";
import { FileName, Path } from "../constant";
import { BUILTIN_MASK_STORE } from "../masks";
export function MaskAvatar(props: { mask: Mask }) {
return props.mask.avatar !== DEFAULT_MASK_AVATAR ? (
<Avatar avatar={props.mask.avatar} />
) : (
<Avatar model={props.mask.modelConfig.model} />
);
}
export function MaskConfig(props: {
mask: Mask;
updateMask: Updater<Mask>;
extraListItems?: JSX.Element;
readonly?: boolean;
}) {
const [showPicker, setShowPicker] = useState(false);
const updateConfig = (updater: (config: ModelConfig) => void) => {
if (props.readonly) return;
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={Locale.Mask.Config.Avatar}>
<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" }}
>
<MaskAvatar mask={props.mask} />
</div>
</Popover>
</ListItem>
<ListItem title={Locale.Mask.Config.Name}>
<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 navigate = useNavigate();
const maskStore = useMaskStore();
const chatStore = useChatStore();
const [filterLang, setFilterLang] = useState<Lang>();
const allMasks = maskStore
.getAll()
.filter((m) => !filterLang || m.lang === filterLang);
const [searchMasks, setSearchMasks] = useState<Mask[]>([]);
const [searchText, setSearchText] = useState("");
const masks = searchText.length > 0 ? searchMasks : allMasks;
// simple search, will refactor later
const onSearch = (text: string) => {
setSearchText(text);
if (text.length > 0) {
const result = allMasks.filter((m) => m.name.includes(text));
setSearchMasks(result);
} else {
setSearchMasks(allMasks);
}
};
const [editingMaskId, setEditingMaskId] = useState<number | undefined>();
const editingMask =
maskStore.get(editingMaskId) ?? BUILTIN_MASK_STORE.get(editingMaskId);
const closeMaskModal = () => setEditingMaskId(undefined);
const downloadAll = () => {
downloadAs(JSON.stringify(masks), FileName.Masks);
};
return (
<ErrorBoundary>
<div className={styles["mask-page"]}>
<div className="window-header">
<div className="window-header-title">
<div className="window-header-main-title">
{Locale.Mask.Page.Title}
</div>
<div className="window-header-submai-title">
{Locale.Mask.Page.SubTitle(allMasks.length)}
</div>
</div>
<div className="window-actions">
<div className="window-action-button">
<IconButton
icon={<DownloadIcon />}
bordered
onClick={downloadAll}
/>
</div>
<div className="window-action-button">
<IconButton
icon={<UploadIcon />}
bordered
onClick={() => showToast(Locale.WIP)}
/>
</div>
<div className="window-action-button">
<IconButton
icon={<CloseIcon />}
bordered
onClick={() => navigate(-1)}
/>
</div>
</div>
</div>
<div className={styles["mask-page-body"]}>
<div className={styles["mask-filter"]}>
<input
type="text"
className={styles["search-bar"]}
placeholder={Locale.Mask.Page.Search}
autoFocus
onInput={(e) => onSearch(e.currentTarget.value)}
/>
<select
className={styles["mask-filter-lang"]}
value={filterLang ?? Locale.Settings.Lang.All}
onChange={(e) => {
const value = e.currentTarget.value;
if (value === Locale.Settings.Lang.All) {
setFilterLang(undefined);
} else {
setFilterLang(value as Lang);
}
}}
>
<option key="all" value={Locale.Settings.Lang.All}>
{Locale.Settings.Lang.All}
</option>
{AllLangs.map((lang) => (
<option value={lang} key={lang}>
{Locale.Settings.Lang.Options[lang]}
</option>
))}
</select>
<div className={styles["mask-create"]}>
<IconButton
icon={<AddIcon />}
text={Locale.Mask.Page.Create}
bordered
onClick={() => maskStore.create()}
/>
</div>
</div>
<div>
{masks.map((m) => (
<div className={styles["mask-item"]} key={m.id}>
<div className={styles["mask-header"]}>
<div className={styles["mask-icon"]}>
<MaskAvatar mask={m} />
</div>
<div className={styles["mask-title"]}>
<div className={styles["mask-name"]}>{m.name}</div>
<div className={styles["mask-info"] + " one-line"}>
{`${Locale.Mask.Item.Info(m.context.length)} / ${
Locale.Settings.Lang.Options[m.lang]
} / ${m.modelConfig.model}`}
</div>
</div>
</div>
<div className={styles["mask-actions"]}>
<IconButton
icon={<AddIcon />}
text={Locale.Mask.Item.Chat}
onClick={() => {
chatStore.newSession(m);
navigate(Path.Chat);
}}
/>
{m.builtin ? (
<IconButton
icon={<EyeIcon />}
text={Locale.Mask.Item.View}
onClick={() => setEditingMaskId(m.id)}
/>
) : (
<IconButton
icon={<EditIcon />}
text={Locale.Mask.Item.Edit}
onClick={() => setEditingMaskId(m.id)}
/>
)}
{!m.builtin && (
<IconButton
icon={<DeleteIcon />}
text={Locale.Mask.Item.Delete}
onClick={() => {
if (confirm(Locale.Mask.Item.DeleteConfirm)) {
maskStore.delete(m.id);
}
}}
/>
)}
</div>
</div>
))}
</div>
</div>
</div>
{editingMask && (
<div className="modal-mask">
<Modal
title={Locale.Mask.EditModal.Title(editingMask?.builtin)}
onClose={closeMaskModal}
actions={[
<IconButton
icon={<DownloadIcon />}
text={Locale.Mask.EditModal.Download}
key="export"
bordered
/>,
<IconButton
key="copy"
icon={<CopyIcon />}
bordered
text={Locale.Mask.EditModal.Clone}
onClick={() => {
navigate(Path.Masks);
maskStore.create(editingMask);
setEditingMaskId(undefined);
}}
/>,
]}
>
<MaskConfig
mask={editingMask}
updateMask={(updater) =>
maskStore.update(editingMaskId!, updater)
}
readonly={editingMask.builtin}
/>
</Modal>
</div>
)}
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,141 @@
import styles from "./settings.module.scss";
import { ALL_MODELS, ModalConfigValidator, ModelConfig } from "../store";
import Locale from "../locales";
import { InputRange } from "./input-range";
import { List, ListItem } from "./ui-lib";
export function ModelConfigList(props: {
modelConfig: ModelConfig;
updateConfig: (updater: (config: ModelConfig) => void) => void;
}) {
return (
<>
<ListItem title={Locale.Settings.Model}>
<select
value={props.modelConfig.model}
onChange={(e) => {
props.updateConfig(
(config) =>
(config.model = ModalConfigValidator.model(
e.currentTarget.value,
)),
);
}}
>
{ALL_MODELS.map((v) => (
<option value={v.name} key={v.name} disabled={!v.available}>
{v.name}
</option>
))}
</select>
</ListItem>
<ListItem
title={Locale.Settings.Temperature.Title}
subTitle={Locale.Settings.Temperature.SubTitle}
>
<InputRange
value={props.modelConfig.temperature?.toFixed(1)}
min="0"
max="1" // lets limit it to 0-1
step="0.1"
onChange={(e) => {
props.updateConfig(
(config) =>
(config.temperature = ModalConfigValidator.temperature(
e.currentTarget.valueAsNumber,
)),
);
}}
></InputRange>
</ListItem>
<ListItem
title={Locale.Settings.MaxTokens.Title}
subTitle={Locale.Settings.MaxTokens.SubTitle}
>
<input
type="number"
min={100}
max={32000}
value={props.modelConfig.max_tokens}
onChange={(e) =>
props.updateConfig(
(config) =>
(config.max_tokens = ModalConfigValidator.max_tokens(
e.currentTarget.valueAsNumber,
)),
)
}
></input>
</ListItem>
<ListItem
title={Locale.Settings.PresencePenlty.Title}
subTitle={Locale.Settings.PresencePenlty.SubTitle}
>
<InputRange
value={props.modelConfig.presence_penalty?.toFixed(1)}
min="-2"
max="2"
step="0.1"
onChange={(e) => {
props.updateConfig(
(config) =>
(config.presence_penalty =
ModalConfigValidator.presence_penalty(
e.currentTarget.valueAsNumber,
)),
);
}}
></InputRange>
</ListItem>
<ListItem
title={Locale.Settings.HistoryCount.Title}
subTitle={Locale.Settings.HistoryCount.SubTitle}
>
<InputRange
title={props.modelConfig.historyMessageCount.toString()}
value={props.modelConfig.historyMessageCount}
min="0"
max="32"
step="1"
onChange={(e) =>
props.updateConfig(
(config) => (config.historyMessageCount = e.target.valueAsNumber),
)
}
></InputRange>
</ListItem>
<ListItem
title={Locale.Settings.CompressThreshold.Title}
subTitle={Locale.Settings.CompressThreshold.SubTitle}
>
<input
type="number"
min={500}
max={4000}
value={props.modelConfig.compressMessageLengthThreshold}
onChange={(e) =>
props.updateConfig(
(config) =>
(config.compressMessageLengthThreshold =
e.currentTarget.valueAsNumber),
)
}
></input>
</ListItem>
<ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}>
<input
type="checkbox"
checked={props.modelConfig.sendMemory}
onChange={(e) =>
props.updateConfig(
(config) => (config.sendMemory = e.currentTarget.checked),
)
}
></input>
</ListItem>
</>
);
}

View File

@@ -0,0 +1,116 @@
@import "../styles/animation.scss";
.new-chat {
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
.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;
border: var(--border-in-light);
box-shadow: var(--card-shadow);
border-radius: 14px;
background-color: var(--white);
transform: scale(1);
&:first-child {
transform: rotate(-15deg) translateY(5px);
}
&:last-child {
transform: rotate(15deg) translateY(5px);
}
}
}
.title {
font-size: 32px;
font-weight: bolder;
margin-bottom: 1vh;
animation: slide-in ease 0.35s;
}
.sub-title {
animation: slide-in ease 0.4s;
}
.actions {
margin-top: 5vh;
margin-bottom: 5vh;
animation: slide-in ease 0.45s;
display: flex;
justify-content: center;
.search-bar {
font-size: 12px;
margin-right: 10px;
width: 40vw;
}
}
.masks {
flex-grow: 1;
width: 100%;
overflow: hidden;
align-items: center;
padding-top: 20px;
animation: slide-in ease 0.5s;
.mask-row {
margin-bottom: 10px;
display: flex;
justify-content: center;
@for $i from 1 to 10 {
&:nth-child(#{$i * 2}) {
margin-left: 50px;
}
}
.mask {
display: flex;
align-items: center;
padding: 10px 14px;
border: var(--border-in-light);
box-shadow: var(--card-shadow);
background-color: var(--white);
border-radius: 10px;
margin-right: 10px;
max-width: 8em;
transform: scale(1);
cursor: pointer;
transition: all ease 0.3s;
&:hover {
transform: translateY(-5px) scale(1.1);
z-index: 999;
border-color: var(--primary);
}
.mask-name {
margin-left: 10px;
font-size: 14px;
}
}
}
}
}

182
app/components/new-chat.tsx Normal file
View File

@@ -0,0 +1,182 @@
import { useEffect, useRef, useState } from "react";
import { Path, 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 AddIcon from "../icons/lightning.svg";
import { useLocation, useNavigate } from "react-router-dom";
import { createEmptyMask, Mask, useMaskStore } from "../store/mask";
import Locale from "../locales";
import { useAppConfig, useChatStore } from "../store";
import { MaskAvatar } from "./mask";
function getIntersectionArea(aRect: DOMRect, bRect: DOMRect) {
const xmin = Math.max(aRect.x, bRect.x);
const xmax = Math.min(aRect.x + aRect.width, bRect.x + bRect.width);
const ymin = Math.max(aRect.y, bRect.y);
const ymax = Math.min(aRect.y + aRect.height, bRect.y + bRect.height);
const width = xmax - xmin;
const height = ymax - ymin;
const intersectionArea = width < 0 || height < 0 ? 0 : width * height;
return intersectionArea;
}
function MaskItem(props: { mask: Mask; onClick?: () => void }) {
const domRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const changeOpacity = () => {
const dom = domRef.current;
const parent = document.getElementById(SlotID.AppBody);
if (!parent || !dom) return;
const domRect = dom.getBoundingClientRect();
const parentRect = parent.getBoundingClientRect();
const intersectionArea = getIntersectionArea(domRect, parentRect);
const domArea = domRect.width * domRect.height;
const ratio = intersectionArea / domArea;
const opacity = ratio > 0.9 ? 1 : 0.4;
dom.style.opacity = opacity.toString();
};
setTimeout(changeOpacity, 30);
window.addEventListener("resize", changeOpacity);
return () => window.removeEventListener("resize", changeOpacity);
}, [domRef]);
return (
<div className={styles["mask"]} ref={domRef} onClick={props.onClick}>
<MaskAvatar mask={props.mask} />
<div className={styles["mask-name"] + " one-line"}>{props.mask.name}</div>
</div>
);
}
function useMaskGroup(masks: Mask[]) {
const [groups, setGroups] = useState<Mask[][]>([]);
useEffect(() => {
const appBody = document.getElementById(SlotID.AppBody);
if (!appBody || masks.length === 0) return;
const rect = appBody.getBoundingClientRect();
const maxWidth = rect.width;
const maxHeight = rect.height * 0.6;
const maskItemWidth = 120;
const maskItemHeight = 50;
const randomMask = () => masks[Math.floor(Math.random() * masks.length)];
let maskIndex = 0;
const nextMask = () => masks[maskIndex++ % masks.length];
const rows = Math.ceil(maxHeight / maskItemHeight);
const cols = Math.ceil(maxWidth / maskItemWidth);
const newGroups = new Array(rows)
.fill(0)
.map((_, _i) =>
new Array(cols)
.fill(0)
.map((_, j) => (j < 1 || j > cols - 2 ? randomMask() : nextMask())),
);
setGroups(newGroups);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return groups;
}
export function NewChat() {
const chatStore = useChatStore();
const maskStore = useMaskStore();
const masks = maskStore.getAll();
const groups = useMaskGroup(masks);
const navigate = useNavigate();
const config = useAppConfig();
const { state } = useLocation();
const startChat = (mask?: Mask) => {
chatStore.newSession(mask);
navigate(Path.Chat);
};
return (
<div className={styles["new-chat"]}>
<div className={styles["mask-header"]}>
<IconButton
icon={<LeftIcon />}
text={Locale.NewChat.Return}
onClick={() => navigate(Path.Home)}
></IconButton>
{!state?.fromHome && (
<IconButton
text={Locale.NewChat.NotShow}
onClick={() => {
if (confirm(Locale.NewChat.ConfirmNoShow)) {
startChat();
config.update(
(config) => (config.dontShowMaskSplashScreen = true),
);
}
}}
></IconButton>
)}
</div>
<div className={styles["mask-cards"]}>
<div className={styles["mask-card"]}>
<EmojiAvatar avatar="1f606" size={24} />
</div>
<div className={styles["mask-card"]}>
<EmojiAvatar avatar="1f916" size={24} />
</div>
<div className={styles["mask-card"]}>
<EmojiAvatar avatar="1f479" size={24} />
</div>
</div>
<div className={styles["title"]}>{Locale.NewChat.Title}</div>
<div className={styles["sub-title"]}>{Locale.NewChat.SubTitle}</div>
<div className={styles["actions"]}>
<input
className={styles["search-bar"]}
placeholder={Locale.NewChat.More}
type="text"
onClick={() => navigate(Path.Masks)}
/>
<IconButton
text={Locale.NewChat.Skip}
onClick={() => startChat()}
icon={<AddIcon />}
type="primary"
shadow
/>
</div>
<div className={styles["masks"]}>
{groups.map((masks, i) => (
<div key={i} className={styles["mask-row"]}>
{masks.map((mask, index) => (
<MaskItem
key={index}
mask={mask}
onClick={() => startChat(mask)}
/>
))}
</div>
))}
</div>
</div>
);
}

View File

@@ -1,38 +1,12 @@
@import "./window.scss";
.settings {
padding: 20px;
overflow: auto;
}
.settings-title {
font-size: 14px;
font-weight: bolder;
}
.settings-sub-title {
font-size: 12px;
font-weight: normal;
}
.avatar {
cursor: pointer;
}
.password-input-container {
max-width: 50%;
display: flex;
justify-content: flex-end;
.password-eye {
margin-right: 4px;
}
.password-input {
min-width: 80%;
}
}
.user-prompt-modal {
min-height: 40vh;

View File

@@ -1,7 +1,5 @@
import { useState, useEffect, useMemo, HTMLProps, useRef } from "react";
import EmojiPicker, { Theme as EmojiTheme } from "emoji-picker-react";
import styles from "./settings.module.scss";
import ResetIcon from "../icons/reload.svg";
@@ -9,32 +7,28 @@ import CloseIcon from "../icons/close.svg";
import CopyIcon from "../icons/copy.svg";
import ClearIcon from "../icons/clear.svg";
import EditIcon from "../icons/edit.svg";
import EyeIcon from "../icons/eye.svg";
import EyeOffIcon from "../icons/eye-off.svg";
import { Input, List, ListItem, Modal, Popover } from "./ui-lib";
import { Input, List, ListItem, Modal, PasswordInput, Popover } from "./ui-lib";
import { ModelConfigList } from "./model-config";
import { IconButton } from "./button";
import {
SubmitKey,
useChatStore,
Theme,
ALL_MODELS,
useUpdateStore,
useAccessStore,
ModalConfigValidator,
useAppConfig,
} from "../store";
import { Avatar } from "./chat";
import Locale, { AllLangs, changeLang, getLang } from "../locales";
import { copyToClipboard, getEmojiUrl } from "../utils";
import { copyToClipboard } from "../utils";
import Link from "next/link";
import { Path, UPDATE_URL } from "../constant";
import { Prompt, SearchService, usePromptStore } from "../store/prompt";
import { ErrorBoundary } from "./error";
import { InputRange } from "./input-range";
import { useNavigate } from "react-router-dom";
import { Avatar, AvatarPicker } from "./emoji";
function UserPromptModal(props: { onClose?: () => void }) {
const promptStore = usePromptStore();
@@ -137,57 +131,13 @@ function UserPromptModal(props: { onClose?: () => void }) {
);
}
function SettingItem(props: {
title: string;
subTitle?: string;
children: JSX.Element;
}) {
return (
<ListItem>
<div className={styles["settings-title"]}>
<div>{props.title}</div>
{props.subTitle && (
<div className={styles["settings-sub-title"]}>{props.subTitle}</div>
)}
</div>
{props.children}
</ListItem>
);
}
function PasswordInput(props: HTMLProps<HTMLInputElement>) {
const [visible, setVisible] = useState(false);
function changeVisibility() {
setVisible(!visible);
}
return (
<div className={styles["password-input-container"]}>
<IconButton
icon={visible ? <EyeIcon /> : <EyeOffIcon />}
onClick={changeVisibility}
className={styles["password-eye"]}
/>
<input
{...props}
type={visible ? "text" : "password"}
className={styles["password-input"]}
/>
</div>
);
}
export function Settings() {
const navigate = useNavigate();
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const config = useAppConfig();
const updateConfig = config.update;
const resetConfig = config.reset;
const [clearAllData, clearSessions] = useChatStore((state) => [
state.clearAllData,
state.clearSessions,
]);
const chatStore = useChatStore();
const updateStore = useUpdateStore();
const [checkingUpdate, setCheckingUpdate] = useState(false);
@@ -207,9 +157,9 @@ export function Settings() {
subscription: updateStore.subscription,
};
const [loadingUsage, setLoadingUsage] = useState(false);
function checkUsage() {
function checkUsage(force = false) {
setLoadingUsage(true);
updateStore.updateUsage().finally(() => {
updateStore.updateUsage(force).finally(() => {
setLoadingUsage(false);
});
}
@@ -249,39 +199,33 @@ 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={() => {
const confirmed = window.confirm(
`${Locale.Settings.Actions.ConfirmClearAll.Confirm}`,
);
if (confirmed) {
clearSessions();
if (confirm(Locale.Settings.Actions.ConfirmClearAll)) {
chatStore.clearAllData();
}
}}
bordered
title={Locale.Settings.Actions.ClearAll}
/>
</div>
<div className={styles["window-action-button"]}>
<div className="window-action-button">
<IconButton
icon={<ResetIcon />}
onClick={() => {
const confirmed = window.confirm(
`${Locale.Settings.Actions.ConfirmResetAll.Confirm}`,
);
if (confirmed) {
if (confirm(Locale.Settings.Actions.ConfirmResetAll)) {
resetConfig();
}
}}
@@ -289,7 +233,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)}
@@ -301,16 +245,13 @@ export function Settings() {
</div>
<div className={styles["settings"]}>
<List>
<SettingItem title={Locale.Settings.Avatar}>
<ListItem title={Locale.Settings.Avatar}>
<Popover
onClose={() => setShowEmojiPicker(false)}
content={
<EmojiPicker
lazyLoadEmojis
theme={EmojiTheme.AUTO}
getEmojiUrl={getEmojiUrl}
onEmojiClick={(e) => {
updateConfig((config) => (config.avatar = e.unified));
<AvatarPicker
onEmojiClick={(avatar: string) => {
updateConfig((config) => (config.avatar = avatar));
setShowEmojiPicker(false);
}}
/>
@@ -321,12 +262,12 @@ export function Settings() {
className={styles.avatar}
onClick={() => setShowEmojiPicker(true)}
>
<Avatar role="user" />
<Avatar avatar={config.avatar} />
</div>
</Popover>
</SettingItem>
</ListItem>
<SettingItem
<ListItem
title={Locale.Settings.Update.Version(currentVersion ?? "unknown")}
subTitle={
checkingUpdate
@@ -349,9 +290,9 @@ export function Settings() {
onClick={() => checkUpdate(true)}
/>
)}
</SettingItem>
</ListItem>
<SettingItem title={Locale.Settings.SendKey}>
<ListItem title={Locale.Settings.SendKey}>
<select
value={config.submitKey}
onChange={(e) => {
@@ -367,12 +308,9 @@ export function Settings() {
</option>
))}
</select>
</SettingItem>
</ListItem>
<ListItem>
<div className={styles["settings-title"]}>
{Locale.Settings.Theme}
</div>
<ListItem title={Locale.Settings.Theme}>
<select
value={config.theme}
onChange={(e) => {
@@ -389,7 +327,7 @@ export function Settings() {
</select>
</ListItem>
<SettingItem title={Locale.Settings.Lang.Name}>
<ListItem title={Locale.Settings.Lang.Name}>
<select
value={getLang()}
onChange={(e) => {
@@ -402,9 +340,9 @@ export function Settings() {
</option>
))}
</select>
</SettingItem>
</ListItem>
<SettingItem
<ListItem
title={Locale.Settings.FontSize.Title}
subTitle={Locale.Settings.FontSize.SubTitle}
>
@@ -421,21 +359,12 @@ export function Settings() {
)
}
></InputRange>
</SettingItem>
</ListItem>
<SettingItem title={Locale.Settings.TightBorder}>
<input
type="checkbox"
checked={config.tightBorder}
onChange={(e) =>
updateConfig(
(config) => (config.tightBorder = e.currentTarget.checked),
)
}
></input>
</SettingItem>
<SettingItem title={Locale.Settings.SendPreviewBubble}>
<ListItem
title={Locale.Settings.SendPreviewBubble.Title}
subTitle={Locale.Settings.SendPreviewBubble.SubTitle}
>
<input
type="checkbox"
checked={config.sendPreviewBubble}
@@ -446,12 +375,29 @@ export function Settings() {
)
}
></input>
</SettingItem>
</ListItem>
<ListItem
title={Locale.Settings.Mask.Title}
subTitle={Locale.Settings.Mask.SubTitle}
>
<input
type="checkbox"
checked={!config.dontShowMaskSplashScreen}
onChange={(e) =>
updateConfig(
(config) =>
(config.dontShowMaskSplashScreen =
!e.currentTarget.checked),
)
}
></input>
</ListItem>
</List>
<List>
{enabledAccessControl ? (
<SettingItem
<ListItem
title={Locale.Settings.AccessCode.Title}
subTitle={Locale.Settings.AccessCode.SubTitle}
>
@@ -463,12 +409,12 @@ export function Settings() {
accessStore.updateCode(e.currentTarget.value);
}}
/>
</SettingItem>
</ListItem>
) : (
<></>
)}
<SettingItem
<ListItem
title={Locale.Settings.Token.Title}
subTitle={Locale.Settings.Token.SubTitle}
>
@@ -480,9 +426,9 @@ export function Settings() {
accessStore.updateToken(e.currentTarget.value);
}}
/>
</SettingItem>
</ListItem>
<SettingItem
<ListItem
title={Locale.Settings.Usage.Title}
subTitle={
showUsage
@@ -501,52 +447,14 @@ export function Settings() {
<IconButton
icon={<ResetIcon></ResetIcon>}
text={Locale.Settings.Usage.Check}
onClick={checkUsage}
onClick={() => checkUsage(true)}
/>
)}
</SettingItem>
<SettingItem
title={Locale.Settings.HistoryCount.Title}
subTitle={Locale.Settings.HistoryCount.SubTitle}
>
<InputRange
title={config.historyMessageCount.toString()}
value={config.historyMessageCount}
min="0"
max="25"
step="1"
onChange={(e) =>
updateConfig(
(config) =>
(config.historyMessageCount = e.target.valueAsNumber),
)
}
></InputRange>
</SettingItem>
<SettingItem
title={Locale.Settings.CompressThreshold.Title}
subTitle={Locale.Settings.CompressThreshold.SubTitle}
>
<input
type="number"
min={500}
max={4000}
value={config.compressMessageLengthThreshold}
onChange={(e) =>
updateConfig(
(config) =>
(config.compressMessageLengthThreshold =
e.currentTarget.valueAsNumber),
)
}
></input>
</SettingItem>
</ListItem>
</List>
<List>
<SettingItem
<ListItem
title={Locale.Settings.Prompt.Disable.Title}
subTitle={Locale.Settings.Prompt.Disable.SubTitle}
>
@@ -560,9 +468,9 @@ export function Settings() {
)
}
></input>
</SettingItem>
</ListItem>
<SettingItem
<ListItem
title={Locale.Settings.Prompt.List}
subTitle={Locale.Settings.Prompt.ListCount(
builtinCount,
@@ -574,89 +482,18 @@ export function Settings() {
text={Locale.Settings.Prompt.Edit}
onClick={() => setShowPromptModal(true)}
/>
</SettingItem>
</ListItem>
</List>
<List>
<SettingItem title={Locale.Settings.Model}>
<select
value={config.modelConfig.model}
onChange={(e) => {
updateConfig(
(config) =>
(config.modelConfig.model = ModalConfigValidator.model(
e.currentTarget.value,
)),
);
}}
>
{ALL_MODELS.map((v) => (
<option value={v.name} key={v.name} disabled={!v.available}>
{v.name}
</option>
))}
</select>
</SettingItem>
<SettingItem
title={Locale.Settings.Temperature.Title}
subTitle={Locale.Settings.Temperature.SubTitle}
>
<InputRange
value={config.modelConfig.temperature?.toFixed(1)}
min="0"
max="2"
step="0.1"
onChange={(e) => {
updateConfig(
(config) =>
(config.modelConfig.temperature =
ModalConfigValidator.temperature(
e.currentTarget.valueAsNumber,
)),
);
}}
></InputRange>
</SettingItem>
<SettingItem
title={Locale.Settings.MaxTokens.Title}
subTitle={Locale.Settings.MaxTokens.SubTitle}
>
<input
type="number"
min={100}
max={32000}
value={config.modelConfig.max_tokens}
onChange={(e) =>
updateConfig(
(config) =>
(config.modelConfig.max_tokens =
ModalConfigValidator.max_tokens(
e.currentTarget.valueAsNumber,
)),
)
}
></input>
</SettingItem>
<SettingItem
title={Locale.Settings.PresencePenlty.Title}
subTitle={Locale.Settings.PresencePenlty.SubTitle}
>
<InputRange
value={config.modelConfig.presence_penalty?.toFixed(1)}
min="-2"
max="2"
step="0.1"
onChange={(e) => {
updateConfig(
(config) =>
(config.modelConfig.presence_penalty =
ModalConfigValidator.presence_penalty(
e.currentTarget.valueAsNumber,
)),
);
}}
></InputRange>
</SettingItem>
<ModelConfigList
modelConfig={config.modelConfig}
updateConfig={(upater) => {
const modelConfig = { ...config.modelConfig };
upater(modelConfig);
config.update((config) => (config.modelConfig = modelConfig));
}}
/>
</List>
{shouldShowPromptModal && (

View File

@@ -8,6 +8,9 @@ import GithubIcon from "../icons/github.svg";
import ChatGptIcon from "../icons/chatgpt.svg";
import AddIcon from "../icons/add.svg";
import CloseIcon from "../icons/close.svg";
import MaskIcon from "../icons/mask.svg";
import PluginIcon from "../icons/plugin.svg";
import Locale from "../locales";
import { useAppConfig, useChatStore } from "../store";
@@ -23,6 +26,7 @@ import {
import { Link, useNavigate } from "react-router-dom";
import { useMobileScreen } from "../utils";
import dynamic from "next/dynamic";
import { showToast } from "./ui-lib";
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
loading: () => null,
@@ -83,6 +87,8 @@ export function SideBar(props: { className?: string }) {
const { onDragMouseDown, shouldNarrow } = useDragSideBar();
const navigate = useNavigate();
const config = useAppConfig();
return (
<div
className={`${styles.sidebar} ${props.className} ${
@@ -94,11 +100,28 @@ export function SideBar(props: { className?: string }) {
<div className={styles["sidebar-sub-title"]}>
LF来福科技
</div>
<div className={styles["sidebar-logo"]}>
<div className={styles["sidebar-logo"] + " no-dark"}>
<ChatGptIcon />
</div>
</div>
<div className={styles["sidebar-header-bar"]}>
<IconButton
icon={<MaskIcon />}
text={shouldNarrow ? undefined : Locale.Mask.Name}
className={styles["sidebar-bar-button"]}
onClick={() => navigate(Path.NewChat, { state: { fromHome: true } })}
shadow
/>
<IconButton
icon={<PluginIcon />}
text={shouldNarrow ? undefined : Locale.Plugin.Name}
className={styles["sidebar-bar-button"]}
onClick={() => showToast(Locale.WIP)}
shadow
/>
</div>
<div
className={styles["sidebar-body"]}
onClick={(e) => {
@@ -130,7 +153,11 @@ export function SideBar(props: { className?: string }) {
icon={<AddIcon />}
text={shouldNarrow ? undefined : Locale.Home.NewChat}
onClick={() => {
chatStore.newSession();
if (config.dontShowMaskSplashScreen) {
chatStore.newSession();
} else {
navigate(Path.NewChat);
}
}}
shadow
/>

View File

@@ -35,6 +35,25 @@
border-bottom: var(--border-in-light);
padding: 10px 20px;
animation: slide-in ease 0.6s;
.list-header {
display: flex;
align-items: center;
.list-icon {
margin-right: 10px;
}
.list-item-title {
font-size: 14px;
font-weight: bolder;
}
.list-item-sub-title {
font-size: 12px;
font-weight: normal;
}
}
}
.list {
@@ -89,6 +108,8 @@
padding: var(--modal-padding);
display: flex;
justify-content: flex-end;
border-top: var(--border-in-light);
box-shadow: var(--shadow);
.modal-actions {
display: flex;
@@ -122,7 +143,7 @@
.toast-container {
position: fixed;
bottom: 0;
bottom: 5vh;
left: 0;
width: 100vw;
display: flex;

View File

@@ -1,8 +1,12 @@
import styles from "./ui-lib.module.scss";
import LoadingIcon from "../icons/three-dots.svg";
import CloseIcon from "../icons/close.svg";
import EyeIcon from "../icons/eye.svg";
import EyeOffIcon from "../icons/eye-off.svg";
import { createRoot } from "react-dom/client";
import React, { useEffect } from "react";
import React, { HTMLProps, useEffect, useState } from "react";
import { IconButton } from "./button";
export function Popover(props: {
children: JSX.Element;
@@ -29,15 +33,38 @@ export function Card(props: { children: JSX.Element[]; className?: string }) {
);
}
export function ListItem(props: { children: JSX.Element[] }) {
if (props.children.length > 2) {
throw Error("Only Support Two Children");
}
return <div className={styles["list-item"]}>{props.children}</div>;
export function ListItem(props: {
title: string;
subTitle?: string;
children?: JSX.Element | JSX.Element[];
icon?: JSX.Element;
className?: string;
}) {
return (
<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>;
}
@@ -59,7 +86,7 @@ export function Loading() {
interface ModalProps {
title: string;
children?: JSX.Element;
children?: JSX.Element | JSX.Element[];
actions?: JSX.Element[];
onClose?: () => void;
}
@@ -190,3 +217,26 @@ export function Input(props: InputProps) {
></textarea>
);
}
export function PasswordInput(props: HTMLProps<HTMLInputElement>) {
const [visible, setVisible] = useState(false);
function changeVisibility() {
setVisible(!visible);
}
return (
<div className={"password-input-container"}>
<IconButton
icon={visible ? <EyeIcon /> : <EyeOffIcon />}
onClick={changeVisibility}
className={"password-eye"}
/>
<input
{...props}
type={visible ? "text" : "password"}
className={"password-input"}
/>
</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;
}