forked from XiaoMo/ChatGPT-Next-Web
Merge branch 'main' into main
This commit is contained in:
commit
c98517fc48
@ -6,19 +6,21 @@
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
|
||||
box-shadow: var(--card-shadow);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.shadow {
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.border {
|
||||
border: var(--border-in-light);
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
filter: brightness(0.9);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
@ -36,24 +38,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@mixin dark-button {
|
||||
div:not(:global(.no-dark))>.icon-button-icon {
|
||||
filter: invert(0.5);
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.dark) {
|
||||
@include dark-button;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@include dark-button;
|
||||
}
|
||||
|
||||
.icon-button-text {
|
||||
margin-left: 5px;
|
||||
font-size: 12px;
|
||||
|
@ -7,6 +7,7 @@ export function IconButton(props: {
|
||||
icon: JSX.Element;
|
||||
text?: string;
|
||||
bordered?: boolean;
|
||||
shadow?: boolean;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
@ -14,10 +15,13 @@ export function IconButton(props: {
|
||||
<div
|
||||
className={
|
||||
styles["icon-button"] +
|
||||
` ${props.bordered && styles.border} ${props.className ?? ""}`
|
||||
` ${props.bordered && styles.border} ${props.shadow && styles.shadow} ${
|
||||
props.className ?? ""
|
||||
} clickable`
|
||||
}
|
||||
onClick={props.onClick}
|
||||
title={props.title}
|
||||
role="button"
|
||||
>
|
||||
<div className={styles["icon-button-icon"]}>{props.icon}</div>
|
||||
{props.text && (
|
||||
|
73
app/components/chat-list.tsx
Normal file
73
app/components/chat-list.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { useState, useRef, useEffect, useLayoutEffect } from "react";
|
||||
import DeleteIcon from "../icons/delete.svg";
|
||||
import styles from "./home.module.scss";
|
||||
|
||||
import {
|
||||
Message,
|
||||
SubmitKey,
|
||||
useChatStore,
|
||||
ChatSession,
|
||||
BOT_HELLO,
|
||||
} from "../store";
|
||||
|
||||
import Locale from "../locales";
|
||||
import { isMobileScreen } from "../utils";
|
||||
|
||||
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={() =>
|
||||
(!isMobileScreen() || confirm(Locale.Home.DeleteChat)) &&
|
||||
removeSession(i)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
71
app/components/chat.module.scss
Normal file
71
app/components/chat.module.scss
Normal file
@ -0,0 +1,71 @@
|
||||
.prompt-toast {
|
||||
position: absolute;
|
||||
bottom: -50px;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: calc(100% - 40px);
|
||||
|
||||
.prompt-toast-inner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
background-color: var(--white);
|
||||
color: var(--black);
|
||||
|
||||
border: var(--border-in-light);
|
||||
box-shadow: var(--card-shadow);
|
||||
padding: 10px 20px;
|
||||
border-radius: 100px;
|
||||
|
||||
.prompt-toast-content {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.context-prompt {
|
||||
.context-prompt-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.context-role {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.context-content {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.context-delete-button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.context-prompt-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.memory-prompt {
|
||||
margin-top: 20px;
|
||||
|
||||
.memory-prompt-title {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.memory-prompt-content {
|
||||
background-color: var(--gray);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
user-select: text;
|
||||
}
|
||||
}
|
622
app/components/chat.tsx
Normal file
622
app/components/chat.tsx
Normal file
@ -0,0 +1,622 @@
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { useState, useRef, useEffect, useLayoutEffect } from "react";
|
||||
|
||||
import SendWhiteIcon from "../icons/send-white.svg";
|
||||
import BrainIcon from "../icons/brain.svg";
|
||||
import ExportIcon from "../icons/export.svg";
|
||||
import MenuIcon from "../icons/menu.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 AddIcon from "../icons/add.svg";
|
||||
import DeleteIcon from "../icons/delete.svg";
|
||||
|
||||
import {
|
||||
Message,
|
||||
SubmitKey,
|
||||
useChatStore,
|
||||
ChatSession,
|
||||
BOT_HELLO,
|
||||
ROLES,
|
||||
} from "../store";
|
||||
|
||||
import {
|
||||
copyToClipboard,
|
||||
downloadAs,
|
||||
isMobileScreen,
|
||||
selectOrCopy,
|
||||
} from "../utils";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { ControllerPool } from "../requests";
|
||||
import { Prompt, usePromptStore } from "../store/prompt";
|
||||
import Locale from "../locales";
|
||||
|
||||
import { IconButton } from "./button";
|
||||
import styles from "./home.module.scss";
|
||||
import chatStyle from "./chat.module.scss";
|
||||
|
||||
import { Modal, showModal, showToast } from "./ui-lib";
|
||||
|
||||
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
||||
loading: () => <LoadingIcon />,
|
||||
});
|
||||
|
||||
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 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 PromptToast(props: {
|
||||
showModal: boolean;
|
||||
setShowModal: (_: boolean) => void;
|
||||
}) {
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={chatStyle["prompt-toast"]} key="prompt-toast">
|
||||
<div
|
||||
className={chatStyle["prompt-toast-inner"] + " clickable"}
|
||||
role="button"
|
||||
onClick={() => props.setShowModal(true)}
|
||||
>
|
||||
<BrainIcon />
|
||||
<span className={chatStyle["prompt-toast-content"]}>
|
||||
{Locale.Context.Toast(context.length)}
|
||||
</span>
|
||||
</div>
|
||||
{props.showModal && (
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={Locale.Context.Edit}
|
||||
onClose={() => props.setShowModal(false)}
|
||||
actions={[
|
||||
<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"]}
|
||||
onChange={(e) =>
|
||||
updateContextPrompt(i, {
|
||||
...c,
|
||||
content: e.target.value as any,
|
||||
})
|
||||
}
|
||||
></input>
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
className={chatStyle["context-delete-button"]}
|
||||
onClick={() => removeContextPrompt(i)}
|
||||
/>
|
||||
</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"]}>
|
||||
{Locale.Memory.Title} ({session.lastSummarizeIndex} of{" "}
|
||||
{session.messages.length})
|
||||
</div>
|
||||
<div className={chatStyle["memory-prompt-content"]}>
|
||||
{session.memoryPrompt || Locale.Memory.EmptyContent}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</Modal>
|
||||
</div>
|
||||
)}
|
||||
</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 useScrollToBottom() {
|
||||
// for auto-scroll
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
|
||||
// auto scroll
|
||||
useLayoutEffect(() => {
|
||||
const dom = scrollRef.current;
|
||||
if (dom && autoScroll) {
|
||||
setTimeout(() => (dom.scrollTop = dom.scrollHeight), 500);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
autoScroll,
|
||||
setAutoScroll,
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
const { scrollRef, setAutoScroll } = useScrollToBottom();
|
||||
|
||||
// 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("/")) {
|
||||
let searchText = text.slice(1);
|
||||
if (searchText.length === 0) {
|
||||
searchText = " ";
|
||||
}
|
||||
onSearch(searchText);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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) => {
|
||||
ControllerPool.stop(sessionIndex, messageIndex);
|
||||
};
|
||||
|
||||
// check if should send message
|
||||
const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const config = useChatStore((state) => state.config);
|
||||
|
||||
const context: RenderMessage[] = session.context.slice();
|
||||
|
||||
if (
|
||||
context.length === 0 &&
|
||||
session.messages.at(0)?.content !== BOT_HELLO.content
|
||||
) {
|
||||
context.push(BOT_HELLO);
|
||||
}
|
||||
|
||||
// preview messages
|
||||
const messages = context
|
||||
.concat(session.messages as RenderMessage[])
|
||||
.concat(
|
||||
isLoading
|
||||
? [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "……",
|
||||
date: new Date().toLocaleString(),
|
||||
preview: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
.concat(
|
||||
userInput.length > 0 && config.sendPreviewBubble
|
||||
? [
|
||||
{
|
||||
role: "user",
|
||||
content: userInput,
|
||||
date: new Date().toLocaleString(),
|
||||
preview: false,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
const [showPromptModal, setShowPromptModal] = useState(false);
|
||||
|
||||
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={() => {
|
||||
setShowPromptModal(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles["window-action-button"]}>
|
||||
<IconButton
|
||||
icon={<ExportIcon />}
|
||||
bordered
|
||||
title={Locale.Chat.Actions.Export}
|
||||
onClick={() => {
|
||||
exportMessages(session.messages, session.topic);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PromptToast
|
||||
showModal={showPromptModal}
|
||||
setShowModal={setShowPromptModal}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles["chat-body"]} ref={scrollRef}>
|
||||
{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"]}
|
||||
onMouseOver={() => inputRef.current?.blur()}
|
||||
>
|
||||
{!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={() => {
|
||||
if (!isMobileScreen()) return;
|
||||
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>
|
||||
|
||||
<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={2}
|
||||
onInput={(e) => onInput(e.currentTarget.value)}
|
||||
value={userInput}
|
||||
onKeyDown={onInputKeyDown}
|
||||
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>
|
||||
);
|
||||
}
|
@ -218,6 +218,7 @@
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-body-title {
|
||||
|
@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useLayoutEffect } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
import { IconButton } from "./button";
|
||||
import styles from "./home.module.scss";
|
||||
@ -9,33 +8,31 @@ 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 {
|
||||
Message,
|
||||
SubmitKey,
|
||||
useChatStore,
|
||||
ChatSession,
|
||||
BOT_HELLO,
|
||||
} from "../store";
|
||||
import {
|
||||
copyToClipboard,
|
||||
downloadAs,
|
||||
isIOS,
|
||||
isMobileScreen,
|
||||
selectOrCopy,
|
||||
} 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 { ControllerPool } from "../requests";
|
||||
import { Prompt, usePromptStore } from "../store/prompt";
|
||||
|
||||
export function Loading(props: { noLogo?: boolean }) {
|
||||
return (
|
||||
@ -46,469 +43,10 @@ export function Loading(props: { noLogo?: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
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={() => confirm(Locale.Home.DeleteChat) && removeSession(i)}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
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);
|
||||
|
||||
const config = useChatStore((state) => state.config);
|
||||
|
||||
// preview messages
|
||||
const messages = (session.messages as RenderMessage[])
|
||||
.concat(
|
||||
isLoading
|
||||
? [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "……",
|
||||
date: new Date().toLocaleString(),
|
||||
preview: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
).concat(
|
||||
userInput.length > 0 && config.sendPreviewBubble
|
||||
? [
|
||||
{
|
||||
role: "user",
|
||||
content: userInput,
|
||||
date: new Date().toLocaleString(),
|
||||
preview: false,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
// 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={() => {
|
||||
if (!isMobileScreen()) return;
|
||||
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: "1px" }}>
|
||||
-
|
||||
</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={onInputKeyDown}
|
||||
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);
|
||||
|
||||
@ -530,64 +68,6 @@ function useSwitchTheme() {
|
||||
}, [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);
|
||||
|
||||
@ -669,11 +149,12 @@ export function Home() {
|
||||
setOpenSettings(true);
|
||||
setShowSideBar(false);
|
||||
}}
|
||||
shadow
|
||||
/>
|
||||
</div>
|
||||
<div className={styles["sidebar-action"]}>
|
||||
<a href={REPO_URL} target="_blank">
|
||||
<IconButton icon={<GithubIcon />} />
|
||||
<IconButton icon={<GithubIcon />} shadow />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -685,6 +166,7 @@ export function Home() {
|
||||
createNewSession();
|
||||
setShowSideBar(false);
|
||||
}}
|
||||
shadow
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -4,8 +4,8 @@ import RemarkMath from "remark-math";
|
||||
import RemarkBreaks from "remark-breaks";
|
||||
import RehypeKatex from "rehype-katex";
|
||||
import RemarkGfm from "remark-gfm";
|
||||
import RehypePrsim from "rehype-prism-plus";
|
||||
import { useRef } from "react";
|
||||
import RehypeHighlight from "rehype-highlight";
|
||||
import { useRef, useState, RefObject, useEffect } from "react";
|
||||
import { copyToClipboard } from "../utils";
|
||||
|
||||
export function PreCode(props: { children: any }) {
|
||||
@ -27,11 +27,43 @@ export function PreCode(props: { children: any }) {
|
||||
);
|
||||
}
|
||||
|
||||
const useLazyLoad = (ref: RefObject<Element>): boolean => {
|
||||
const [isIntersecting, setIntersecting] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIntersecting(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
if (ref.current) {
|
||||
observer.observe(ref.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
return isIntersecting;
|
||||
};
|
||||
|
||||
export function Markdown(props: { content: string }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
|
||||
rehypePlugins={[RehypeKatex, [RehypePrsim, { ignoreMissing: true }]]}
|
||||
rehypePlugins={[
|
||||
RehypeKatex,
|
||||
[
|
||||
RehypeHighlight,
|
||||
{
|
||||
detect: true,
|
||||
ignoreMissing: true,
|
||||
},
|
||||
],
|
||||
]}
|
||||
components={{
|
||||
pre: PreCode,
|
||||
}}
|
||||
|
@ -20,7 +20,7 @@ import {
|
||||
useUpdateStore,
|
||||
useAccessStore,
|
||||
} from "../store";
|
||||
import { Avatar, PromptHints } from "./home";
|
||||
import { Avatar } from "./chat";
|
||||
|
||||
import Locale, { AllLangs, changeLang, getLang } from "../locales";
|
||||
import { getCurrentVersion } from "../utils";
|
||||
@ -72,7 +72,6 @@ export function Settings(props: { closeSettings: () => void }) {
|
||||
}
|
||||
|
||||
const [usage, setUsage] = useState<{
|
||||
granted?: number;
|
||||
used?: number;
|
||||
}>();
|
||||
const [loadingUsage, setLoadingUsage] = useState(false);
|
||||
@ -81,8 +80,7 @@ export function Settings(props: { closeSettings: () => void }) {
|
||||
requestUsage()
|
||||
.then((res) =>
|
||||
setUsage({
|
||||
granted: res?.total_granted,
|
||||
used: res?.total_used,
|
||||
used: res,
|
||||
}),
|
||||
)
|
||||
.finally(() => {
|
||||
@ -285,7 +283,8 @@ export function Settings(props: { closeSettings: () => void }) {
|
||||
checked={config.sendPreviewBubble}
|
||||
onChange={(e) =>
|
||||
updateConfig(
|
||||
(config) => (config.sendPreviewBubble = e.currentTarget.checked),
|
||||
(config) =>
|
||||
(config.sendPreviewBubble = e.currentTarget.checked),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
@ -360,10 +359,7 @@ export function Settings(props: { closeSettings: () => void }) {
|
||||
subTitle={
|
||||
loadingUsage
|
||||
? Locale.Settings.Usage.IsChecking
|
||||
: Locale.Settings.Usage.SubTitle(
|
||||
usage?.granted ?? "[?]",
|
||||
usage?.used ?? "[?]",
|
||||
)
|
||||
: Locale.Settings.Usage.SubTitle(usage?.used ?? "[?]")
|
||||
}
|
||||
>
|
||||
{loadingUsage ? (
|
||||
|
@ -1,6 +1,7 @@
|
||||
.window-header {
|
||||
padding: 14px 20px;
|
||||
border-bottom: rgba(0, 0, 0, 0.1) 1px solid;
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
@ -1,7 +1,7 @@
|
||||
/* eslint-disable @next/next/no-page-custom-font */
|
||||
import "./styles/globals.scss";
|
||||
import "./styles/markdown.scss";
|
||||
import "./styles/prism.scss";
|
||||
import "./styles/highlight.scss";
|
||||
import process from "child_process";
|
||||
import { ACCESS_CODES, IS_IN_DOCKER } from "./api/access";
|
||||
|
||||
|
@ -104,8 +104,8 @@ const cn = {
|
||||
},
|
||||
Usage: {
|
||||
Title: "账户余额",
|
||||
SubTitle(granted: any, used: any) {
|
||||
return `总共 $${granted},已使用 $${used}`;
|
||||
SubTitle(used: any) {
|
||||
return `本月已使用 $${used}`;
|
||||
},
|
||||
IsChecking: "正在检查…",
|
||||
Check: "重新检查",
|
||||
@ -139,7 +139,7 @@ const cn = {
|
||||
Topic:
|
||||
"使用四到五个字直接返回这句话的简要主题,不要解释、不要标点、不要语气词、不要多余文本,如果没有主题,请直接返回“闲聊”",
|
||||
Summarize:
|
||||
"简要总结一下你和用户的对话,用作后续的上下文提示 prompt,控制在 50 字以内",
|
||||
"简要总结一下你和用户的对话,用作后续的上下文提示 prompt,控制在 200 字以内",
|
||||
},
|
||||
ConfirmClearAll: "确认清除所有聊天、设置数据?",
|
||||
},
|
||||
@ -147,6 +147,11 @@ const cn = {
|
||||
Success: "已写入剪切板",
|
||||
Failed: "复制失败,请赋予剪切板权限",
|
||||
},
|
||||
Context: {
|
||||
Toast: (x: any) => `已设置 ${x} 条前置上下文`,
|
||||
Edit: "前置上下文和历史记忆",
|
||||
Add: "新增一条",
|
||||
},
|
||||
};
|
||||
|
||||
export type LocaleType = typeof cn;
|
||||
|
@ -54,7 +54,7 @@ const en: LocaleType = {
|
||||
Close: "Close",
|
||||
},
|
||||
Lang: {
|
||||
Name: "Language",
|
||||
Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
|
||||
Options: {
|
||||
cn: "简体中文",
|
||||
en: "English",
|
||||
@ -106,8 +106,8 @@ const en: LocaleType = {
|
||||
},
|
||||
Usage: {
|
||||
Title: "Account Balance",
|
||||
SubTitle(granted: any, used: any) {
|
||||
return `Total $${granted}, Used $${used}`;
|
||||
SubTitle(used: any) {
|
||||
return `Used this month $${used}`;
|
||||
},
|
||||
IsChecking: "Checking...",
|
||||
Check: "Check Again",
|
||||
@ -143,7 +143,7 @@ const en: LocaleType = {
|
||||
Topic:
|
||||
"Please generate a four to five word title summarizing our conversation without any lead-in, punctuation, quotation marks, periods, symbols, or additional text. Remove enclosing quotation marks.",
|
||||
Summarize:
|
||||
"Summarize our discussion briefly in 50 characters or less to use as a prompt for future context.",
|
||||
"Summarize our discussion briefly in 200 words or less to use as a prompt for future context.",
|
||||
},
|
||||
ConfirmClearAll: "Confirm to clear all chat and setting data?",
|
||||
},
|
||||
@ -151,6 +151,11 @@ const en: LocaleType = {
|
||||
Success: "Copied to clipboard",
|
||||
Failed: "Copy failed, please grant permission to access clipboard",
|
||||
},
|
||||
Context: {
|
||||
Toast: (x: any) => `With ${x} contextual prompts`,
|
||||
Edit: "Contextual and Memory Prompts",
|
||||
Add: "Add One",
|
||||
},
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
@ -79,7 +79,7 @@ const es: LocaleType = {
|
||||
SendKey: "Tecla de envío",
|
||||
Theme: "Tema",
|
||||
TightBorder: "Borde ajustado",
|
||||
SendPreviewBubble: "Send preview bubble",
|
||||
SendPreviewBubble: "Enviar burbuja de vista previa",
|
||||
Prompt: {
|
||||
Disable: {
|
||||
Title: "Desactivar autocompletado",
|
||||
@ -106,8 +106,8 @@ const es: LocaleType = {
|
||||
},
|
||||
Usage: {
|
||||
Title: "Saldo de la cuenta",
|
||||
SubTitle(granted: any, used: any) {
|
||||
return `Total $${granted}, Usado $${used}`;
|
||||
SubTitle(used: any) {
|
||||
return `Usado $${used}`;
|
||||
},
|
||||
IsChecking: "Comprobando...",
|
||||
Check: "Comprobar de nuevo",
|
||||
@ -143,7 +143,7 @@ const es: LocaleType = {
|
||||
Topic:
|
||||
"Por favor, genera un título de cuatro a cinco palabras que resuma nuestra conversación sin ningún inicio, puntuación, comillas, puntos, símbolos o texto adicional. Elimina las comillas que lo envuelven.",
|
||||
Summarize:
|
||||
"Resuma nuestra discusión brevemente en 50 caracteres o menos para usarlo como un recordatorio para futuros contextos.",
|
||||
"Resuma nuestra discusión brevemente en 200 caracteres o menos para usarlo como un recordatorio para futuros contextos.",
|
||||
},
|
||||
ConfirmClearAll:
|
||||
"¿Confirmar para borrar todos los datos de chat y configuración?",
|
||||
@ -153,6 +153,11 @@ const es: LocaleType = {
|
||||
Failed:
|
||||
"La copia falló, por favor concede permiso para acceder al portapapeles",
|
||||
},
|
||||
Context: {
|
||||
Toast: (x: any) => `With ${x} contextual prompts`,
|
||||
Edit: "Contextual and Memory Prompts",
|
||||
Add: "Add One",
|
||||
},
|
||||
};
|
||||
|
||||
export default es;
|
||||
|
@ -104,8 +104,8 @@ const tw: LocaleType = {
|
||||
},
|
||||
Usage: {
|
||||
Title: "帳戶餘額",
|
||||
SubTitle(granted: any, used: any) {
|
||||
return `總共 $${granted},已使用 $${used}`;
|
||||
SubTitle(used: any) {
|
||||
return `本月已使用 $${used}`;
|
||||
},
|
||||
IsChecking: "正在檢查…",
|
||||
Check: "重新檢查",
|
||||
@ -138,7 +138,7 @@ const tw: LocaleType = {
|
||||
"這是 AI 與用戶的歷史聊天總結,作為前情提要:" + content,
|
||||
Topic: "直接返回這句話的簡要主題,無須解釋,若無主題,請直接返回「閒聊」",
|
||||
Summarize:
|
||||
"簡要總結一下你和用戶的對話,作為後續的上下文提示 prompt,且字數控制在 50 字以內",
|
||||
"簡要總結一下你和用戶的對話,作為後續的上下文提示 prompt,且字數控制在 200 字以內",
|
||||
},
|
||||
ConfirmClearAll: "確認清除所有對話、設定數據?",
|
||||
},
|
||||
@ -146,6 +146,11 @@ const tw: LocaleType = {
|
||||
Success: "已複製到剪貼簿中",
|
||||
Failed: "複製失敗,請賦予剪貼簿權限",
|
||||
},
|
||||
Context: {
|
||||
Toast: (x: any) => `已設置 ${x} 條前置上下文`,
|
||||
Edit: "前置上下文和歷史記憶",
|
||||
Add: "新增壹條",
|
||||
},
|
||||
};
|
||||
|
||||
export default tw;
|
||||
|
@ -1,4 +1,7 @@
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
|
||||
import "array.prototype.at";
|
||||
|
||||
import { Home } from "./components/home";
|
||||
|
||||
export default function App() {
|
||||
|
@ -2,10 +2,6 @@ import type { ChatRequest, ChatReponse } from "./api/openai/typing";
|
||||
import { filterConfig, Message, ModelConfig, useAccessStore } from "./store";
|
||||
import Locale from "./locales";
|
||||
|
||||
if (!Array.prototype.at) {
|
||||
require("array.prototype.at/auto");
|
||||
}
|
||||
|
||||
const TIME_OUT_MS = 30000;
|
||||
|
||||
const makeRequestParam = (
|
||||
@ -52,6 +48,7 @@ export function requestOpenaiClient(path: string) {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-cache",
|
||||
path,
|
||||
...getHeaders(),
|
||||
},
|
||||
@ -73,17 +70,25 @@ export async function requestChat(messages: Message[]) {
|
||||
}
|
||||
|
||||
export async function requestUsage() {
|
||||
const formatDate = (d: Date) =>
|
||||
`${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}-${d
|
||||
.getDate()
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
const ONE_DAY = 24 * 60 * 60 * 1000;
|
||||
const now = new Date(Date.now() + ONE_DAY);
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const startDate = formatDate(startOfMonth);
|
||||
const endDate = formatDate(now);
|
||||
const res = await requestOpenaiClient(
|
||||
"dashboard/billing/credit_grants?_vercel_no_cache=1",
|
||||
`dashboard/billing/usage?start_date=${startDate}&end_date=${endDate}`,
|
||||
)(null, "GET");
|
||||
|
||||
try {
|
||||
const response = (await res.json()) as {
|
||||
total_available: number;
|
||||
total_granted: number;
|
||||
total_used: number;
|
||||
total_usage: number;
|
||||
};
|
||||
return response;
|
||||
return Math.round(response.total_usage) / 100;
|
||||
} catch (error) {
|
||||
console.error("[Request usage] ", error, res.body);
|
||||
}
|
||||
|
@ -11,10 +11,6 @@ import { trimTopic } from "../utils";
|
||||
|
||||
import Locale from "../locales";
|
||||
|
||||
if (!Array.prototype.at) {
|
||||
require("array.prototype.at/auto");
|
||||
}
|
||||
|
||||
export type Message = ChatCompletionResponseMessage & {
|
||||
date: string;
|
||||
streaming?: boolean;
|
||||
@ -57,6 +53,8 @@ export interface ChatConfig {
|
||||
|
||||
export type ModelConfig = ChatConfig["modelConfig"];
|
||||
|
||||
export const ROLES: Message["role"][] = ["system", "user", "assistant"];
|
||||
|
||||
const ENABLE_GPT4 = true;
|
||||
|
||||
export const ALL_MODELS = [
|
||||
@ -155,6 +153,7 @@ export interface ChatSession {
|
||||
id: number;
|
||||
topic: string;
|
||||
memoryPrompt: string;
|
||||
context: Message[];
|
||||
messages: Message[];
|
||||
stat: ChatStat;
|
||||
lastUpdate: string;
|
||||
@ -162,6 +161,11 @@ export interface ChatSession {
|
||||
}
|
||||
|
||||
const DEFAULT_TOPIC = Locale.Store.DefaultTopic;
|
||||
export const BOT_HELLO: Message = {
|
||||
role: "assistant",
|
||||
content: Locale.Store.BotHello,
|
||||
date: "",
|
||||
};
|
||||
|
||||
function createEmptySession(): ChatSession {
|
||||
const createDate = new Date().toLocaleString();
|
||||
@ -170,13 +174,8 @@ function createEmptySession(): ChatSession {
|
||||
id: Date.now(),
|
||||
topic: DEFAULT_TOPIC,
|
||||
memoryPrompt: "",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: Locale.Store.BotHello,
|
||||
date: createDate,
|
||||
},
|
||||
],
|
||||
context: [],
|
||||
messages: [],
|
||||
stat: {
|
||||
tokenCount: 0,
|
||||
wordCount: 0,
|
||||
@ -385,16 +384,18 @@ export const useChatStore = create<ChatStore>()(
|
||||
const session = get().currentSession();
|
||||
const config = get().config;
|
||||
const n = session.messages.length;
|
||||
const recentMessages = session.messages.slice(
|
||||
Math.max(0, n - config.historyMessageCount),
|
||||
);
|
||||
|
||||
const context = session.context.slice();
|
||||
|
||||
if (session.memoryPrompt && session.memoryPrompt.length > 0) {
|
||||
const memoryPrompt = get().getMemoryPrompt();
|
||||
|
||||
if (session.memoryPrompt) {
|
||||
recentMessages.unshift(memoryPrompt);
|
||||
context.push(memoryPrompt);
|
||||
}
|
||||
|
||||
const recentMessages = context.concat(
|
||||
session.messages.slice(Math.max(0, n - config.historyMessageCount)),
|
||||
);
|
||||
|
||||
return recentMessages;
|
||||
},
|
||||
|
||||
@ -432,11 +433,13 @@ export const useChatStore = create<ChatStore>()(
|
||||
let toBeSummarizedMsgs = session.messages.slice(
|
||||
session.lastSummarizeIndex,
|
||||
);
|
||||
|
||||
const historyMsgLength = countMessages(toBeSummarizedMsgs);
|
||||
|
||||
if (historyMsgLength > 4000) {
|
||||
if (historyMsgLength > get().config?.modelConfig?.max_tokens ?? 4000) {
|
||||
const n = toBeSummarizedMsgs.length;
|
||||
toBeSummarizedMsgs = toBeSummarizedMsgs.slice(
|
||||
-config.historyMessageCount,
|
||||
Math.max(0, n - config.historyMessageCount),
|
||||
);
|
||||
}
|
||||
|
||||
@ -499,7 +502,16 @@ export const useChatStore = create<ChatStore>()(
|
||||
}),
|
||||
{
|
||||
name: LOCAL_KEY,
|
||||
version: 1,
|
||||
version: 1.1,
|
||||
migrate(persistedState, version) {
|
||||
const state = persistedState as ChatStore;
|
||||
|
||||
if (version === 1) {
|
||||
state.sessions.forEach((s) => (s.context = []));
|
||||
}
|
||||
|
||||
return state;
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
@ -117,7 +117,7 @@ body {
|
||||
|
||||
select {
|
||||
border: var(--border-in-light);
|
||||
padding: 8px 10px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
@ -188,7 +188,7 @@ input[type="text"] {
|
||||
appearance: none;
|
||||
border-radius: 10px;
|
||||
border: var(--border-in-light);
|
||||
height: 32px;
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
background: var(--white);
|
||||
color: var(--black);
|
||||
@ -235,6 +235,7 @@ pre {
|
||||
.copy-code-button {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 1em;
|
||||
cursor: pointer;
|
||||
padding: 0px 5px;
|
||||
background-color: var(--black);
|
||||
@ -255,3 +256,15 @@ pre {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
div:not(.no-dark) > svg {
|
||||
filter: invert(0.5);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
}
|
||||
|
114
app/styles/highlight.scss
Normal file
114
app/styles/highlight.scss
Normal file
@ -0,0 +1,114 @@
|
||||
.markdown-body {
|
||||
pre {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
pre,
|
||||
code {
|
||||
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
|
||||
}
|
||||
|
||||
pre code.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
code.hljs {
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
/*!
|
||||
Theme: Tokyo-night-Dark
|
||||
origin: https://github.com/enkia/tokyo-night-vscode-theme
|
||||
Description: Original highlight.js style
|
||||
Author: (c) Henri Vandersleyen <hvandersleyen@gmail.com>
|
||||
License: see project LICENSE
|
||||
Touched: 2022
|
||||
*/
|
||||
.hljs-comment,
|
||||
.hljs-meta {
|
||||
color: #565f89;
|
||||
}
|
||||
|
||||
.hljs-deletion,
|
||||
.hljs-doctag,
|
||||
.hljs-regexp,
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-class,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-pseudo,
|
||||
.hljs-tag,
|
||||
.hljs-template-tag,
|
||||
.hljs-variable.language_ {
|
||||
color: #f7768e;
|
||||
}
|
||||
|
||||
.hljs-link,
|
||||
.hljs-literal,
|
||||
.hljs-number,
|
||||
.hljs-params,
|
||||
.hljs-template-variable,
|
||||
.hljs-type,
|
||||
.hljs-variable {
|
||||
color: #ff9e64;
|
||||
}
|
||||
|
||||
.hljs-attribute,
|
||||
.hljs-built_in {
|
||||
color: #e0af68;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-property,
|
||||
.hljs-subst,
|
||||
.hljs-title,
|
||||
.hljs-title.class_,
|
||||
.hljs-title.class_.inherited__,
|
||||
.hljs-title.function_ {
|
||||
color: #7dcfff;
|
||||
}
|
||||
|
||||
.hljs-selector-tag {
|
||||
color: #73daca;
|
||||
}
|
||||
|
||||
.hljs-addition,
|
||||
.hljs-bullet,
|
||||
.hljs-quote,
|
||||
.hljs-string,
|
||||
.hljs-symbol {
|
||||
color: #9ece6a;
|
||||
}
|
||||
|
||||
.hljs-code,
|
||||
.hljs-formula,
|
||||
.hljs-section {
|
||||
color: #7aa2f7;
|
||||
}
|
||||
|
||||
.hljs-attr,
|
||||
.hljs-char.escape_,
|
||||
.hljs-keyword,
|
||||
.hljs-name,
|
||||
.hljs-operator {
|
||||
color: #bb9af7;
|
||||
}
|
||||
|
||||
.hljs-punctuation {
|
||||
color: #c0caf5;
|
||||
}
|
||||
|
||||
.hljs {
|
||||
background: #1a1b26;
|
||||
color: #9aa5ce;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
@ -1,122 +0,0 @@
|
||||
.markdown-body {
|
||||
pre {
|
||||
background: #282a36;
|
||||
color: #f8f8f2;
|
||||
}
|
||||
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
color: #f8f8f2;
|
||||
background: none;
|
||||
text-shadow: 0 1px rgba(0, 0, 0, 0.3);
|
||||
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
word-wrap: normal;
|
||||
line-height: 1.5;
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
-webkit-hyphens: none;
|
||||
-moz-hyphens: none;
|
||||
-ms-hyphens: none;
|
||||
hyphens: none;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre[class*="language-"] {
|
||||
padding: 1em;
|
||||
margin: 0.5em 0;
|
||||
overflow: auto;
|
||||
border-radius: 0.3em;
|
||||
}
|
||||
|
||||
:not(pre) > code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
background: #282a36;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
:not(pre) > code[class*="language-"] {
|
||||
padding: 0.1em;
|
||||
border-radius: 0.3em;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: #6272a4;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: #f8f8f2;
|
||||
}
|
||||
|
||||
.namespace {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.tag,
|
||||
.token.constant,
|
||||
.token.symbol,
|
||||
.token.deleted {
|
||||
color: #ff79c6;
|
||||
}
|
||||
|
||||
.token.boolean,
|
||||
.token.number {
|
||||
color: #bd93f9;
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.attr-name,
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.builtin,
|
||||
.token.inserted {
|
||||
color: #50fa7b;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.language-css .token.string,
|
||||
.style .token.string,
|
||||
.token.variable {
|
||||
color: #f8f8f2;
|
||||
}
|
||||
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.function,
|
||||
.token.class-name {
|
||||
color: #f1fa8c;
|
||||
}
|
||||
|
||||
.token.keyword {
|
||||
color: #8be9fd;
|
||||
}
|
||||
|
||||
.token.regex,
|
||||
.token.important {
|
||||
color: #ffb86c;
|
||||
}
|
||||
|
||||
.token.important,
|
||||
.token.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
}
|
||||
}
|
@ -14,6 +14,7 @@
|
||||
"dependencies": {
|
||||
"@svgr/webpack": "^6.5.1",
|
||||
"@vercel/analytics": "^0.1.11",
|
||||
"array.prototype.at": "^1.1.1",
|
||||
"emoji-picker-react": "^4.4.7",
|
||||
"eventsource-parser": "^0.1.0",
|
||||
"fuse.js": "^6.6.2",
|
||||
@ -23,8 +24,8 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^8.0.5",
|
||||
"rehype-highlight": "^6.0.0",
|
||||
"rehype-katex": "^6.0.2",
|
||||
"rehype-prism-plus": "^1.5.1",
|
||||
"remark-breaks": "^3.0.2",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-math": "^5.1.1",
|
||||
@ -39,7 +40,6 @@
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@types/react-katex": "^3.0.0",
|
||||
"@types/spark-md5": "^3.0.2",
|
||||
"array.prototype.at": "^1.1.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.36.0",
|
||||
"eslint-config-next": "13.2.3",
|
||||
|
@ -11,3 +11,5 @@ self.addEventListener("install", function (event) {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (e) => {});
|
||||
|
39
yarn.lock
39
yarn.lock
@ -2548,6 +2548,13 @@ fastq@^1.6.0:
|
||||
dependencies:
|
||||
reusify "^1.0.4"
|
||||
|
||||
fault@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/fault/-/fault-2.0.1.tgz#d47ca9f37ca26e4bd38374a7c500b5a384755b6c"
|
||||
integrity sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==
|
||||
dependencies:
|
||||
format "^0.2.0"
|
||||
|
||||
fetch-blob@^3.1.2, fetch-blob@^3.1.4:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9"
|
||||
@ -2612,6 +2619,11 @@ form-data@^4.0.0:
|
||||
combined-stream "^1.0.8"
|
||||
mime-types "^2.1.12"
|
||||
|
||||
format@^0.2.0:
|
||||
version "0.2.2"
|
||||
resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b"
|
||||
integrity sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==
|
||||
|
||||
formdata-polyfill@^4.0.10:
|
||||
version "4.0.10"
|
||||
resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423"
|
||||
@ -2874,7 +2886,7 @@ hast-util-to-string@^2.0.0:
|
||||
dependencies:
|
||||
"@types/hast" "^2.0.0"
|
||||
|
||||
hast-util-to-text@^3.1.0:
|
||||
hast-util-to-text@^3.0.0, hast-util-to-text@^3.1.0:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/hast-util-to-text/-/hast-util-to-text-3.1.2.tgz#ecf30c47141f41e91a5d32d0b1e1859fd2ac04f2"
|
||||
integrity sha512-tcllLfp23dJJ+ju5wCCZHVpzsQQ43+moJbqVX3jNWPB7z/KFC4FyZD6R7y94cHL6MQ33YtMZL8Z0aIXXI4XFTw==
|
||||
@ -2900,6 +2912,11 @@ hastscript@^7.0.0:
|
||||
property-information "^6.0.0"
|
||||
space-separated-tokens "^2.0.0"
|
||||
|
||||
highlight.js@~11.7.0:
|
||||
version "11.7.0"
|
||||
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.7.0.tgz#3ff0165bc843f8c9bce1fd89e2fda9143d24b11e"
|
||||
integrity sha512-1rRqesRFhMO/PRF+G86evnyJkCgaZFOI+Z6kdj15TA18funfoqJXvgPCLSf0SWq3SRfg1j3HlDs8o4s3EGq1oQ==
|
||||
|
||||
human-signals@^4.3.0:
|
||||
version "4.3.1"
|
||||
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-4.3.1.tgz#ab7f811e851fca97ffbd2c1fe9a958964de321b2"
|
||||
@ -3385,6 +3402,15 @@ loose-envify@^1.1.0, loose-envify@^1.4.0:
|
||||
dependencies:
|
||||
js-tokens "^3.0.0 || ^4.0.0"
|
||||
|
||||
lowlight@^2.0.0:
|
||||
version "2.8.1"
|
||||
resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-2.8.1.tgz#5f54016ebd1b2f66b3d0b94d10ef6dd5df4f2e42"
|
||||
integrity sha512-HCaGL61RKc1MYzEYn3rFoGkK0yslzCVDFJEanR19rc2L0mb8i58XM55jSRbzp9jcQrFzschPlwooC0vuNitk8Q==
|
||||
dependencies:
|
||||
"@types/hast" "^2.0.0"
|
||||
fault "^2.0.0"
|
||||
highlight.js "~11.7.0"
|
||||
|
||||
lru-cache@^5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
|
||||
@ -4374,6 +4400,17 @@ regjsparser@^0.9.1:
|
||||
dependencies:
|
||||
jsesc "~0.5.0"
|
||||
|
||||
rehype-highlight@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/rehype-highlight/-/rehype-highlight-6.0.0.tgz#8097219d8813b51f4c2b6d92db27dac6cbc9a641"
|
||||
integrity sha512-q7UtlFicLhetp7K48ZgZiJgchYscMma7XjzX7t23bqEJF8m6/s+viXQEe4oHjrATTIZpX7RG8CKD7BlNZoh9gw==
|
||||
dependencies:
|
||||
"@types/hast" "^2.0.0"
|
||||
hast-util-to-text "^3.0.0"
|
||||
lowlight "^2.0.0"
|
||||
unified "^10.0.0"
|
||||
unist-util-visit "^4.0.0"
|
||||
|
||||
rehype-katex@^6.0.2:
|
||||
version "6.0.2"
|
||||
resolved "https://registry.yarnpkg.com/rehype-katex/-/rehype-katex-6.0.2.tgz#20197bbc10bdf79f6b999bffa6689d7f17226c35"
|
||||
|
Loading…
Reference in New Issue
Block a user