Merge branch 'Yidadaa:main' into main

This commit is contained in:
Rentoo 2023-03-29 21:55:29 +08:00 committed by GitHub
commit 28e10012da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 8322 additions and 4850 deletions

View File

@ -1,3 +1,4 @@
{ {
"extends": "next/core-web-vitals" "extends": "next/core-web-vitals",
"plugins": ["prettier"]
} }

4
.gitignore vendored
View File

@ -34,4 +34,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
dev dev
public/prompts.json

4
.husky/pre-commit Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged

6
.lintstagedrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"./app/**/*.{js,ts,jsx,tsx,json,html,css,scss,md}": [
"eslint --fix",
"prettier --write"
]
}

10
.prettierrc.js Normal file
View File

@ -0,0 +1,10 @@
module.exports = {
printWidth: 80,
tabWidth: 2,
useTabs: false,
semi: true,
singleQuote: false,
trailingComma: 'all',
bracketSpacing: true,
arrowParens: 'always',
};

View File

@ -22,6 +22,7 @@ One-Click to deploy your own ChatGPT web UI.
- 在 1 分钟内使用 Vercel **免费一键部署** - 在 1 分钟内使用 Vercel **免费一键部署**
- 精心设计的 UI响应式设计支持深色模式 - 精心设计的 UI响应式设计支持深色模式
- 极快的首屏加载速度(~85kb - 极快的首屏加载速度(~85kb
- 海量的内置 prompt 列表,来自[中文](https://github.com/PlexPt/awesome-chatgpt-prompts-zh)和[英文](https://github.com/f/awesome-chatgpt-prompts)
- 自动压缩上下文聊天记录,在节省 Token 的同时支持超长对话 - 自动压缩上下文聊天记录,在节省 Token 的同时支持超长对话
- 一键导出聊天记录,完整的 Markdown 支持 - 一键导出聊天记录,完整的 Markdown 支持
- 拥有自己的域名?好上加好,绑定后即可在任何地方**无障碍**快速访问 - 拥有自己的域名?好上加好,绑定后即可在任何地方**无障碍**快速访问
@ -31,6 +32,7 @@ One-Click to deploy your own ChatGPT web UI.
- **Deploy for free with one-click** on Vercel in under 1 minute - **Deploy for free with one-click** on Vercel in under 1 minute
- Responsive design, and dark mode - Responsive design, and dark mode
- Fast first screen loading speed (~85kb) - Fast first screen loading speed (~85kb)
- 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 - Automatically compresses chat history to support long conversations while also saving your tokens
- One-click export all chat history with full Markdown support - One-click export all chat history with full Markdown support
@ -152,13 +154,10 @@ If you would like to contribute your API key, you can email it to the author and
[@mushan0x0](https://github.com/mushan0x0) [@mushan0x0](https://github.com/mushan0x0)
[@ClarenceDan](https://github.com/ClarenceDan) [@ClarenceDan](https://github.com/ClarenceDan)
[@zhangjia](https://github.com/zhangjia) [@zhangjia](https://github.com/zhangjia)
[@hoochanlon](https://github.com/hoochanlon)
### 贡献者 Contributor ### 贡献者 Contributor
[Contributors](https://github.com/Yidadaa/ChatGPT-Next-Web/graphs/contributors)
[@AprilNEA](https://github.com/AprilNEA)
[@iSource](https://github.com/iSource)
[@iFwu](https://github.com/iFwu)
[@xiaotianxt](https://github.com/xiaotianxt)
## LICENSE ## LICENSE

View File

@ -292,6 +292,7 @@
position: absolute; position: absolute;
right: 20px; right: 20px;
top: -26px; top: -26px;
left: 100px;
transition: all ease 0.3s; transition: all ease 0.3s;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
@ -302,6 +303,7 @@
.chat-message-top-action { .chat-message-top-action {
opacity: 0.5; opacity: 0.5;
color: var(--black); color: var(--black);
white-space: nowrap;
cursor: pointer; cursor: pointer;
&:hover { &:hover {
@ -333,11 +335,65 @@
.chat-input-panel { .chat-input-panel {
position: absolute; position: absolute;
bottom: 20px; bottom: 0px;
display: flex; display: flex;
width: 100%; width: 100%;
padding: 20px; padding: 20px;
box-sizing: border-box; box-sizing: border-box;
flex-direction: column;
}
@mixin single-line {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.prompt-hints {
min-height: 20px;
width: 100%;
max-height: 50vh;
overflow: auto;
display: flex;
flex-direction: column-reverse;
background-color: var(--white);
border: var(--border-in-light);
border-radius: 10px;
margin-bottom: 10px;
box-shadow: var(--shadow);
.prompt-hint {
color: var(--black);
padding: 6px 10px;
animation: slide-in ease 0.3s;
cursor: pointer;
transition: all ease 0.3s;
border: transparent 1px solid;
margin: 4px;
border-radius: 8px;
&:not(:last-child) {
margin-top: 0;
}
.hint-title {
font-size: 12px;
font-weight: bolder;
@include single-line();
}
.hint-content {
font-size: 12px;
@include single-line();
}
&-selected,
&:hover {
border-color: var(--primary);
}
}
} }
.chat-input-panel-inner { .chat-input-panel-inner {
@ -375,7 +431,7 @@
position: absolute; position: absolute;
right: 30px; right: 30px;
bottom: 10px; bottom: 30px;
} }
.export-content { .export-content {

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useRef, useEffect, useLayoutEffect } from "react"; import { useState, useRef, useEffect, useLayoutEffect } from "react";
import { useDebouncedCallback } from "use-debounce";
import { IconButton } from "./button"; import { IconButton } from "./button";
import styles from "./home.module.scss"; import styles from "./home.module.scss";
@ -28,6 +29,7 @@ import Locale from "../locales";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { REPO_URL } from "../constant"; import { REPO_URL } from "../constant";
import { ControllerPool } from "../requests"; import { ControllerPool } from "../requests";
import { Prompt, usePromptStore } from "../store/prompt";
export function Loading(props: { noLogo?: boolean }) { export function Loading(props: { noLogo?: boolean }) {
return ( return (
@ -126,7 +128,7 @@ function useSubmitHandler() {
const shouldSubmit = (e: KeyboardEvent) => { const shouldSubmit = (e: KeyboardEvent) => {
if (e.key !== "Enter") return false; if (e.key !== "Enter") return false;
return ( return (
(config.submitKey === SubmitKey.AltEnter && e.altKey) || (config.submitKey === SubmitKey.AltEnter && e.altKey) ||
(config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) || (config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) ||
@ -146,24 +148,78 @@ function useSubmitHandler() {
}; };
} }
export function Chat(props: { showSideBar?: () => void }) { 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 }; type RenderMessage = Message & { preview?: boolean };
const chatStore = useChatStore();
const [session, sessionIndex] = useChatStore((state) => [ const [session, sessionIndex] = useChatStore((state) => [
state.currentSession(), state.currentSession(),
state.currentSessionIndex, state.currentSessionIndex,
]); ]);
const fontSize = useChatStore((state) => state.config.fontSize);
const inputRef = useRef<HTMLTextAreaElement>(null);
const [userInput, setUserInput] = useState(""); const [userInput, setUserInput] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { submitKey, shouldSubmit } = useSubmitHandler(); const { submitKey, shouldSubmit } = useSubmitHandler();
const onUserInput = useChatStore((state) => state.onUserInput); // prompt hints
const promptStore = usePromptStore();
const [promptHints, setPromptHints] = useState<Prompt[]>([]);
const onSearch = useDebouncedCallback(
(text: string) => {
if (chatStore.config.disablePromptHint) return;
setPromptHints(promptStore.search(text));
},
100,
{ leading: true, trailing: true }
);
const onPromptSelect = (prompt: Prompt) => {
setUserInput(prompt.content);
setPromptHints([]);
inputRef.current?.focus();
};
// only search prompts when user input is short
const SEARCH_TEXT_LIMIT = 30;
const onInput = (text: string) => {
setUserInput(text);
const n = text.trim().length;
if (n === 0 || n > SEARCH_TEXT_LIMIT) {
setPromptHints([]);
} else {
onSearch(text);
}
};
// submit user input // submit user input
const onUserSubmit = () => { const onUserSubmit = () => {
if (userInput.length <= 0) return; if (userInput.length <= 0) return;
setIsLoading(true); setIsLoading(true);
onUserInput(userInput).then(() => setIsLoading(false)); chatStore.onUserInput(userInput).then(() => setIsLoading(false));
setUserInput(""); setUserInput("");
inputRef.current?.focus(); inputRef.current?.focus();
}; };
@ -198,7 +254,9 @@ export function Chat(props: { showSideBar?: () => void }) {
for (let i = botIndex; i >= 0; i -= 1) { for (let i = botIndex; i >= 0; i -= 1) {
if (messages[i].role === "user") { if (messages[i].role === "user") {
setIsLoading(true); setIsLoading(true);
onUserInput(messages[i].content).then(() => setIsLoading(false)); chatStore
.onUserInput(messages[i].content)
.then(() => setIsLoading(false));
return; return;
} }
} }
@ -206,7 +264,6 @@ export function Chat(props: { showSideBar?: () => void }) {
// for auto-scroll // for auto-scroll
const latestMessageRef = useRef<HTMLDivElement>(null); const latestMessageRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// wont scroll while hovering messages // wont scroll while hovering messages
const [autoScroll, setAutoScroll] = useState(false); const [autoScroll, setAutoScroll] = useState(false);
@ -318,7 +375,7 @@ export function Chat(props: { showSideBar?: () => void }) {
</div> </div>
)} )}
<div className={styles["chat-message-item"]}> <div className={styles["chat-message-item"]}>
{!isUser && ( {(!isUser && !(message.preview || message.content.length === 0)) && (
<div className={styles["chat-message-top-actions"]}> <div className={styles["chat-message-top-actions"]}>
{message.streaming ? ( {message.streaming ? (
<div <div
@ -350,6 +407,7 @@ export function Chat(props: { showSideBar?: () => void }) {
) : ( ) : (
<div <div
className="markdown-body" className="markdown-body"
style={{ fontSize: `${fontSize}px` }}
onContextMenu={(e) => onRightClick(e, message)} onContextMenu={(e) => onRightClick(e, message)}
> >
<Markdown content={message.content} /> <Markdown content={message.content} />
@ -373,18 +431,22 @@ export function Chat(props: { showSideBar?: () => void }) {
</div> </div>
<div className={styles["chat-input-panel"]}> <div className={styles["chat-input-panel"]}>
<PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
<div className={styles["chat-input-panel-inner"]}> <div className={styles["chat-input-panel-inner"]}>
<textarea <textarea
ref={inputRef} ref={inputRef}
className={styles["chat-input"]} className={styles["chat-input"]}
placeholder={Locale.Chat.Input(submitKey)} placeholder={Locale.Chat.Input(submitKey)}
rows={3} rows={4}
onInput={(e) => setUserInput(e.currentTarget.value)} onInput={(e) => onInput(e.currentTarget.value)}
value={userInput} value={userInput}
onKeyDown={(e) => onInputKeyDown(e as any)} onKeyDown={(e) => onInputKeyDown(e as any)}
onFocus={() => setAutoScroll(true)} onFocus={() => setAutoScroll(true)}
onBlur={() => setAutoScroll(false)} onBlur={() => {
autoFocus setAutoScroll(false);
setTimeout(() => setPromptHints([]), 100);
}}
autoFocus={!props?.sideBarShowing}
/> />
<IconButton <IconButton
icon={<SendWhiteIcon />} icon={<SendWhiteIcon />}
@ -411,9 +473,11 @@ function useSwitchTheme() {
document.body.classList.add("light"); document.body.classList.add("light");
} }
const themeColor = getComputedStyle(document.body).getPropertyValue("--theme-color").trim(); const themeColor = getComputedStyle(document.body)
.getPropertyValue("--theme-color")
.trim();
const metaDescription = document.querySelector('meta[name="theme-color"]'); const metaDescription = document.querySelector('meta[name="theme-color"]');
metaDescription?.setAttribute('content', themeColor); metaDescription?.setAttribute("content", themeColor);
}, [config.theme]); }, [config.theme]);
} }
@ -566,7 +630,7 @@ export function Home() {
<IconButton <IconButton
icon={<AddIcon />} icon={<AddIcon />}
text={Locale.Home.NewChat} text={Locale.Home.NewChat}
onClick={()=>{ onClick={() => {
createNewSession(); createNewSession();
setShowSideBar(false); setShowSideBar(false);
}} }}
@ -584,7 +648,7 @@ export function Home() {
}} }}
/> />
) : ( ) : (
<Chat key="chat" showSideBar={() => setShowSideBar(true)} /> <Chat key="chat" showSideBar={() => setShowSideBar(true)} sideBarShowing={showSideBar} />
)} )}
</div> </div>
</div> </div>

View File

@ -34,6 +34,7 @@ export function Markdown(props: { content: string }) {
components={{ components={{
pre: PreCode, pre: PreCode,
}} }}
className="line-break"
> >
{props.content} {props.content}
</ReactMarkdown> </ReactMarkdown>

View File

@ -7,8 +7,9 @@ import styles from "./settings.module.scss";
import ResetIcon from "../icons/reload.svg"; import ResetIcon from "../icons/reload.svg";
import CloseIcon from "../icons/close.svg"; import CloseIcon from "../icons/close.svg";
import ClearIcon from "../icons/clear.svg"; import ClearIcon from "../icons/clear.svg";
import EditIcon from "../icons/edit.svg";
import { List, ListItem, Popover } from "./ui-lib"; import { List, ListItem, Popover, showToast } from "./ui-lib";
import { IconButton } from "./button"; import { IconButton } from "./button";
import { import {
@ -19,12 +20,13 @@ import {
useUpdateStore, useUpdateStore,
useAccessStore, useAccessStore,
} from "../store"; } from "../store";
import { Avatar } from "./home"; import { Avatar, PromptHints } from "./home";
import Locale, { changeLang, getLang } from "../locales"; import Locale, { AllLangs, changeLang, getLang } from "../locales";
import { getCurrentCommitId } from "../utils"; import { getCurrentCommitId } from "../utils";
import Link from "next/link"; import Link from "next/link";
import { UPDATE_URL } from "../constant"; import { UPDATE_URL } from "../constant";
import { SearchService, usePromptStore } from "../store/prompt";
function SettingItem(props: { function SettingItem(props: {
title: string; title: string;
@ -78,6 +80,10 @@ export function Settings(props: { closeSettings: () => void }) {
[] []
); );
const promptStore = usePromptStore();
const builtinCount = SearchService.count.builtin;
const customCount = promptStore.prompts.size ?? 0;
return ( return (
<> <>
<div className={styles["window-header"]}> <div className={styles["window-header"]}>
@ -206,22 +212,38 @@ export function Settings(props: { closeSettings: () => void }) {
</ListItem> </ListItem>
<SettingItem title={Locale.Settings.Lang.Name}> <SettingItem title={Locale.Settings.Lang.Name}>
<div className=""> <select
<select value={getLang()}
value={getLang()} onChange={(e) => {
onChange={(e) => { changeLang(e.target.value as any);
changeLang(e.target.value as any); }}
}} >
> {AllLangs.map((lang) => (
<option value="en" key="en"> <option value={lang} key={lang}>
{Locale.Settings.Lang.Options.en} {Locale.Settings.Lang.Options[lang]}
</option> </option>
))}
</select>
</SettingItem>
<option value="cn" key="cn"> <SettingItem
{Locale.Settings.Lang.Options.cn} title={Locale.Settings.FontSize.Title}
</option> subTitle={Locale.Settings.FontSize.SubTitle}
</select> >
</div> <input
type="range"
title={`${config.fontSize ?? 14}px`}
value={config.fontSize}
min="12"
max="18"
step="1"
onChange={(e) =>
updateConfig(
(config) =>
(config.fontSize = Number.parseInt(e.currentTarget.value))
)
}
></input>
</SettingItem> </SettingItem>
<div className="no-mobile"> <div className="no-mobile">
@ -238,6 +260,37 @@ export function Settings(props: { closeSettings: () => void }) {
</SettingItem> </SettingItem>
</div> </div>
</List> </List>
<List>
<SettingItem
title={Locale.Settings.Prompt.Disable.Title}
subTitle={Locale.Settings.Prompt.Disable.SubTitle}
>
<input
type="checkbox"
checked={config.disablePromptHint}
onChange={(e) =>
updateConfig(
(config) =>
(config.disablePromptHint = e.currentTarget.checked)
)
}
></input>
</SettingItem>
<SettingItem
title={Locale.Settings.Prompt.List}
subTitle={Locale.Settings.Prompt.ListCount(
builtinCount,
customCount
)}
>
<IconButton
icon={<EditIcon />}
text={Locale.Settings.Prompt.Edit}
onClick={() => showToast(Locale.WIP)}
/>
</SettingItem>
</List>
<List> <List>
{enabledAccessControl ? ( {enabledAccessControl ? (
<SettingItem <SettingItem
@ -336,7 +389,7 @@ export function Settings(props: { closeSettings: () => void }) {
type="range" type="range"
value={config.modelConfig.temperature.toFixed(1)} value={config.modelConfig.temperature.toFixed(1)}
min="0" min="0"
max="1" max="2"
step="0.1" step="0.1"
onChange={(e) => { onChange={(e) => {
updateConfig( updateConfig(

View File

@ -36,7 +36,7 @@ export function ListItem(props: { children: JSX.Element[] }) {
return <div className={styles["list-item"]}>{props.children}</div>; return <div className={styles["list-item"]}>{props.children}</div>;
} }
export function List(props: { children: JSX.Element[] }) { export function List(props: { children: JSX.Element[] | JSX.Element }) {
return <div className={styles.list}>{props.children}</div>; return <div className={styles.list}>{props.children}</div>;
} }

1
app/icons/edit.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(10.5 11) rotate(0 1.4166666666666665 1.8333333333333333)" d="M2.83,0L2.83,3C2.83,3.37 2.53,3.67 2.17,3.67L0,3.67 " /><path id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(2.6666666666666665 1.3333333333333333) rotate(0 5.333333333333333 6.666666666666666)" d="M10.67,4L10.67,0.67C10.67,0.3 10.37,0 10,0L0.67,0C0.3,0 0,0.3 0,0.67L0,12.67C0,13.03 0.3,13.33 0.67,13.33L2.67,13.33 " /><path id="路径 3" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.333333333333333 5.333333333333333) rotate(0 2.333333333333333 0)" d="M0,0L4.67,0 " /><path id="路径 4" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(7.666666666666666 7.666666666666666) rotate(0 2.833333333333333 3.5)" d="M0,7L5.67,0 " /><path id="路径 5" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.333333333333333 8) rotate(0 1.3333333333333333 0)" d="M0,0L2.67,0 " /></g></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -1,3 +1,5 @@
import { SubmitKey } from "../store/app";
const cn = { const cn = {
WIP: "该功能仍在开发中……", WIP: "该功能仍在开发中……",
Error: { Error: {
@ -17,7 +19,13 @@ const cn = {
Retry: "重试", Retry: "重试",
}, },
Typing: "正在输入…", Typing: "正在输入…",
Input: (submitKey: string) => `输入消息,${submitKey} 发送`, Input: (submitKey: string) => {
var inputHints = `输入消息,${submitKey} 发送`;
if (submitKey === String(SubmitKey.Enter)) {
inputHints += "Shift + Enter 换行";
}
return inputHints;
},
Send: "发送", Send: "发送",
}, },
Export: { Export: {
@ -45,11 +53,16 @@ const cn = {
Lang: { Lang: {
Name: "Language", Name: "Language",
Options: { Options: {
cn: "中文", cn: "简体中文",
en: "English", en: "English",
tw: "繁體中文",
}, },
}, },
Avatar: "头像", Avatar: "头像",
FontSize: {
Title: "字体大小",
SubTitle: "聊天内容的字体大小",
},
Update: { Update: {
Version: (x: string) => `当前版本:${x}`, Version: (x: string) => `当前版本:${x}`,
IsLatest: "已是最新版本", IsLatest: "已是最新版本",
@ -61,6 +74,16 @@ const cn = {
SendKey: "发送键", SendKey: "发送键",
Theme: "主题", Theme: "主题",
TightBorder: "紧凑边框", TightBorder: "紧凑边框",
Prompt: {
Disable: {
Title: "禁用提示词自动补全",
SubTitle: "禁用后将无法自动根据输入补全",
},
List: "自定义提示词列表",
ListCount: (builtin: number, custom: number) =>
`内置 ${builtin} 条,用户定义 ${custom}`,
Edit: "编辑",
},
HistoryCount: { HistoryCount: {
Title: "附带历史消息数", Title: "附带历史消息数",
SubTitle: "每次请求携带的历史消息数", SubTitle: "每次请求携带的历史消息数",

View File

@ -1,3 +1,4 @@
import { SubmitKey } from "../store/app";
import type { LocaleType } from "./index"; import type { LocaleType } from "./index";
const en: LocaleType = { const en: LocaleType = {
@ -20,8 +21,13 @@ const en: LocaleType = {
Retry: "Retry", Retry: "Retry",
}, },
Typing: "Typing…", Typing: "Typing…",
Input: (submitKey: string) => Input: (submitKey: string) => {
`Type something and press ${submitKey} to send`, var inputHints = `Type something and press ${submitKey} to send`;
if (submitKey === String(SubmitKey.Enter)) {
inputHints += ", press Shift + Enter to newline";
}
return inputHints;
},
Send: "Send", Send: "Send",
}, },
Export: { Export: {
@ -49,11 +55,16 @@ const en: LocaleType = {
Lang: { Lang: {
Name: "语言", Name: "语言",
Options: { Options: {
cn: "中文", cn: "简体中文",
en: "English", en: "English",
tw: "繁體中文",
}, },
}, },
Avatar: "Avatar", Avatar: "Avatar",
FontSize: {
Title: "Font Size",
SubTitle: "Adjust font size of chat content",
},
Update: { Update: {
Version: (x: string) => `Version: ${x}`, Version: (x: string) => `Version: ${x}`,
IsLatest: "Latest version", IsLatest: "Latest version",
@ -65,6 +76,16 @@ const en: LocaleType = {
SendKey: "Send Key", SendKey: "Send Key",
Theme: "Theme", Theme: "Theme",
TightBorder: "Tight Border", TightBorder: "Tight Border",
Prompt: {
Disable: {
Title: "Disable auto-completion",
SubTitle: "After disabling, auto-completion will not be available",
},
List: "Prompt List",
ListCount: (builtin: number, custom: number) =>
`${builtin} built-in, ${custom} user-defined`,
Edit: "Edit",
},
HistoryCount: { HistoryCount: {
Title: "Attached Messages Count", Title: "Attached Messages Count",
SubTitle: "Number of sent messages attached per request", SubTitle: "Number of sent messages attached per request",

View File

@ -1,53 +1,57 @@
import CN from './cn' import CN from "./cn";
import EN from './en' import EN from "./en";
import TW from "./tw";
export type { LocaleType } from './cn' export type { LocaleType } from "./cn";
type Lang = 'en' | 'cn' export const AllLangs = ["en", "cn", "tw"] as const;
type Lang = (typeof AllLangs)[number];
const LANG_KEY = 'lang' const LANG_KEY = "lang";
function getItem(key: string) { function getItem(key: string) {
try { try {
return localStorage.getItem(key) return localStorage.getItem(key);
} catch { } catch {
return null return null;
} }
} }
function setItem(key: string, value: string) { function setItem(key: string, value: string) {
try { try {
localStorage.setItem(key, value) localStorage.setItem(key, value);
} catch { } } catch {}
} }
function getLanguage() { function getLanguage() {
try { try {
return navigator.language.toLowerCase() return navigator.language.toLowerCase();
} catch { } catch {
return 'cn' return "cn";
} }
} }
export function getLang(): Lang { export function getLang(): Lang {
const savedLang = getItem(LANG_KEY) const savedLang = getItem(LANG_KEY);
if (['en', 'cn'].includes(savedLang ?? '')) { if (AllLangs.includes((savedLang ?? "") as Lang)) {
return savedLang as Lang return savedLang as Lang;
} }
const lang = getLanguage() const lang = getLanguage();
if (lang.includes('zh') || lang.includes('cn')) { if (lang.includes("zh") || lang.includes("cn")) {
return 'cn' return "cn";
} else { } else if (lang.includes("tw")) {
return 'en' return "tw";
} } else {
return "en";
}
} }
export function changeLang(lang: Lang) { export function changeLang(lang: Lang) {
setItem(LANG_KEY, lang) setItem(LANG_KEY, lang);
location.reload() location.reload();
} }
export default { en: EN, cn: CN }[getLang()] export default { en: EN, cn: CN, tw: TW }[getLang()];

140
app/locales/tw.ts Normal file
View File

@ -0,0 +1,140 @@
import { SubmitKey } from "../store/app";
import type { LocaleType } from "./index";
const tw: LocaleType = {
WIP: "該功能仍在開發中……",
Error: {
Unauthorized: "目前您的狀態是未授權,請前往設定頁面填寫授權碼。",
},
ChatItem: {
ChatItemCount: (count: number) => `${count} 條對話`,
},
Chat: {
SubTitle: (count: number) => `您已經與 ChatGPT 進行了 ${count} 條對話`,
Actions: {
ChatList: "查看消息列表",
CompressedHistory: "查看壓縮後的歷史 Prompt",
Export: "匯出聊天紀錄",
Copy: "複製",
Stop: "停止",
Retry: "重試",
},
Typing: "正在輸入…",
Input: (submitKey: string) => {
var inputHints = `輸入訊息後,按下 ${submitKey} 鍵即可發送`;
if (submitKey === String(SubmitKey.Enter)) {
inputHints += "Shift + Enter 鍵換行";
}
return inputHints;
},
Send: "發送",
},
Export: {
Title: "匯出聊天記錄為 Markdown",
Copy: "複製全部",
Download: "下載檔案",
},
Memory: {
Title: "上下文記憶 Prompt",
EmptyContent: "尚未記憶",
Copy: "複製全部",
},
Home: {
NewChat: "新的對話",
DeleteChat: "確定要刪除選取的對話嗎?",
},
Settings: {
Title: "設定",
SubTitle: "設定選項",
Actions: {
ClearAll: "清除所有數據",
ResetAll: "重置所有設定",
Close: "關閉",
},
Lang: {
Name: "語言",
Options: {
cn: "简体中文",
en: "English",
tw: "繁體中文",
},
},
Avatar: "大頭貼",
FontSize: {
Title: "字型大小",
SubTitle: "聊天內容的字型大小",
},
Update: {
Version: (x: string) => `當前版本:${x}`,
IsLatest: "已是最新版本",
CheckUpdate: "檢查更新",
IsChecking: "正在檢查更新...",
FoundUpdate: (x: string) => `發現新版本:${x}`,
GoToUpdate: "前往更新",
},
SendKey: "發送鍵",
Theme: "主題",
TightBorder: "緊湊邊框",
Prompt: {
Disable: {
Title: "停用提示詞自動補全",
SubTitle: "若停用後,將無法自動根據輸入進行補全",
},
List: "自定義提示詞列表",
ListCount: (builtin: number, custom: number) =>
`內置 ${builtin} 條,用戶定義 ${custom}`,
Edit: "編輯",
},
HistoryCount: {
Title: "附帶歷史訊息數",
SubTitle: "每次請求附帶的歷史訊息數",
},
CompressThreshold: {
Title: "歷史訊息長度壓縮閾值",
SubTitle: "當未壓縮的歷史訊息超過該值時,將進行壓縮",
},
Token: {
Title: "API Key",
SubTitle: "使用自己的 Key 可規避受控訪問限制",
Placeholder: "OpenAI API Key",
},
AccessCode: {
Title: "訪問碼",
SubTitle: "現在是受控訪問狀態",
Placeholder: "請輸入訪問碼",
},
Model: "模型 (model)",
Temperature: {
Title: "隨機性 (temperature)",
SubTitle: "值越大,回復越隨機",
},
MaxTokens: {
Title: "單次回復限制 (max_tokens)",
SubTitle: "單次交互所用的最大 Token 數",
},
PresencePenlty: {
Title: "話題新穎度 (presence_penalty)",
SubTitle: "值越大,越有可能擴展到新話題",
},
},
Store: {
DefaultTopic: "新的對話",
BotHello: "請問需要我的協助嗎?",
Error: "出錯了,請稍後再嘗試",
Prompt: {
History: (content: string) =>
"這是 AI 與用戶的歷史聊天總結,作為前情提要:" + content,
Topic:
"直接返回這句話的簡要主題,無須解釋,若無主題,請直接返回「閒聊」",
Summarize:
"簡要總結一下你和用戶的對話,作為後續的上下文提示 prompt且字數控制在 50 字以內",
},
ConfirmClearAll: "確認清除所有對話、設定數據?",
},
Copy: {
Success: "已複製到剪貼簿中",
Failed: "複製失敗,請賦予剪貼簿權限",
},
};
export default tw;

View File

@ -31,15 +31,17 @@ export enum Theme {
} }
export interface ChatConfig { export interface ChatConfig {
maxToken?: number;
historyMessageCount: number; // -1 means all historyMessageCount: number; // -1 means all
compressMessageLengthThreshold: number; compressMessageLengthThreshold: number;
sendBotMessages: boolean; // send bot's message or not sendBotMessages: boolean; // send bot's message or not
submitKey: SubmitKey; submitKey: SubmitKey;
avatar: string; avatar: string;
fontSize: number;
theme: Theme; theme: Theme;
tightBorder: boolean; tightBorder: boolean;
disablePromptHint: boolean;
modelConfig: { modelConfig: {
model: string; model: string;
temperature: number; temperature: number;
@ -121,9 +123,12 @@ const DEFAULT_CONFIG: ChatConfig = {
sendBotMessages: true as boolean, sendBotMessages: true as boolean,
submitKey: SubmitKey.CtrlEnter as SubmitKey, submitKey: SubmitKey.CtrlEnter as SubmitKey,
avatar: "1f603", avatar: "1f603",
fontSize: 14,
theme: Theme.Auto as Theme, theme: Theme.Auto as Theme,
tightBorder: false, tightBorder: false,
disablePromptHint: false,
modelConfig: { modelConfig: {
model: "gpt-3.5-turbo", model: "gpt-3.5-turbo",
temperature: 1, temperature: 1,
@ -190,7 +195,7 @@ interface ChatStore {
updateMessage: ( updateMessage: (
sessionIndex: number, sessionIndex: number,
messageIndex: number, messageIndex: number,
updater: (message?: Message) => void updater: (message?: Message) => void,
) => void; ) => void;
getMessagesWithMemory: () => Message[]; getMessagesWithMemory: () => Message[];
getMemoryPrompt: () => Message; getMemoryPrompt: () => Message;
@ -338,7 +343,7 @@ export const useChatStore = create<ChatStore>()(
ControllerPool.addController( ControllerPool.addController(
sessionIndex, sessionIndex,
messageIndex, messageIndex,
controller controller,
); );
}, },
filterBot: !get().config.sendBotMessages, filterBot: !get().config.sendBotMessages,
@ -361,7 +366,7 @@ export const useChatStore = create<ChatStore>()(
const config = get().config; const config = get().config;
const n = session.messages.length; const n = session.messages.length;
const recentMessages = session.messages.slice( const recentMessages = session.messages.slice(
n - config.historyMessageCount n - config.historyMessageCount,
); );
const memoryPrompt = get().getMemoryPrompt(); const memoryPrompt = get().getMemoryPrompt();
@ -376,7 +381,7 @@ export const useChatStore = create<ChatStore>()(
updateMessage( updateMessage(
sessionIndex: number, sessionIndex: number,
messageIndex: number, messageIndex: number,
updater: (message?: Message) => void updater: (message?: Message) => void,
) { ) {
const sessions = get().sessions; const sessions = get().sessions;
const session = sessions.at(sessionIndex); const session = sessions.at(sessionIndex);
@ -393,24 +398,24 @@ export const useChatStore = create<ChatStore>()(
requestWithPrompt(session.messages, Locale.Store.Prompt.Topic).then( requestWithPrompt(session.messages, Locale.Store.Prompt.Topic).then(
(res) => { (res) => {
get().updateCurrentSession( get().updateCurrentSession(
(session) => (session.topic = trimTopic(res)) (session) => (session.topic = trimTopic(res)),
); );
} },
); );
} }
const config = get().config; const config = get().config;
let toBeSummarizedMsgs = session.messages.slice( let toBeSummarizedMsgs = session.messages.slice(
session.lastSummarizeIndex session.lastSummarizeIndex,
); );
const historyMsgLength = toBeSummarizedMsgs.reduce( const historyMsgLength = toBeSummarizedMsgs.reduce(
(pre, cur) => pre + cur.content.length, (pre, cur) => pre + cur.content.length,
0 0,
); );
if (historyMsgLength > 4000) { if (historyMsgLength > 4000) {
toBeSummarizedMsgs = toBeSummarizedMsgs.slice( toBeSummarizedMsgs = toBeSummarizedMsgs.slice(
-config.historyMessageCount -config.historyMessageCount,
); );
} }
@ -423,7 +428,7 @@ export const useChatStore = create<ChatStore>()(
"[Chat History] ", "[Chat History] ",
toBeSummarizedMsgs, toBeSummarizedMsgs,
historyMsgLength, historyMsgLength,
config.compressMessageLengthThreshold config.compressMessageLengthThreshold,
); );
if (historyMsgLength > config.compressMessageLengthThreshold) { if (historyMsgLength > config.compressMessageLengthThreshold) {
@ -445,7 +450,7 @@ export const useChatStore = create<ChatStore>()(
onError(error) { onError(error) {
console.error("[Summarize] ", error); console.error("[Summarize] ", error);
}, },
} },
); );
} }
}, },
@ -474,6 +479,6 @@ export const useChatStore = create<ChatStore>()(
{ {
name: LOCAL_KEY, name: LOCAL_KEY,
version: 1, version: 1,
} },
) ),
); );

117
app/store/prompt.ts Normal file
View File

@ -0,0 +1,117 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import Fuse from "fuse.js";
export interface Prompt {
id?: number;
title: string;
content: string;
}
export interface PromptStore {
latestId: number;
prompts: Map<number, Prompt>;
add: (prompt: Prompt) => number;
remove: (id: number) => void;
search: (text: string) => Prompt[];
}
export const PROMPT_KEY = "prompt-store";
export const SearchService = {
ready: false,
engine: new Fuse<Prompt>([], { keys: ["title"] }),
count: {
builtin: 0,
},
init(prompts: Prompt[]) {
if (this.ready) {
return;
}
this.engine.setCollection(prompts);
this.ready = true;
},
remove(id: number) {
this.engine.remove((doc) => doc.id === id);
},
add(prompt: Prompt) {
this.engine.add(prompt);
},
search(text: string) {
const results = this.engine.search(text);
return results.map((v) => v.item);
},
};
export const usePromptStore = create<PromptStore>()(
persist(
(set, get) => ({
latestId: 0,
prompts: new Map(),
add(prompt) {
const prompts = get().prompts;
prompt.id = get().latestId + 1;
prompts.set(prompt.id, prompt);
set(() => ({
latestId: prompt.id!,
prompts: prompts,
}));
return prompt.id!;
},
remove(id) {
const prompts = get().prompts;
prompts.delete(id);
SearchService.remove(id);
set(() => ({
prompts,
}));
},
search(text) {
return SearchService.search(text) as Prompt[];
},
}),
{
name: PROMPT_KEY,
version: 1,
onRehydrateStorage(state) {
const PROMPT_URL = "./prompts.json";
type PromptList = Array<[string, string]>;
fetch(PROMPT_URL)
.then((res) => res.json())
.then((res) => {
const builtinPrompts = [res.en, res.cn]
.map((promptList: PromptList) => {
return promptList.map(
([title, content]) =>
({
title,
content,
} as Prompt)
);
})
.concat([...(state?.prompts?.values() ?? [])]);
const allPromptsForSearch = builtinPrompts.reduce(
(pre, cur) => pre.concat(cur),
[]
);
SearchService.count.builtin = res.en.length + res.cn.length;
SearchService.init(allPromptsForSearch);
});
},
}
)
);

View File

@ -1117,3 +1117,6 @@
.markdown-body ::-webkit-calendar-picker-indicator { .markdown-body ::-webkit-calendar-picker-indicator {
filter: invert(50%); filter: invert(50%);
} }
.markdown-body .line-break {
white-space: pre-wrap;
}

View File

@ -4,25 +4,21 @@
"private": false, "private": false,
"license": "Anti 996", "license": "Anti 996",
"scripts": { "scripts": {
"dev": "next dev", "dev": "yarn fetch && next dev",
"build": "next build", "build": "yarn fetch && next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint",
"fetch": "node ./scripts/fetch-prompts.mjs",
"prepare": "husky install"
}, },
"dependencies": { "dependencies": {
"@svgr/webpack": "^6.5.1", "@svgr/webpack": "^6.5.1",
"@types/node": "^18.14.6",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@types/react-katex": "^3.0.0",
"@types/spark-md5": "^3.0.2",
"@vercel/analytics": "^0.1.11", "@vercel/analytics": "^0.1.11",
"cross-env": "^7.0.3",
"emoji-picker-react": "^4.4.7", "emoji-picker-react": "^4.4.7",
"eslint": "8.35.0",
"eslint-config-next": "13.2.3",
"eventsource-parser": "^0.1.0", "eventsource-parser": "^0.1.0",
"fuse.js": "^6.6.2",
"next": "^13.2.3", "next": "^13.2.3",
"node-fetch": "^3.3.1",
"openai": "^3.2.1", "openai": "^3.2.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@ -33,7 +29,23 @@
"remark-math": "^5.1.1", "remark-math": "^5.1.1",
"sass": "^1.59.2", "sass": "^1.59.2",
"spark-md5": "^3.0.2", "spark-md5": "^3.0.2",
"typescript": "4.9.5", "use-debounce": "^9.0.3",
"zustand": "^4.3.6" "zustand": "^4.3.6"
},
"devDependencies": {
"@types/node": "^18.14.6",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@types/react-katex": "^3.0.0",
"@types/spark-md5": "^3.0.2",
"cross-env": "^7.0.3",
"eslint": "^8.36.0",
"eslint-config-next": "13.2.3",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
"husky": "^8.0.0",
"lint-staged": "^13.2.0",
"prettier": "^2.8.7",
"typescript": "4.9.5"
} }
} }

49
scripts/fetch-prompts.mjs Normal file
View File

@ -0,0 +1,49 @@
import fetch from "node-fetch";
import fs from "fs/promises";
const CN_URL =
"https://raw.githubusercontent.com/PlexPt/awesome-chatgpt-prompts-zh/main/prompts-zh.json";
const EN_URL =
"https://raw.githubusercontent.com/f/awesome-chatgpt-prompts/main/prompts.csv";
const FILE = "./public/prompts.json";
async function fetchCN() {
console.log("[Fetch] fetching cn prompts...");
try {
const raw = await (await fetch(CN_URL)).json();
return raw.map((v) => [v.act, v.prompt]);
} catch (error) {
console.error("[Fetch] failed to fetch cn prompts", error);
return [];
}
}
async function fetchEN() {
console.log("[Fetch] fetching en prompts...");
try {
const raw = await (await fetch(EN_URL)).text();
return raw
.split("\n")
.slice(1)
.map((v) => v.split('","').map((v) => v.replace('"', "")));
} catch (error) {
console.error("[Fetch] failed to fetch cn prompts", error);
return [];
}
}
async function main() {
Promise.all([fetchCN(), fetchEN()])
.then(([cn, en]) => {
fs.writeFile(FILE, JSON.stringify({ cn, en }));
})
.catch((e) => {
console.error("[Fetch] failed to fetch prompts");
fs.writeFile(FILE, JSON.stringify({ cn: [], en: [] }));
})
.finally(() => {
console.log("[Fetch] saved to " + FILE);
});
}
main();

12391
yarn.lock

File diff suppressed because it is too large Load Diff