You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

382 lines
11 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"use client";
import { useState, useRef, useEffect } from "react";
import ReactMarkdown from "react-markdown";
import "katex/dist/katex.min.css";
import RemarkMath from "remark-math";
import RehypeKatex from "rehype-katex";
import EmojiPicker, { Emoji, EmojiClickData } from "emoji-picker-react";
import { IconButton } from "./button";
import styles from "./home.module.css";
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 { Message, SubmitKey, useChatStore } from "../store";
import { Card, List, ListItem, Popover } from "./ui-lib";
export function Markdown(props: { content: string }) {
return (
<ReactMarkdown remarkPlugins={[RemarkMath]} rehypePlugins={[RehypeKatex]}>
{props.content}
</ReactMarkdown>
);
}
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"]}>{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>
);
}
export function Chat() {
type RenderMessage = Message & { preview?: boolean };
const session = useChatStore((state) => state.currentSession());
const [userInput, setUserInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const onUserInput = useChatStore((state) => state.onUserInput);
const onUserSubmit = () => {
if (userInput.length <= 0) return;
setIsLoading(true);
onUserInput(userInput).then(() => setIsLoading(false));
setUserInput("");
};
const onInputKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && (e.shiftKey || e.ctrlKey || e.metaKey)) {
onUserSubmit();
e.preventDefault();
}
};
const latestMessageRef = useRef<HTMLDivElement>(null);
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,
},
]
: []
);
useEffect(() => {
latestMessageRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
});
});
return (
<div className={styles.chat} key={session.id}>
<div className={styles["window-header"]}>
<div>
<div className={styles["window-header-title"]}>{session.topic}</div>
<div className={styles["window-header-sub-title"]}>
ChatGPT {session.messages.length}
</div>
</div>
<div className={styles["chat-actions"]}>
<div className={styles["chat-action-button"]}>
<IconButton icon={<BrainIcon />} bordered />
</div>
<div className={styles["chat-action-button"]}>
<IconButton icon={<ExportIcon />} bordered />
</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"]}></div>
)}
<div className={styles["chat-message-item"]}>
{(message.preview || message.content.length === 0) &&
!isUser ? (
<LoadingIcon />
) : (
<div className="markdown-body">
<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>
);
})}
<span ref={latestMessageRef} style={{ opacity: 0 }}>
-
</span>
</div>
<div className={styles["chat-input-panel"]}>
<div className={styles["chat-input-panel-inner"]}>
<textarea
className={styles["chat-input"]}
placeholder="输入消息Ctrl + Enter 发送"
rows={3}
onInput={(e) => setUserInput(e.currentTarget.value)}
value={userInput}
onKeyDown={(e) => onInputKeyDown(e as any)}
/>
<IconButton
icon={<SendWhiteIcon />}
text={"发送"}
className={styles["chat-input-send"]}
onClick={onUserSubmit}
/>
</div>
</div>
</div>
);
}
export function Home() {
const [createNewSession] = useChatStore((state) => [state.newSession]);
// settings
const [openSettings, setOpenSettings] = useState(false);
return (
<div className={styles.container}>
<div className={styles.sidebar}>
<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"]}>
<ChatList />
</div>
<div className={styles["sidebar-tail"]}>
<div className={styles["sidebar-actions"]}>
<div className={styles["sidebar-action"]}>
<IconButton
icon={<SettingsIcon />}
onClick={() => setOpenSettings(!openSettings)}
/>
</div>
<div className={styles["sidebar-action"]}>
<a href="https://github.com/Yidadaa" target="_blank">
<IconButton icon={<GithubIcon />} />
</a>
</div>
</div>
<div>
<IconButton
icon={<AddIcon />}
text={"新的聊天"}
onClick={createNewSession}
/>
</div>
</div>
</div>
<div className={styles["window-content"]}>
{openSettings ? <Settings /> : <Chat key="chat" />}
</div>
</div>
);
}
export function EmojiPickerModal(props: {
show: boolean;
onClose: (_: boolean) => void;
}) {
return <div className=""></div>;
}
export function Settings() {
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [config, updateConfig] = useChatStore((state) => [
state.config,
state.updateConfig,
]);
return (
<>
<div className={styles["window-header"]}>
<div>
<div className={styles["window-header-title"]}></div>
<div className={styles["window-header-sub-title"]}></div>
</div>
</div>
<div className={styles["settings"]}>
<List>
<ListItem>
<div className={styles["settings-title"]}></div>
<Popover
onClose={() => setShowEmojiPicker(false)}
content={
<EmojiPicker
lazyLoadEmojis
onEmojiClick={(e) => {
updateConfig((config) => (config.avatar = e.unified));
setShowEmojiPicker(false);
}}
/>
}
open={showEmojiPicker}
>
<div
className={styles.avatar}
onClick={() => setShowEmojiPicker(true)}
>
<Avatar role="user" />
</div>
</Popover>
</ListItem>
<ListItem>
<div className={styles["settings-title"]}></div>
<div className="">
<select
value={config.submitKey}
onChange={(e) => {
updateConfig(
(config) =>
(config.submitKey = e.target.value as any as SubmitKey)
);
}}
>
{Object.entries(SubmitKey).map(([k, v]) => (
<option value={k} key={v}>
{v}
</option>
))}
</select>
</div>
</ListItem>
</List>
<List>
<ListItem>
<div className={styles["settings-title"]}></div>
<div className="">{config.historyMessageCount}</div>
</ListItem>
<ListItem>
<div className={styles["settings-title"]}></div>
<div className="">{config.sendBotMessages ? "是" : "否"}</div>
</ListItem>
</List>
</div>
</>
);
}