feat: add mask screen

This commit is contained in:
Yidadaa 2023-04-24 01:15:44 +08:00
parent e654cee3c8
commit aeb986243c
11 changed files with 315 additions and 35 deletions

View File

@ -272,16 +272,16 @@
} }
.sidebar-tail { .sidebar-tail {
flex-direction: column; flex-direction: column-reverse;
align-items: center; align-items: center;
.sidebar-actions { .sidebar-actions {
flex-direction: column; flex-direction: column-reverse;
align-items: center; align-items: center;
.sidebar-action { .sidebar-action {
margin-right: 0; margin-right: 0;
margin-bottom: 15px; margin-top: 15px;
} }
} }
} }

View File

@ -12,7 +12,7 @@ import LoadingIcon from "../icons/three-dots.svg";
import { getCSSVar, useMobileScreen } from "../utils"; import { getCSSVar, useMobileScreen } from "../utils";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { Path } from "../constant"; import { Path, SlotID } from "../constant";
import { ErrorBoundary } from "./error"; import { ErrorBoundary } from "./error";
import { import {
@ -23,6 +23,7 @@ import {
} from "react-router-dom"; } from "react-router-dom";
import { SideBar } from "./sidebar"; import { SideBar } from "./sidebar";
import { useAppConfig } from "../store/config"; import { useAppConfig } from "../store/config";
import { NewChat } from "./new-chat";
export function Loading(props: { noLogo?: boolean }) { export function Loading(props: { noLogo?: boolean }) {
return ( return (
@ -82,39 +83,29 @@ const useHasHydrated = () => {
return hasHydrated; return hasHydrated;
}; };
function WideScreen() { function Screen() {
const config = useAppConfig(); const config = useAppConfig();
const location = useLocation();
const isHome = location.pathname === Path.Home;
const isMobileScreen = useMobileScreen();
return ( return (
<div <div
className={`${ className={
config.tightBorder ? styles["tight-container"] : styles.container 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"] : ""} /> <SideBar className={isHome ? styles["sidebar-show"] : ""} />
<div className={styles["window-content"]}> <div className={styles["window-content"]} id={SlotID.AppBody}>
<Routes> <Routes>
<Route path={Path.Home} element={null} /> <Route path={Path.Home} element={<Chat />} />
<Route path={Path.NewChat} element={<NewChat />} />
<Route path={Path.Chat} element={<Chat />} /> <Route path={Path.Chat} element={<Chat />} />
<Route path={Path.Settings} element={<Settings />} /> <Route path={Path.Settings} element={<Settings />} />
</Routes> </Routes>
@ -124,7 +115,6 @@ function MobileScreen() {
} }
export function Home() { export function Home() {
const isMobileScreen = useMobileScreen();
useSwitchTheme(); useSwitchTheme();
if (!useHasHydrated()) { if (!useHasHydrated()) {
@ -133,7 +123,9 @@ export function Home() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<Router>{isMobileScreen ? <MobileScreen /> : <WideScreen />}</Router> <Router>
<Screen />
</Router>
</ErrorBoundary> </ErrorBoundary>
); );
} }

View File

@ -0,0 +1,100 @@
.new-chat {
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
padding-top: 80px;
.mask-cards {
display: flex;
margin-bottom: 20px;
.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;
animation: slide-in ease 0.3s;
}
.sub-title {
animation: slide-in ease 0.3s;
}
.search-bar {
margin-top: 20px;
}
.masks {
flex-grow: 1;
width: 100%;
overflow: hidden;
align-items: center;
padding-top: 20px;
animation: slide-in ease 0.3s;
.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 16px;
border: var(--border-in-light);
box-shadow: var(--card-shadow);
background-color: var(--white);
border-radius: 10px;
margin-right: 10px;
width: 100px;
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-avatar {
display: flex;
min-width: 18px;
min-height: 18px;
background-color: #eee;
border-radius: 20px;
}
.mask-name {
margin-left: 10px;
}
}
}
}
}

View File

@ -0,0 +1,92 @@
import { useEffect, useRef } from "react";
import { SlotID } from "../constant";
import { EmojiAvatar } from "./emoji";
import styles from "./new-chat.module.scss";
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 Mask(props: { avatar: string; name: string }) {
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}>
<div className={styles["mask-avatar"]}>
<EmojiAvatar avatar={props.avatar} />
</div>
<div className={styles["mask-name"] + " one-line"}>{props.name}</div>
</div>
);
}
export function NewChat() {
const masks = new Array(20).fill(0).map(() =>
new Array(10).fill(0).map((_, i) => ({
avatar: "1f" + (Math.round(Math.random() * 50) + 600).toString(),
name: ["撩妹达人", "编程高手", "情感大师", "健康医生", "数码通"][
Math.floor(Math.random() * 4)
],
})),
);
return (
<div className={styles["new-chat"]}>
<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"]}></div>
<div className={styles["sub-title"]}></div>
<input className={styles["search-bar"]} placeholder="搜索" type="text" />
<div className={styles["masks"]}>
{masks.map((masks, i) => (
<div key={i} className={styles["mask-row"]}>
{masks.map((mask, index) => (
<Mask key={index} {...mask} />
))}
</div>
))}
</div>
</div>
);
}

View File

@ -134,7 +134,7 @@ export function SideBar(props: { className?: string }) {
icon={<AddIcon />} icon={<AddIcon />}
text={shouldNarrow ? undefined : Locale.Home.NewChat} text={shouldNarrow ? undefined : Locale.Home.NewChat}
onClick={() => { onClick={() => {
chatStore.newSession(); navigate(Path.NewChat);
}} }}
shadow shadow
/> />

View File

@ -11,6 +11,11 @@ export enum Path {
Home = "/", Home = "/",
Chat = "/chat", Chat = "/chat",
Settings = "/settings", Settings = "/settings",
NewChat = "/new-chat",
}
export enum SlotID {
AppBody = "app-body",
} }
export const MAX_SIDEBAR_WIDTH = 500; export const MAX_SIDEBAR_WIDTH = 500;

View File

@ -3,7 +3,8 @@ import { SubmitKey } from "../store/config";
const cn = { const cn = {
WIP: "该功能仍在开发中……", WIP: "该功能仍在开发中……",
Error: { Error: {
Unauthorized: "现在是未授权状态,请点击左下角设置按钮输入访问密码。", Unauthorized:
"现在是未授权状态,请点击左下角[设置](/#/settings)按钮输入访问密码。",
}, },
ChatItem: { ChatItem: {
ChatItemCount: (count: number) => `${count} 条对话`, ChatItemCount: (count: number) => `${count} 条对话`,
@ -141,7 +142,7 @@ const cn = {
Model: "模型 (model)", Model: "模型 (model)",
Temperature: { Temperature: {
Title: "随机性 (temperature)", Title: "随机性 (temperature)",
SubTitle: "值越大,回复越随机,大于 1 的值可能会导致乱码", SubTitle: "值越大,回复越随机",
}, },
MaxTokens: { MaxTokens: {
Title: "单次回复限制 (max_tokens)", Title: "单次回复限制 (max_tokens)",

View File

@ -19,7 +19,7 @@ export const AllLangs = [
"jp", "jp",
"de", "de",
] as const; ] as const;
type Lang = (typeof AllLangs)[number]; export type Lang = (typeof AllLangs)[number];
const LANG_KEY = "lang"; const LANG_KEY = "lang";

3
app/masks.ts Normal file
View File

@ -0,0 +1,3 @@
import { Mask } from "./store/mask";
export const BUILT_IN_MASKS: Mask[] = [];

81
app/store/mask.ts Normal file
View File

@ -0,0 +1,81 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { getLang, Lang } from "../locales";
import { Message } from "./chat";
import { ModelConfig, useAppConfig } from "./config";
export const MASK_KEY = "mask-store";
export type Mask = {
id: number;
avatar: string;
name: string;
context: Message[];
config: ModelConfig;
lang: Lang;
};
export const DEFAULT_MASK_STATE = {
masks: {} as Record<number, Mask>,
globalMaskId: 0,
};
export type MaskState = typeof DEFAULT_MASK_STATE;
type MaskStore = MaskState & {
create: (mask: Partial<Mask>) => Mask;
update: (id: number, updater: (mask: Mask) => void) => void;
delete: (id: number) => void;
search: (text: string) => Mask[];
getAll: () => Mask[];
};
export const useMaskStore = create<MaskStore>()(
persist(
(set, get) => ({
...DEFAULT_MASK_STATE,
create(mask) {
set(() => ({ globalMaskId: get().globalMaskId + 1 }));
const id = get().globalMaskId;
const masks = get().masks;
masks[id] = {
id,
avatar: "1f916",
name: "",
config: useAppConfig.getState().modelConfig,
context: [],
lang: getLang(),
...mask,
};
set(() => ({ masks }));
return masks[id];
},
update(id, updater) {
const masks = get().masks;
const mask = masks[id];
if (!mask) return;
const updateMask = { ...mask };
updater(updateMask);
masks[id] = updateMask;
set(() => ({ masks }));
},
delete(id) {
const masks = get().masks;
delete masks[id];
set(() => ({ masks }));
},
getAll() {
return Object.values(get().masks).sort((a, b) => a.id - b.id);
},
search(text) {
return Object.values(get().masks);
},
}),
{
name: MASK_KEY,
version: 2,
},
),
);

View File

@ -336,3 +336,9 @@ pre {
box-shadow: var(--card-shadow); box-shadow: var(--card-shadow);
border-radius: 10px; border-radius: 10px;
} }
.one-line {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}