336 lines
8.9 KiB
TypeScript
336 lines
8.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
|
|
import { IconButton } from "./button";
|
|
import styles from "./home.module.scss";
|
|
|
|
import SettingsIcon from "../icons/settings.svg";
|
|
import GithubIcon from "../icons/github.svg";
|
|
import ChatGptIcon from "../icons/chatgpt.svg";
|
|
import DeleteIcon from "../icons/delete.svg";
|
|
import BotIcon from "../icons/bot.svg";
|
|
import AddIcon from "../icons/add.svg";
|
|
import LoadingIcon from "../icons/three-dots.svg";
|
|
import CloseIcon from "../icons/close.svg";
|
|
import LeftIcon from "../icons/left.svg";
|
|
import RightIcon from "../icons/right.svg";
|
|
|
|
import { Message, SubmitKey, useChatStore } from "../store";
|
|
import { isMobileScreen } from "../utils";
|
|
import Locale from "../locales";
|
|
import { ChatList } from "./chat-list";
|
|
import { Chat } from "./chat";
|
|
|
|
import dynamic from "next/dynamic";
|
|
import { REPO_URL } from "../constant";
|
|
|
|
import type { Prompt } from "../store/prompt";
|
|
|
|
export function Loading(props: { noLogo?: boolean }) {
|
|
return (
|
|
<div className={styles["loading-content"]}>
|
|
{!props.noLogo && <BotIcon />}
|
|
<LoadingIcon />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const Settings = dynamic(async () => (await import("./settings")).Settings, {
|
|
loading: () => <Loading noLogo />,
|
|
});
|
|
|
|
const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, {
|
|
loading: () => <LoadingIcon />,
|
|
});
|
|
|
|
export function Avatar(props: { role: Message["role"] }) {
|
|
const config = useChatStore((state) => state.config);
|
|
|
|
if (props.role === "assistant") {
|
|
return <BotIcon className={styles["user-avtar"]} />;
|
|
}
|
|
|
|
return (
|
|
<div className={styles["user-avtar"]}>
|
|
<Emoji unified={config.avatar} size={18} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function useSubmitHandler() {
|
|
const config = useChatStore((state) => state.config);
|
|
const submitKey = config.submitKey;
|
|
|
|
const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (e.key !== "Enter") return false;
|
|
if (e.key === "Enter" && e.nativeEvent.isComposing) return false;
|
|
return (
|
|
(config.submitKey === SubmitKey.AltEnter && e.altKey) ||
|
|
(config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) ||
|
|
(config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) ||
|
|
(config.submitKey === SubmitKey.MetaEnter && e.metaKey) ||
|
|
(config.submitKey === SubmitKey.Enter &&
|
|
!e.altKey &&
|
|
!e.ctrlKey &&
|
|
!e.shiftKey &&
|
|
!e.metaKey)
|
|
);
|
|
};
|
|
|
|
return {
|
|
submitKey,
|
|
shouldSubmit,
|
|
};
|
|
}
|
|
|
|
export function PromptHints(props: {
|
|
prompts: Prompt[];
|
|
onPromptSelect: (prompt: Prompt) => void;
|
|
}) {
|
|
if (props.prompts.length === 0) return null;
|
|
|
|
return (
|
|
<div className={styles["prompt-hints"]}>
|
|
{props.prompts.map((prompt, i) => (
|
|
<div
|
|
className={styles["prompt-hint"]}
|
|
key={prompt.title + i.toString()}
|
|
onClick={() => props.onPromptSelect(prompt)}
|
|
>
|
|
<div className={styles["hint-title"]}>{prompt.title}</div>
|
|
<div className={styles["hint-content"]}>{prompt.content}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function useSwitchTheme() {
|
|
const config = useChatStore((state) => state.config);
|
|
|
|
useEffect(() => {
|
|
document.body.classList.remove("light");
|
|
document.body.classList.remove("dark");
|
|
|
|
if (config.theme === "dark") {
|
|
document.body.classList.add("dark");
|
|
} else if (config.theme === "light") {
|
|
document.body.classList.add("light");
|
|
}
|
|
|
|
const themeColor = getComputedStyle(document.body)
|
|
.getPropertyValue("--theme-color")
|
|
.trim();
|
|
const metaDescription = document.querySelector('meta[name="theme-color"]');
|
|
metaDescription?.setAttribute("content", themeColor);
|
|
}, [config.theme]);
|
|
}
|
|
|
|
const useHasHydrated = () => {
|
|
const [hasHydrated, setHasHydrated] = useState<boolean>(false);
|
|
|
|
useEffect(() => {
|
|
setHasHydrated(true);
|
|
}, []);
|
|
|
|
return hasHydrated;
|
|
};
|
|
|
|
export function Home() {
|
|
const [createNewSession, currentIndex, removeSession] = useChatStore(
|
|
(state) => [
|
|
state.newSession,
|
|
state.currentSessionIndex,
|
|
state.removeSession,
|
|
],
|
|
);
|
|
const loading = !useHasHydrated();
|
|
const [sidebarCollapse, setSideBarCollapse] = useChatStore((state) => [
|
|
state.sidebarCollapse,
|
|
state.setSidebarCollapse,
|
|
]);
|
|
|
|
// setting
|
|
const [openSettings, setOpenSettings] = useState(false);
|
|
const config = useChatStore((state) => state.config);
|
|
|
|
useSwitchTheme();
|
|
|
|
if (loading) {
|
|
return <Loading />;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={`${
|
|
config.tightBorder && !isMobileScreen()
|
|
? styles["tight-container"]
|
|
: styles.container
|
|
}`}
|
|
>
|
|
<div
|
|
className={
|
|
sidebarCollapse ? styles["sidebar-collapse"] : styles["sidebar"]
|
|
}
|
|
>
|
|
<div
|
|
className={
|
|
sidebarCollapse
|
|
? styles["sidebar-header-collapse"]
|
|
: styles["sidebar-header"]
|
|
}
|
|
>
|
|
{sidebarCollapse ? null : (
|
|
<>
|
|
<div className={styles["sidebar-title"]}>ChatGPT Next</div>
|
|
<div className={styles["sidebar-sub-title"]}>
|
|
Build your own AI assistant.
|
|
</div>
|
|
</>
|
|
)}
|
|
{sidebarCollapse ? (
|
|
<div className={styles["sidebar-logo-collapse"]}>
|
|
<ChatGptIcon />
|
|
</div>
|
|
) : (
|
|
<div className={styles["sidebar-logo"]}>
|
|
<ChatGptIcon />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
className={styles["sidebar-body"]}
|
|
onClick={() => {
|
|
setOpenSettings(false);
|
|
if (window.innerWidth < 768) {
|
|
setSideBarCollapse(true);
|
|
}
|
|
}}
|
|
>
|
|
<ChatList />
|
|
</div>
|
|
|
|
<div
|
|
className={
|
|
sidebarCollapse
|
|
? styles["sidebar-tail-collapse"]
|
|
: styles["sidebar-tail"]
|
|
}
|
|
>
|
|
<div
|
|
className={
|
|
sidebarCollapse
|
|
? styles["sidebar-actions-collapse"]
|
|
: styles["sidebar-actions"]
|
|
}
|
|
>
|
|
<div
|
|
className={
|
|
sidebarCollapse
|
|
? styles["sidebar-action-collapse"]
|
|
: styles["sidebar-action"]
|
|
}
|
|
>
|
|
{sidebarCollapse ? (
|
|
<IconButton
|
|
icon={<RightIcon />}
|
|
onClick={() => {
|
|
setSideBarCollapse(false);
|
|
}}
|
|
/>
|
|
) : (
|
|
<IconButton
|
|
icon={<LeftIcon />}
|
|
onClick={() => {
|
|
setSideBarCollapse(true);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div
|
|
className={
|
|
sidebarCollapse
|
|
? styles["sidebar-action-collapse"]
|
|
: styles["sidebar-action"] + " " + styles.mobile
|
|
}
|
|
>
|
|
<IconButton
|
|
icon={<CloseIcon />}
|
|
onClick={() => {
|
|
if (confirm(Locale.Home.DeleteChat)) {
|
|
removeSession(currentIndex);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
className={
|
|
sidebarCollapse
|
|
? styles["sidebar-action-collapse"]
|
|
: styles["sidebar-action"]
|
|
}
|
|
>
|
|
<IconButton
|
|
icon={<SettingsIcon />}
|
|
onClick={() => {
|
|
setOpenSettings(true);
|
|
setSideBarCollapse(true);
|
|
}}
|
|
shadow
|
|
/>
|
|
</div>
|
|
<div
|
|
className={
|
|
sidebarCollapse
|
|
? styles["sidebar-action-collapse"]
|
|
: styles["sidebar-action"]
|
|
}
|
|
>
|
|
<a href={REPO_URL} target="_blank">
|
|
<IconButton icon={<GithubIcon />} shadow />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={
|
|
sidebarCollapse ? styles["sidebar-action-collapse"] : undefined
|
|
}
|
|
>
|
|
<IconButton
|
|
icon={<AddIcon />}
|
|
text={sidebarCollapse ? undefined : Locale.Home.NewChat}
|
|
onClick={() => {
|
|
createNewSession();
|
|
setSideBarCollapse(true);
|
|
}}
|
|
shadow
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className={
|
|
sidebarCollapse
|
|
? styles["window-content-collapse"]
|
|
: styles["window-content"]
|
|
}
|
|
>
|
|
{openSettings ? (
|
|
<Settings
|
|
closeSettings={() => {
|
|
setOpenSettings(false);
|
|
setSideBarCollapse(false);
|
|
}}
|
|
/>
|
|
) : (
|
|
<Chat key="chat" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|