Merge remote-tracking branch 'upstream/main' into dev

This commit is contained in:
Jason Wang 2023-04-03 22:14:42 +08:00
commit 1bec280900
35 changed files with 1500 additions and 924 deletions

View File

@ -1,17 +1,21 @@
name: Upstream Sync
permissions:
contents: write
on:
schedule:
- cron: '0 */12 * * *' # every 12 hours
workflow_dispatch: # on button click
- cron: "0 */6 * * *" # every 6 hours
workflow_dispatch:
jobs:
sync_latest_from_upstream:
name: Sync latest commits from upstream repo
runs-on: ubuntu-latest
if: ${{ github.event.repository.fork }}
steps:
# Step 1: run a standard checkout action, provided by github
# Step 1: run a standard checkout action
- name: Checkout target repo
uses: actions/checkout@v3

View File

@ -35,14 +35,17 @@ One-Click to deploy your own ChatGPT web UI.
- Awesome prompts powered by [awesome-chatgpt-prompts-zh](https://github.com/PlexPt/awesome-chatgpt-prompts-zh) and [awesome-chatgpt-prompts](https://github.com/f/awesome-chatgpt-prompts)
- Automatically compresses chat history to support long conversations while also saving your tokens
- One-click export all chat history with full Markdown support
- I18n supported
## 开发计划 Roadmap
- System Prompt: pin a user defined prompt as system prompt 为每个对话设置系统 Prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)
- User Prompt: user can edit and save custom prompts to prompt list 允许用户自行编辑内置 Prompt 列表
- Self-host Model: support llama, alpaca, ChatGLM, BELLE etc. 支持自部署的大语言模型
- Plugins: support network search, caculator, any other apis etc. 插件机制,支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
### 不会开发的功能 Not in Plan
- User login, accounts, cloud sync 用户登录、账号管理、消息云同步
- UI text customize 界面文字自定义
@ -71,7 +74,9 @@ One-Click to deploy your own ChatGPT web UI.
- 前往 vercel 控制台,删除掉原先的 project然后新建 project选择你刚刚 fork 出来的项目重新进行部署即可;
- 在重新部署的过程中,请手动添加名为 `OPENAI_API_KEY` 的环境变量,并填入你的 api key 作为值。
本项目会持续更新,如果你想让代码库总是保持更新,可以查看 [Github 的文档](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) 了解如何让 fork 的项目与上游代码同步,建议定期进行同步操作以获得新功能。
本项目会持续更新,当你 Fork 项目之后,默认会每天自动同步上游代码,无需额外操作。
如果你想让手动立即更新,可以查看 [Github 的文档](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) 了解如何让 fork 的项目与上游代码同步。
你可以 star/watch 本项目或者 follow 作者来及时获得新功能更新通知。
@ -84,7 +89,9 @@ We recommend that you follow the steps below to re-deploy:
- Go to the Vercel dashboard, delete the original project, then create a new project and select the project you just forked to redeploy;
- Please manually add an environment variable named `OPENAI_API_KEY` and enter your API key as the value during the redeploy process.
This project will be continuously maintained. If you want to keep the code repository up to date, you can check out the [Github documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) to learn how to synchronize a forked project with upstream code. It is recommended to perform synchronization operations regularly.
This project will be continuously updated, and after forking the project, the upstream code will be automatically synchronized every day without additional operations.
If you want to update instantly, you can check out the [Github documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) to learn how to synchronize a forked project with upstream code.
You can star or watch this project or follow author to get release notifictions in time.
@ -180,14 +187,7 @@ docker run -d -p 3000:3000 -e OPENAI_API_KEY="" -e CODE="" yidadaa/chatgpt-next-
![更多展示 More](./static/more.png)
## 捐赠 Donate USDT
> BNB Smart Chain (BEP 20)
```
0x67cD02c7EB62641De576a1fA3EdB32eA0c3ffD89
```
## 鸣谢 Special Thanks
### 捐赠者 Sponsor
[@mushan0x0](https://github.com/mushan0x0)

View File

@ -8,6 +8,15 @@ async function createStream(req: NextRequest) {
const res = await requestOpenai(req);
const contentType = res.headers.get("Content-Type") ?? "";
if (!contentType.includes("stream")) {
const content = await (
await res.text()
).replace(/provided:.*. You/, "provided: ***. You");
console.log("[Stream] error ", content);
return "```json\n" + content + "```";
}
const stream = new ReadableStream({
async start(controller) {
function onParse(event: any) {

View File

@ -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;

View File

@ -7,6 +7,8 @@ export function IconButton(props: {
icon: JSX.Element;
text?: string;
bordered?: boolean;
shadow?: boolean;
noDark?: boolean;
className?: string;
title?: string;
}) {
@ -14,12 +16,19 @@ 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>
<div
className={styles["icon-button-icon"] + ` ${props.noDark && "no-dark"}`}
>
{props.icon}
</div>
{props.text && (
<div className={styles["icon-button-text"]}>{props.text}</div>
)}

View 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>
);
}

View File

@ -0,0 +1,75 @@
@import "../styles/animation.scss";
.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;
animation: slide-in-from-top ease 0.3s;
.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;
}
}

643
app/components/chat.tsx Normal file
View File

@ -0,0 +1,643 @@
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, 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 !== "user") {
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: {
showToast?: boolean;
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">
{props.showToast && (
<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)}
bordered
/>
</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), 1);
}
});
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();
const [hitBottom, setHitBottom] = useState(false);
const onChatBodyScroll = (e: HTMLElement) => {
const isTouchBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 20;
setHitBottom(isTouchBottom);
};
// 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([]);
if (!isMobileScreen()) inputRef.current?.focus();
setAutoScroll(true);
};
// 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: true,
},
]
: [],
);
const [showPromptModal, setShowPromptModal] = useState(false);
// Auto focus
useEffect(() => {
if (props.sideBarShowing && isMobileScreen()) return;
inputRef.current?.focus();
}, []);
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.filter((msg) => !msg.isError),
session.topic,
);
}}
/>
</div>
</div>
<PromptToast
showToast={!hitBottom}
showModal={showPromptModal}
setShowModal={setShowPromptModal}
/>
</div>
<div
className={styles["chat-body"]}
ref={scrollRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
onWheel={(e) => setAutoScroll(hitBottom && e.deltaY > 0)}
onTouchStart={() => {
inputRef.current?.blur();
setAutoScroll(false);
}}
>
{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>
<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"]}
noDark
onClick={onUserSubmit}
/>
</div>
</div>
</div>
);
}

47
app/components/error.tsx Normal file
View File

@ -0,0 +1,47 @@
import React from "react";
import { IconButton } from "./button";
import GithubIcon from "../icons/github.svg";
import { ISSUE_URL } from "../constant";
interface IErrorBoundaryState {
hasError: boolean;
error: Error | null;
info: React.ErrorInfo | null;
}
export class ErrorBoundary extends React.Component<any, IErrorBoundaryState> {
constructor(props: any) {
super(props);
this.state = { hasError: false, error: null, info: null };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
// Update state with error details
this.setState({ hasError: true, error, info });
}
render() {
if (this.state.hasError) {
// Render error message
return (
<div className="error">
<h2>Oops, something went wrong!</h2>
<pre>
<code>{this.state.error?.toString()}</code>
<code>{this.state.info?.componentStack}</code>
</pre>
<a href={ISSUE_URL} className="report">
<IconButton
text="Report This Error"
icon={<GithubIcon />}
bordered
/>
</a>
</div>
);
}
// if no error occurred, render children
return this.props.children;
}
}

View File

@ -1,4 +1,5 @@
@import "./window.scss";
@import "../styles/animation.scss";
@mixin container {
background-color: var(--white);
@ -73,7 +74,7 @@
.sidebar {
position: absolute;
left: -100%;
z-index: 999;
z-index: 1000;
height: var(--full-height);
transition: all ease 0.3s;
box-shadow: none;
@ -132,18 +133,6 @@
overflow: hidden;
}
@keyframes slide-in {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0px);
}
}
.chat-item:hover {
background-color: var(--hover-color);
}
@ -218,6 +207,7 @@
flex: 1;
overflow: auto;
padding: 20px;
position: relative;
}
.chat-body-title {
@ -343,6 +333,7 @@
.chat-input-panel {
width: 100%;
padding: 20px;
padding-top: 5px;
box-sizing: border-box;
flex-direction: column;
}

View File

@ -1,7 +1,8 @@
"use client";
import { useState, useRef, useEffect, useLayoutEffect } from "react";
import { useDebouncedCallback } from "use-debounce";
require("../polyfill");
import { useState, useEffect } from "react";
import { IconButton } from "./button";
import styles from "./home.module.scss";
@ -9,33 +10,21 @@ 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 { useChatStore } from "../store";
import { isMobileScreen } from "../utils";
import Locale from "../locales";
import { ChatList } from "./chat-list";
import { Chat } from "./chat";
import dynamic from "next/dynamic";
import { REPO_URL } from "../constant";
import { ControllerPool } from "../requests";
import { Prompt, usePromptStore } from "../store/prompt";
import { ErrorBoundary } from "./error";
export function Loading(props: { noLogo?: boolean }) {
return (
@ -46,470 +35,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);
@ -531,64 +60,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);
@ -599,7 +70,7 @@ const useHasHydrated = () => {
return hasHydrated;
};
export function Home() {
function _Home() {
const [createNewSession, currentIndex, removeSession] = useChatStore(
(state) => [
state.newSession,
@ -668,11 +139,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>
@ -684,6 +156,7 @@ export function Home() {
createNewSession();
setShowSideBar(false);
}}
shadow
/>
</div>
</div>
@ -708,3 +181,11 @@ export function Home() {
</div>
);
}
export function Home() {
return (
<ErrorBoundary>
<_Home></_Home>
</ErrorBoundary>
);
}

View File

@ -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: false,
ignoreMissing: true,
},
],
]}
components={{
pre: PreCode,
}}

View File

@ -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 ? (

View File

@ -1,3 +1,5 @@
@import "../styles/animation.scss";
.card {
background-color: var(--white);
border-radius: 10px;
@ -24,18 +26,6 @@
height: 100vh;
}
@keyframes slide-in {
from {
transform: translateY(10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.list-item {
display: flex;
justify-content: space-between;
@ -138,6 +128,8 @@
justify-content: center;
.toast-content {
max-width: 80vw;
word-break: break-all;
font-size: 14px;
background-color: var(--white);
box-shadow: var(--card-shadow);

View File

@ -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;

View File

@ -1,6 +1,7 @@
export const OWNER = "Yidadaa";
export const REPO = "ChatGPT-Next-Web";
export const REPO_URL = `https://github.com/${OWNER}/${REPO}`;
export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`;
export const UPDATE_URL = `${REPO_URL}#%E4%BF%9D%E6%8C%81%E6%9B%B4%E6%96%B0-keep-updated`;
export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/commits?per_page=1`;
export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;

View File

@ -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";

View File

@ -35,7 +35,7 @@ const cn = {
Download: "下载文件",
},
Memory: {
Title: "上下文记忆 Prompt",
Title: "历史记忆",
EmptyContent: "尚未记忆",
Copy: "全部复制",
},
@ -58,6 +58,7 @@ const cn = {
en: "English",
tw: "繁體中文",
es: "Español",
it: "Italiano",
},
},
Avatar: "头像",
@ -103,8 +104,8 @@ const cn = {
},
Usage: {
Title: "账户余额",
SubTitle(granted: any, used: any) {
return `总共 $${granted}已使用 $${used}`;
SubTitle(used: any) {
return `本月已使用 $${used}`;
},
IsChecking: "正在检查…",
Check: "重新检查",
@ -138,7 +139,7 @@ const cn = {
Topic:
"使用四到五个字直接返回这句话的简要主题,不要解释、不要标点、不要语气词、不要多余文本,如果没有主题,请直接返回“闲聊”",
Summarize:
"简要总结一下你和用户的对话,用作后续的上下文提示 prompt控制在 50 字以内",
"简要总结一下你和用户的对话,用作后续的上下文提示 prompt控制在 200 字以内",
},
ConfirmClearAll: "确认清除所有聊天、设置数据?",
},
@ -146,6 +147,11 @@ const cn = {
Success: "已写入剪切板",
Failed: "复制失败,请赋予剪切板权限",
},
Context: {
Toast: (x: any) => `已设置 ${x} 条前置上下文`,
Edit: "前置上下文和历史记忆",
Add: "新增一条",
},
};
export type LocaleType = typeof cn;

View File

@ -54,12 +54,13 @@ 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",
tw: "繁體中文",
es: "Español",
it: "Italiano",
},
},
Avatar: "Avatar",
@ -105,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",
@ -142,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?",
},
@ -150,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;

View File

@ -60,6 +60,7 @@ const es: LocaleType = {
en: "Inglés",
tw: "繁體中文",
es: "Español",
it: "Italiano",
},
},
Avatar: "Avatar",
@ -78,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",
@ -105,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",
@ -142,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?",
@ -152,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;

View File

@ -2,10 +2,11 @@ import CN from "./cn";
import EN from "./en";
import TW from "./tw";
import ES from "./es";
import IT from "./it";
export type { LocaleType } from "./cn";
export const AllLangs = ["en", "cn", "tw", "es"] as const;
export const AllLangs = ["en", "cn", "tw", "es", "it"] as const;
type Lang = (typeof AllLangs)[number];
const LANG_KEY = "lang";
@ -47,6 +48,8 @@ export function getLang(): Lang {
return "tw";
} else if (lang.includes("es")) {
return "es";
} else if (lang.includes("it")) {
return "it";
} else {
return "en";
}
@ -57,4 +60,4 @@ export function changeLang(lang: Lang) {
location.reload();
}
export default { en: EN, cn: CN, tw: TW, es: ES }[getLang()];
export default { en: EN, cn: CN, tw: TW, es: ES, it: IT }[getLang()];

164
app/locales/it.ts Normal file
View File

@ -0,0 +1,164 @@
import { SubmitKey } from "../store/app";
import type { LocaleType } from "./index";
const it: LocaleType = {
WIP: "Work in progress...",
Error: {
Unauthorized:
"Accesso non autorizzato, inserire il codice di accesso nella pagina delle impostazioni.",
},
ChatItem: {
ChatItemCount: (count: number) => `${count} messaggi`,
},
Chat: {
SubTitle: (count: number) => `${count} messaggi con ChatGPT`,
Actions: {
ChatList: "Vai alla Chat List",
CompressedHistory: "Prompt di memoria della cronologia compressa",
Export: "Esportazione di tutti i messaggi come Markdown",
Copy: "Copia",
Stop: "Stop",
Retry: "Riprova",
},
Rename: "Rinomina Chat",
Typing: "Typing…",
Input: (submitKey: string) => {
var inputHints = `Scrivi qualcosa e premi ${submitKey} per inviare`;
if (submitKey === String(SubmitKey.Enter)) {
inputHints += ", premi Shift + Enter per andare a capo";
}
return inputHints;
},
Send: "Invia",
},
Export: {
Title: "Tutti i messaggi",
Copy: "Copia tutto",
Download: "Scarica",
},
Memory: {
Title: "Prompt di memoria",
EmptyContent: "Vuoto.",
Copy: "Copia tutto",
},
Home: {
NewChat: "Nuova Chat",
DeleteChat: "Confermare la cancellazione della conversazione selezionata?",
},
Settings: {
Title: "Impostazioni",
SubTitle: "Tutte le impostazioni",
Actions: {
ClearAll: "Cancella tutti i dati",
ResetAll: "Resetta tutte le impostazioni",
Close: "Chiudi",
},
Lang: {
Name: "Lingue",
Options: {
cn: "简体中文",
en: "English",
tw: "繁體中文",
es: "Español",
it: "Italiano",
},
},
Avatar: "Avatar",
FontSize: {
Title: "Dimensione carattere",
SubTitle: "Regolare la dimensione dei caratteri del contenuto della chat",
},
Update: {
Version: (x: string) => `Versione: ${x}`,
IsLatest: "Ultima versione",
CheckUpdate: "Controlla aggiornamenti",
IsChecking: "Sto controllando gli aggiornamenti...",
FoundUpdate: (x: string) => `Trovata nuova versione: ${x}`,
GoToUpdate: "Aggiorna",
},
SendKey: "Tasto invia",
Theme: "tema",
TightBorder: "Bordi stretti",
SendPreviewBubble: "Invia l'anteprima della bolla",
Prompt: {
Disable: {
Title: "Disabilita l'auto completamento",
SubTitle: "Input / per attivare il completamento automatico",
},
List: "Elenco dei suggerimenti",
ListCount: (builtin: number, custom: number) =>
`${builtin} built-in, ${custom} user-defined`,
Edit: "Modifica",
},
HistoryCount: {
Title: "Conteggio dei messaggi allegati",
SubTitle: "Numero di messaggi inviati allegati per richiesta",
},
CompressThreshold: {
Title: "Soglia di compressione della cronologia",
SubTitle:
"Comprimerà se la lunghezza dei messaggi non compressi supera il valore",
},
Token: {
Title: "Chiave API",
SubTitle:
"Utilizzare la chiave per ignorare il limite del codice di accesso",
Placeholder: "OpenAI API Key",
},
Usage: {
Title: "Bilancio Account",
SubTitle(used: any) {
return `Usato in questo mese $${used}`;
},
IsChecking: "Controllando...",
Check: "Controlla ancora",
},
AccessCode: {
Title: "Codice d'accesso",
SubTitle: "Controllo d'accesso abilitato",
Placeholder: "Inserisci il codice d'accesso",
},
Model: "Modello GPT",
Temperature: {
Title: "Temperature",
SubTitle: "Un valore maggiore rende l'output più casuale",
},
MaxTokens: {
Title: "Token massimi",
SubTitle: "Lunghezza massima dei token in ingresso e dei token generati",
},
PresencePenlty: {
Title: "Penalità di presenza",
SubTitle:
"Un valore maggiore aumenta la probabilità di parlare di nuovi argomenti",
},
},
Store: {
DefaultTopic: "Nuova conversazione",
BotHello: "Ciao, come posso aiutarti oggi?",
Error: "Qualcosa è andato storto, riprova più tardi.",
Prompt: {
History: (content: string) =>
"Questo è un riassunto della cronologia delle chat tra l'IA e l'utente:" +
content,
Topic:
"Si prega di generare un titolo di quattro o cinque parole che riassuma la nostra conversazione senza alcuna traccia, punteggiatura, virgolette, punti, simboli o testo aggiuntivo. Rimuovere le virgolette",
Summarize:
"Riassumi brevemente la nostra discussione in 200 caratteri o meno per usarla come spunto per una futura conversazione.",
},
ConfirmClearAll:
"Confermi la cancellazione di tutti i dati della chat e delle impostazioni?",
},
Copy: {
Success: "Copiato sugli appunti",
Failed:
"Copia fallita, concedere l'autorizzazione all'accesso agli appunti",
},
Context: {
Toast: (x: any) => `Con ${x} prompts contestuali`,
Edit: "Prompt contestuali e di memoria",
Add: "Aggiungi altro",
},
};
export default it;

View File

@ -59,6 +59,7 @@ const tw: LocaleType = {
en: "English",
tw: "繁體中文",
es: "Español",
it: "Italiano",
},
},
Avatar: "大頭貼",
@ -103,8 +104,8 @@ const tw: LocaleType = {
},
Usage: {
Title: "帳戶餘額",
SubTitle(granted: any, used: any) {
return `總共 $${granted}已使用 $${used}`;
SubTitle(used: any) {
return `本月已使用 $${used}`;
},
IsChecking: "正在檢查…",
Check: "重新檢查",
@ -137,7 +138,7 @@ const tw: LocaleType = {
"這是 AI 與用戶的歷史聊天總結,作為前情提要:" + content,
Topic: "直接返回這句話的簡要主題,無須解釋,若無主題,請直接返回「閒聊」",
Summarize:
"簡要總結一下你和用戶的對話,作為後續的上下文提示 prompt且字數控制在 50 字以內",
"簡要總結一下你和用戶的對話,作為後續的上下文提示 prompt且字數控制在 200 字以內",
},
ConfirmClearAll: "確認清除所有對話、設定數據?",
},
@ -145,6 +146,11 @@ const tw: LocaleType = {
Success: "已複製到剪貼簿中",
Failed: "複製失敗,請賦予剪貼簿權限",
},
Context: {
Toast: (x: any) => `已設置 ${x} 條前置上下文`,
Edit: "前置上下文和歷史記憶",
Add: "新增壹條",
},
};
export default tw;

View File

@ -1,4 +1,5 @@
import { Analytics } from "@vercel/analytics/react";
import { Home } from "./components/home";
export default function App() {

27
app/polyfill.ts Normal file
View File

@ -0,0 +1,27 @@
declare global {
interface Array<T> {
at(index: number): T | undefined;
}
}
if (!Array.prototype.at) {
Array.prototype.at = function (index: number) {
// Get the length of the array
const length = this.length;
// Convert negative index to a positive index
if (index < 0) {
index = length + index;
}
// Return undefined if the index is out of range
if (index < 0 || index >= length) {
return undefined;
}
// Use Array.prototype.slice method to get value at the specified index
return Array.prototype.slice.call(this, index, index + 1)[0];
};
}
export {};

View File

@ -1,10 +1,7 @@
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");
}
import { showToast } from "./components/ui-lib";
const TIME_OUT_MS = 30000;
@ -52,6 +49,7 @@ export function requestOpenaiClient(path: string) {
method,
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-cache",
path,
...getHeaders(),
},
@ -73,17 +71,38 @@ 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;
error?: {
type: string;
message: string;
};
return response;
};
if (response.error && response.error.type) {
showToast(response.error.message);
return;
}
if (response.total_usage) {
response.total_usage = Math.round(response.total_usage) / 100;
}
return response.total_usage;
} catch (error) {
console.error("[Request usage] ", error, res.body);
}
@ -95,7 +114,7 @@ export async function requestChatStream(
filterBot?: boolean;
modelConfig?: ModelConfig;
onMessage: (message: string, done: boolean) => void;
onError: (error: Error) => void;
onError: (error: Error, statusCode?: number) => void;
onController?: (controller: AbortController) => void;
},
) {
@ -159,11 +178,10 @@ export async function requestChatStream(
finish();
} else if (res.status === 401) {
console.error("Anauthorized");
responseText = Locale.Error.Unauthorized;
finish();
options?.onError(new Error("Anauthorized"), res.status);
} else {
console.error("Stream Error", res.body);
options?.onError(new Error("Stream Error"));
options?.onError(new Error("Stream Error"), res.status);
}
} catch (err) {
console.error("NetWork Error", err);

View File

@ -11,13 +11,10 @@ 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;
isError?: boolean;
};
export enum SubmitKey {
@ -57,6 +54,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 = [
@ -104,7 +103,7 @@ export function filterConfig(oldConfig: ModelConfig): Partial<ModelConfig> {
return isValidModel(x as string);
},
max_tokens(x) {
return isValidNumber(x as number, 100, 4000);
return isValidNumber(x as number, 100, 32000);
},
presence_penalty(x) {
return isValidNumber(x as number, -2, 2);
@ -155,6 +154,7 @@ export interface ChatSession {
id: number;
topic: string;
memoryPrompt: string;
context: Message[];
messages: Message[];
stat: ChatStat;
lastUpdate: string;
@ -162,6 +162,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 +175,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,
@ -352,9 +352,15 @@ export const useChatStore = create<ChatStore>()(
set(() => ({}));
}
},
onError(error) {
onError(error, statusCode) {
if (statusCode === 401) {
botMessage.content = Locale.Error.Unauthorized;
} else {
botMessage.content += "\n\n" + Locale.Store.Error;
}
botMessage.streaming = false;
userMessage.isError = true;
botMessage.isError = true;
set(() => ({}));
ControllerPool.remove(sessionIndex, messageIndex);
},
@ -384,17 +390,20 @@ export const useChatStore = create<ChatStore>()(
getMessagesWithMemory() {
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 messages = session.messages.filter((msg) => !msg.isError);
const n = messages.length;
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(
messages.slice(Math.max(0, n - config.historyMessageCount)),
);
return recentMessages;
},
@ -432,11 +441,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 +510,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;
},
},
),
);

23
app/styles/animation.scss Normal file
View File

@ -0,0 +1,23 @@
@keyframes slide-in {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0px);
}
}
@keyframes slide-in-from-top {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0px);
}
}

View File

@ -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;
min-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,30 @@ pre {
}
}
}
.clickable {
cursor: pointer;
div:not(.no-dark) > svg {
filter: invert(0.5);
}
&:hover {
filter: brightness(0.9);
}
}
.error {
width: 80%;
border-radius: 20px;
border: var(--border-in-light);
box-shadow: var(--card-shadow);
padding: 20px;
overflow: auto;
background-color: var(--white);
color: var(--black);
pre {
overflow: auto;
}
}

115
app/styles/highlight.scss Normal file
View File

@ -0,0 +1,115 @@
.markdown-body {
pre {
padding: 0;
}
pre,
code {
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
}
pre code {
display: block;
overflow-x: auto;
padding: 1em;
}
code {
padding: 3px 5px;
}
.hljs,
pre {
background: #1a1b26;
color: #cbd2ea;
}
/*!
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-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: 700;
}
}

View File

@ -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;
}
}

View File

@ -5,15 +5,19 @@ export function trimTopic(topic: string) {
return topic.replace(/[,。!?、,.!?]*$/, "");
}
export function copyToClipboard(text: string) {
navigator.clipboard
.writeText(text)
.then((res) => {
export async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
} catch (error) {
const textarea = document.createElement("textarea");
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
} finally {
showToast(Locale.Copy.Success);
})
.catch((err) => {
showToast(Locale.Copy.Failed);
});
}
}
export function downloadAs(text: string, filename: string) {

View File

@ -23,8 +23,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 +39,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",

View File

@ -11,3 +11,5 @@ self.addEventListener("install", function (event) {
}),
);
});
self.addEventListener("fetch", (e) => {});

146
yarn.lock
View File

@ -1365,11 +1365,6 @@
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==
"@types/prismjs@^1.0.0":
version "1.26.0"
resolved "https://registry.yarnpkg.com/@types/prismjs/-/prismjs-1.26.0.tgz#a1c3809b0ad61c62cac6d4e0c56d610c910b7654"
integrity sha512-ZTaqn/qSqUuAq1YwvOFQfVW1AR/oQJlLSZVustdjwI+GZ8kr0MSHBj0tsXPW1EqHubx50gtBEjbPGsdZwQwCjQ==
"@types/prop-types@*", "@types/prop-types@^15.0.0":
version "15.7.5"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
@ -1570,16 +1565,6 @@ array-union@^2.1.0:
resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d"
integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==
array.prototype.at@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/array.prototype.at/-/array.prototype.at-1.1.1.tgz#6deda3cd3c704afa16361387ea344e0b8d8831b5"
integrity sha512-n/wYNLJy/fVEU9EGPt2ww920hy1XX3XB2yTREFy1QsxctBgQV/tZIwg1G8jVxELna4pLCzg/xvvS/DDXtI4NNg==
dependencies:
call-bind "^1.0.2"
define-properties "^1.1.4"
es-abstract "^1.20.4"
es-shim-unscopables "^1.0.0"
array.prototype.flat@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.1.tgz#ffc6576a7ca3efc2f46a143b9d1dda9b4b3cf5e2"
@ -1769,21 +1754,11 @@ chalk@^4.0.0:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
character-entities-legacy@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz#76bc83a90738901d7bc223a9e93759fdd560125b"
integrity sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==
character-entities@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-2.0.2.tgz#2d09c2e72cd9523076ccb21157dff66ad43fcc22"
integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==
character-reference-invalid@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz#85c66b041e43b47210faf401278abf808ac45cb9"
integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==
"chokidar@>=3.0.0 <4.0.0":
version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
@ -2548,6 +2523,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 +2594,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"
@ -2867,14 +2854,7 @@ hast-util-parse-selector@^3.0.0:
dependencies:
"@types/hast" "^2.0.0"
hast-util-to-string@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/hast-util-to-string/-/hast-util-to-string-2.0.0.tgz#b008b0a4ea472bf34dd390b7eea1018726ae152a"
integrity sha512-02AQ3vLhuH3FisaMM+i/9sm4OXGSq1UhOOCpTLLQtHdL3tZt7qil69r8M8iDkZYyC0HCFylcYoP+8IO7ddta1A==
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 +2880,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"
@ -2965,19 +2950,6 @@ internal-slot@^1.0.3, internal-slot@^1.0.4, internal-slot@^1.0.5:
has "^1.0.3"
side-channel "^1.0.4"
is-alphabetical@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz#01072053ea7c1036df3c7d19a6daaec7f19e789b"
integrity sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==
is-alphanumerical@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz#7c03fbe96e3e931113e57f964b0a368cc2dfd875"
integrity sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==
dependencies:
is-alphabetical "^2.0.0"
is-decimal "^2.0.0"
is-arguments@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b"
@ -3046,11 +3018,6 @@ is-date-object@^1.0.1, is-date-object@^1.0.5:
dependencies:
has-tostringtag "^1.0.0"
is-decimal@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-2.0.1.tgz#9469d2dc190d0214fd87d78b78caecc0cc14eef7"
integrity sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==
is-docker@^2.0.0, is-docker@^2.1.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa"
@ -3078,11 +3045,6 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
dependencies:
is-extglob "^2.1.1"
is-hexadecimal@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz#86b5bf668fca307498d319dfc03289d781a90027"
integrity sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==
is-map@^2.0.1, is-map@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.2.tgz#00922db8c9bf73e81b7a335827bc2a43f2b91127"
@ -3385,6 +3347,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"
@ -4130,20 +4101,6 @@ parent-module@^1.0.0:
dependencies:
callsites "^3.0.0"
parse-entities@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-4.0.1.tgz#4e2a01111fb1c986549b944af39eeda258fc9e4e"
integrity sha512-SWzvYcSJh4d/SGLIOQfZ/CoNv6BTlI6YEQ7Nj82oDVnRpwe/Z/F1EMx42x3JAOwGBlCjeCH0BRJQbQ/opHL17w==
dependencies:
"@types/unist" "^2.0.0"
character-entities "^2.0.0"
character-entities-legacy "^3.0.0"
character-reference-invalid "^2.0.0"
decode-named-character-reference "^1.0.0"
is-alphanumerical "^2.0.0"
is-decimal "^2.0.0"
is-hexadecimal "^2.0.0"
parse-json@^5.0.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd"
@ -4154,11 +4111,6 @@ parse-json@^5.0.0:
json-parse-even-better-errors "^2.3.0"
lines-and-columns "^1.1.6"
parse-numeric-range@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/parse-numeric-range/-/parse-numeric-range-1.3.0.tgz#7c63b61190d61e4d53a1197f0c83c47bb670ffa3"
integrity sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==
parse5@^6.0.0:
version "6.0.1"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
@ -4312,16 +4264,6 @@ readdirp@~3.6.0:
dependencies:
picomatch "^2.2.1"
refractor@^4.7.0:
version "4.8.1"
resolved "https://registry.yarnpkg.com/refractor/-/refractor-4.8.1.tgz#fbdd889333a3d86c9c864479622855c9b38e9d42"
integrity sha512-/fk5sI0iTgFYlmVGYVew90AoYnNMP6pooClx/XKqyeeCQXrL0Kvgn8V0VEht5ccdljbzzF1i3Q213gcntkRExg==
dependencies:
"@types/hast" "^2.0.0"
"@types/prismjs" "^1.0.0"
hastscript "^7.0.0"
parse-entities "^4.0.0"
regenerate-unicode-properties@^10.1.0:
version "10.1.0"
resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz#7c3192cab6dd24e21cb4461e5ddd7dd24fa8374c"
@ -4374,6 +4316,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"
@ -4388,7 +4341,7 @@ rehype-katex@^6.0.2:
unist-util-remove-position "^4.0.0"
unist-util-visit "^4.0.0"
rehype-parse@^8.0.0, rehype-parse@^8.0.2:
rehype-parse@^8.0.0:
version "8.0.4"
resolved "https://registry.yarnpkg.com/rehype-parse/-/rehype-parse-8.0.4.tgz#3d17c9ff16ddfef6bbcc8e6a25a99467b482d688"
integrity sha512-MJJKONunHjoTh4kc3dsM1v3C9kGrrxvA3U8PxZlP2SjH8RNUSrb+lF7Y0KVaUDnGH2QZ5vAn7ulkiajM9ifuqg==
@ -4398,18 +4351,6 @@ rehype-parse@^8.0.0, rehype-parse@^8.0.2:
parse5 "^6.0.0"
unified "^10.0.0"
rehype-prism-plus@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/rehype-prism-plus/-/rehype-prism-plus-1.5.1.tgz#b5f4eb3c789a13ffe874c81039665e144bcb1cae"
integrity sha512-mowYefSfrIkMMxkb0fwuEXlvc5nA9b1vQ6mzujM81Qx28RI0mo7jCHsBZ2tJ4eIJKXdFn+EdPkZZBGB10K02vg==
dependencies:
hast-util-to-string "^2.0.0"
parse-numeric-range "^1.3.0"
refractor "^4.7.0"
rehype-parse "^8.0.2"
unist-util-filter "^4.0.0"
unist-util-visit "^4.0.0"
remark-breaks@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/remark-breaks/-/remark-breaks-3.0.2.tgz#f466b9d3474d7323146c0149fc1496dabadd908e"
@ -4959,15 +4900,6 @@ unified@^10.0.0:
trough "^2.0.0"
vfile "^5.0.0"
unist-util-filter@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/unist-util-filter/-/unist-util-filter-4.0.1.tgz#fd885dd48adaad345de5f5dc706ec4ff44a8d074"
integrity sha512-RynicUM/vbOSTSiUK+BnaK9XMfmQUh6gyi7L6taNgc7FIf84GukXVV3ucGzEN/PhUUkdP5hb1MmXc+3cvPUm5Q==
dependencies:
"@types/unist" "^2.0.0"
unist-util-is "^5.0.0"
unist-util-visit-parents "^5.0.0"
unist-util-find-after@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/unist-util-find-after/-/unist-util-find-after-4.0.1.tgz#80c69c92b0504033638ce11973f4135f2c822e2d"