ChatGPT-Next-Web/app/components/home.tsx

755 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useRef, useEffect, useLayoutEffect } from "react";
import { useDebouncedCallback } from "use-debounce";
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 SendWhiteIcon from "../icons/send-white.svg";
import BrainIcon from "../icons/brain.svg";
import ExportIcon from "../icons/export.svg";
import BotIcon from "../icons/bot.svg";
import AddIcon from "../icons/add.svg";
import DeleteIcon from "../icons/delete.svg";
import LoadingIcon from "../icons/three-dots.svg";
import MenuIcon from "../icons/menu.svg";
import CloseIcon from "../icons/close.svg";
import CopyIcon from "../icons/copy.svg";
import DownloadIcon from "../icons/download.svg";
import { Message, SubmitKey, useChatStore, ChatSession } from "../store";
import { showModal, showToast } from "./ui-lib";
import {
copyToClipboard,
downloadAs,
isIOS,
isMobileScreen,
selectOrCopy,
} from "../utils";
import Locale from "../locales";
import dynamic from "next/dynamic";
import { REPO_URL } from "../constant";
import { ControllerPool } from "../requests";
import { Prompt, usePromptStore } from "../store/prompt";
export function Loading(props: { noLogo?: boolean }) {
return (
<div className={styles["loading-content"]}>
{!props.noLogo && <BotIcon />}
<LoadingIcon />
</div>
);
}
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
loading: () => <LoadingIcon />,
});
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>
);
}
export function ChatItem(props: {
onClick?: () => void;
onDelete?: () => void;
title: string;
count: number;
time: string;
selected: boolean;
}) {
return (
<div
className={`${styles["chat-item"]} ${
props.selected && styles["chat-item-selected"]
}`}
onClick={props.onClick}
>
<div className={styles["chat-item-title"]}>{props.title}</div>
<div className={styles["chat-item-info"]}>
<div className={styles["chat-item-count"]}>
{Locale.ChatItem.ChatItemCount(props.count)}
</div>
<div className={styles["chat-item-date"]}>{props.time}</div>
</div>
<div className={styles["chat-item-delete"]} onClick={props.onDelete}>
<DeleteIcon />
</div>
</div>
);
}
export function ChatList() {
const [sessions, selectedIndex, selectSession, removeSession] = useChatStore(
(state) => [
state.sessions,
state.currentSessionIndex,
state.selectSession,
state.removeSession,
],
);
return (
<div className={styles["chat-list"]}>
{sessions.map((item, i) => (
<ChatItem
title={item.topic}
time={item.lastUpdate}
count={item.messages.length}
key={i}
selected={i === selectedIndex}
onClick={() => selectSession(i)}
onDelete={() => removeSession(i)}
/>
))}
</div>
);
}
function useSubmitHandler() {
const config = useChatStore((state) => state.config);
const submitKey = config.submitKey;
const shouldSubmit = (e: KeyboardEvent) => {
if (e.key !== "Enter") 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>
);
}
export function Chat(props: {
showSideBar?: () => void;
sideBarShowing?: boolean;
}) {
type RenderMessage = Message & { preview?: boolean };
const chatStore = useChatStore();
const [session, sessionIndex] = useChatStore((state) => [
state.currentSession(),
state.currentSessionIndex,
]);
const fontSize = useChatStore((state) => state.config.fontSize);
const inputRef = useRef<HTMLTextAreaElement>(null);
const [userInput, setUserInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { submitKey, shouldSubmit } = useSubmitHandler();
// prompt hints
const promptStore = usePromptStore();
const [promptHints, setPromptHints] = useState<Prompt[]>([]);
const onSearch = useDebouncedCallback(
(text: string) => {
setPromptHints(promptStore.search(text));
},
100,
{ leading: true, trailing: true },
);
const onPromptSelect = (prompt: Prompt) => {
setUserInput(prompt.content);
setPromptHints([]);
inputRef.current?.focus();
};
const scrollInput = () => {
const dom = inputRef.current;
if (!dom) return;
const paddingBottomNum: number = parseInt(
window.getComputedStyle(dom).paddingBottom,
10,
);
dom.scrollTop = dom.scrollHeight - dom.offsetHeight + paddingBottomNum;
};
// only search prompts when user input is short
const SEARCH_TEXT_LIMIT = 30;
const onInput = (text: string) => {
scrollInput();
setUserInput(text);
const n = text.trim().length;
// clear search results
if (n === 0) {
setPromptHints([]);
} else if (!chatStore.config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
// check if need to trigger auto completion
if (text.startsWith("/") && text.length > 1) {
onSearch(text.slice(1));
}
}
};
// submit user input
const onUserSubmit = () => {
if (userInput.length <= 0) return;
setIsLoading(true);
chatStore.onUserInput(userInput).then(() => setIsLoading(false));
setUserInput("");
setPromptHints([]);
inputRef.current?.focus();
};
// stop response
const onUserStop = (messageIndex: number) => {
console.log(ControllerPool, sessionIndex, messageIndex);
ControllerPool.stop(sessionIndex, messageIndex);
};
// check if should send message
const onInputKeyDown = (e: KeyboardEvent) => {
if (shouldSubmit(e)) {
onUserSubmit();
e.preventDefault();
}
};
const onRightClick = (e: any, message: Message) => {
// auto fill user input
if (message.role === "user") {
setUserInput(message.content);
}
// copy to clipboard
if (selectOrCopy(e.currentTarget, message.content)) {
e.preventDefault();
}
};
const onResend = (botIndex: number) => {
// find last user input message and resend
for (let i = botIndex; i >= 0; i -= 1) {
if (messages[i].role === "user") {
setIsLoading(true);
chatStore
.onUserInput(messages[i].content)
.then(() => setIsLoading(false));
inputRef.current?.focus();
return;
}
}
};
// for auto-scroll
const latestMessageRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
// preview messages
const messages = (session.messages as RenderMessage[])
.concat(
isLoading
? [
{
role: "assistant",
content: "……",
date: new Date().toLocaleString(),
preview: true,
},
]
: [],
)
.concat(
userInput.length > 0
? [
{
role: "user",
content: userInput,
date: new Date().toLocaleString(),
preview: true,
},
]
: [],
);
// auto scroll
useLayoutEffect(() => {
setTimeout(() => {
const dom = latestMessageRef.current;
const inputDom = inputRef.current;
// only scroll when input overlaped message body
let shouldScroll = true;
if (dom && inputDom) {
const domRect = dom.getBoundingClientRect();
const inputRect = inputDom.getBoundingClientRect();
shouldScroll = domRect.top > inputRect.top;
}
if (dom && autoScroll && shouldScroll) {
dom.scrollIntoView({
block: "end",
});
}
}, 500);
});
return (
<div className={styles.chat} key={session.id}>
<div className={styles["window-header"]}>
<div
className={styles["window-header-title"]}
onClick={props?.showSideBar}
>
<div
className={`${styles["window-header-main-title"]} ${styles["chat-body-title"]}`}
onClick={() => {
const newTopic = prompt(Locale.Chat.Rename, session.topic);
if (newTopic && newTopic !== session.topic) {
chatStore.updateCurrentSession(
(session) => (session.topic = newTopic!),
);
}
}}
>
{session.topic}
</div>
<div className={styles["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}>
<IconButton
icon={<MenuIcon />}
bordered
title={Locale.Chat.Actions.ChatList}
onClick={props?.showSideBar}
/>
</div>
<div className={styles["window-action-button"]}>
<IconButton
icon={<BrainIcon />}
bordered
title={Locale.Chat.Actions.CompressedHistory}
onClick={() => {
showMemoryPrompt(session);
}}
/>
</div>
<div className={styles["window-action-button"]}>
<IconButton
icon={<ExportIcon />}
bordered
title={Locale.Chat.Actions.Export}
onClick={() => {
exportMessages(session.messages, session.topic);
}}
/>
</div>
</div>
</div>
<div className={styles["chat-body"]}>
{messages.map((message, i) => {
const isUser = message.role === "user";
return (
<div
key={i}
className={
isUser ? styles["chat-message-user"] : styles["chat-message"]
}
>
<div className={styles["chat-message-container"]}>
<div className={styles["chat-message-avatar"]}>
<Avatar role={message.role} />
</div>
{(message.preview || message.streaming) && (
<div className={styles["chat-message-status"]}>
{Locale.Chat.Typing}
</div>
)}
<div className={styles["chat-message-item"]}>
{!isUser &&
!(message.preview || message.content.length === 0) && (
<div className={styles["chat-message-top-actions"]}>
{message.streaming ? (
<div
className={styles["chat-message-top-action"]}
onClick={() => onUserStop(i)}
>
{Locale.Chat.Actions.Stop}
</div>
) : (
<div
className={styles["chat-message-top-action"]}
onClick={() => onResend(i)}
>
{Locale.Chat.Actions.Retry}
</div>
)}
<div
className={styles["chat-message-top-action"]}
onClick={() => copyToClipboard(message.content)}
>
{Locale.Chat.Actions.Copy}
</div>
</div>
)}
{(message.preview || message.content.length === 0) &&
!isUser ? (
<LoadingIcon />
) : (
<div
className="markdown-body"
style={{ fontSize: `${fontSize}px` }}
onContextMenu={(e) => onRightClick(e, message)}
onDoubleClickCapture={() => setUserInput(message.content)}
>
<Markdown content={message.content} />
</div>
)}
</div>
{!isUser && !message.preview && (
<div className={styles["chat-message-actions"]}>
<div className={styles["chat-message-action-date"]}>
{message.date.toLocaleString()}
</div>
</div>
)}
</div>
</div>
);
})}
<div ref={latestMessageRef} style={{ opacity: 0, height: "4em" }}>
-
</div>
</div>
<div className={styles["chat-input-panel"]}>
<PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
<div className={styles["chat-input-panel-inner"]}>
<textarea
ref={inputRef}
className={styles["chat-input"]}
placeholder={Locale.Chat.Input(submitKey)}
rows={4}
onInput={(e) => onInput(e.currentTarget.value)}
value={userInput}
onKeyDown={(e) => onInputKeyDown(e as any)}
onFocus={() => setAutoScroll(true)}
onBlur={() => {
setAutoScroll(false);
setTimeout(() => setPromptHints([]), 500);
}}
autoFocus={!props?.sideBarShowing}
/>
<IconButton
icon={<SendWhiteIcon />}
text={Locale.Chat.Send}
className={styles["chat-input-send"] + " no-dark"}
onClick={onUserSubmit}
/>
</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]);
}
function exportMessages(messages: Message[], topic: string) {
const mdText =
`# ${topic}\n\n` +
messages
.map((m) => {
return m.role === "user" ? `## ${m.content}` : m.content.trim();
})
.join("\n\n");
const filename = `${topic}.md`;
showModal({
title: Locale.Export.Title,
children: (
<div className="markdown-body">
<pre className={styles["export-content"]}>{mdText}</pre>
</div>
),
actions: [
<IconButton
key="copy"
icon={<CopyIcon />}
bordered
text={Locale.Export.Copy}
onClick={() => copyToClipboard(mdText)}
/>,
<IconButton
key="download"
icon={<DownloadIcon />}
bordered
text={Locale.Export.Download}
onClick={() => downloadAs(mdText, filename)}
/>,
],
});
}
function showMemoryPrompt(session: ChatSession) {
showModal({
title: `${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`,
children: (
<div className="markdown-body">
<pre className={styles["export-content"]}>
{session.memoryPrompt || Locale.Memory.EmptyContent}
</pre>
</div>
),
actions: [
<IconButton
key="copy"
icon={<CopyIcon />}
bordered
text={Locale.Memory.Copy}
onClick={() => copyToClipboard(session.memoryPrompt)}
/>,
],
});
}
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 [showSideBar, setShowSideBar] = useState(true);
// setting
const [openSettings, setOpenSettings] = useState(false);
const config = useChatStore((state) => state.config);
const [isAllow, setIsAllow] = useState(undefined);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const signature = urlParams.get("signature");
// 是否在白名单
fetch(`${process.env.API_URL}/api/check_user_white/?signature=${signature}`)
.then((response) => response.json())
.then((data) => {
const { code, msg } = data;
setIsAllow(code === 0);
})
.catch((error) => console.error(error));
}, []);
const isWorkWechat = () => {
//获取user-agaent标识头
const ua = window.navigator.userAgent.toLowerCase();
//判断ua和微信浏览器的标识头是否匹配
if (
ua.match(/micromessenger/i) == "micromessenger" &&
ua.match(/wxwork/i) == "wxwork"
) {
return true;
} else {
return false;
}
};
useSwitchTheme();
if (loading) {
return <Loading />;
}
const goApply = () => {
location.href = `${process.env.APPLY_URL}/#/process/create-ticket?processId=117`;
};
return (
<div
className={`${
config.tightBorder && !isMobileScreen()
? styles["tight-container"]
: styles.container
}`}
>
{!isWorkWechat() ? (
<h2 className="not-wx-work">使Gpt~</h2>
) : typeof isAllow === "boolean" && !isAllow ? (
<h2 className="not-allow">
<span className="text">使Gpt~</span>
<span onClick={goApply} className={styles["button"]}>
</span>
</h2>
) : (
<>
<div
className={
styles.sidebar + ` ${showSideBar && styles["sidebar-show"]}`
}
>
<div className={styles["sidebar-header"]}>
<div className={styles["sidebar-title"]}>ChatGPT Next</div>
<div className={styles["sidebar-sub-title"]}>
Build your own AI assistant.
</div>
<div className={styles["sidebar-logo"]}>
<ChatGptIcon />
</div>
</div>
<div
className={styles["sidebar-body"]}
onClick={() => {
setOpenSettings(false);
setShowSideBar(false);
}}
>
<ChatList />
</div>
<div className={styles["sidebar-tail"]}>
<div className={styles["sidebar-actions"]}>
<div className={styles["sidebar-action"] + " " + styles.mobile}>
<IconButton
icon={<CloseIcon />}
onClick={() => {
if (confirm(Locale.Home.DeleteChat)) {
removeSession(currentIndex);
}
}}
/>
</div>
<div className={styles["sidebar-action"]}>
<IconButton
icon={<SettingsIcon />}
onClick={() => {
setOpenSettings(true);
setShowSideBar(false);
}}
/>
</div>
<div className={styles["sidebar-action"]}>
<a href={REPO_URL} target="_blank">
<IconButton icon={<GithubIcon />} />
</a>
</div>
</div>
<div>
<IconButton
icon={<AddIcon />}
text={Locale.Home.NewChat}
onClick={() => {
createNewSession();
setShowSideBar(false);
}}
/>
</div>
</div>
</div>
<div className={styles["window-content"]}>
{openSettings ? (
<Settings
closeSettings={() => {
setOpenSettings(false);
setShowSideBar(true);
}}
/>
) : (
<Chat
key="chat"
showSideBar={() => setShowSideBar(true)}
sideBarShowing={showSideBar}
/>
)}
</div>
</>
)}
</div>
);
}