diff --git a/README.md b/README.md index 1346585b8..55a6da404 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ One-Click to deploy well-designed ChatGPT web UI on Vercel. 一键免费部署你的私人 ChatGPT 网页应用。 -[演示 Demo](https://chat-gpt-next-web.vercel.app/) / [反馈 Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [Join Discord](https://discord.gg/zrhvHCr79N) / [QQ 群](https://user-images.githubusercontent.com/16968934/228190818-7dd00845-e9b9-4363-97e5-44c507ac76da.jpeg) / [打赏开发者](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg) +[演示 Demo](https://chat-gpt-next-web.vercel.app/) / [反馈 Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [Join Discord](https://discord.gg/zrhvHCr79N) / [QQ 群](https://user-images.githubusercontent.com/16968934/228190818-7dd00845-e9b9-4363-97e5-44c507ac76da.jpeg) / [打赏开发者](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg) / [Buy Me a Coffee](https://www.buymeacoffee.com/yidadaa) [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web) @@ -169,6 +169,8 @@ bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/s ![More](./docs/images/more.png) +## Donation +[Buy Me a Coffee](https://www.buymeacoffee.com/yidadaa) ## Special Thanks ### Sponsor @@ -177,6 +179,11 @@ bash <(curl -s https://raw.githubusercontent.com/Yidadaa/ChatGPT-Next-Web/main/s [@ClarenceDan](https://github.com/ClarenceDan) [@zhangjia](https://github.com/zhangjia) [@hoochanlon](https://github.com/hoochanlon) +[@relativequantum](https://github.com/relativequantum) +[@desenmeng](https://github.com/desenmeng) +[@webees](https://github.com/webees) +[@chazzhou](https://github.com/chazzhou) +[@hauy](https://github.com/hauy) ### Contributor diff --git a/README_CN.md b/README_CN.md index cba9df9c5..efd5d56a1 100644 --- a/README_CN.md +++ b/README_CN.md @@ -11,7 +11,7 @@ [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web) -![主界面](./static/cover.png) +![主界面](./docs/images/cover.png) @@ -29,7 +29,7 @@ 1. 准备好你的 [OpenAI API Key](https://platform.openai.com/account/api-keys); 2. 点击右侧按钮开始部署: - [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web),直接使用 Github 账号登录即可,记得在环境变量页填入 API Key; + [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web),直接使用 Github 账号登录即可,记得在环境变量页填入 API Key 和[页面访问密码](#配置页面访问密码) CODE; 3. 部署完毕后,即可开始使用; 4. (可选)[绑定自定义域名](https://vercel.com/docs/concepts/projects/domains/add-a-domain):Vercel 分配的域名 DNS 在某些区域被污染了,绑定自定义域名即可直连。 @@ -53,6 +53,8 @@ > 配置密码后,用户需要在设置页手动填写访问码才可以正常聊天,否则会通过消息提示未授权状态。 +> **警告**:请务必将密码的位数设置得足够长,最好 7 位以上,否则[会被爆破](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/518)。 + 本项目提供有限的权限控制功能,请在 Vercel 项目控制面板的环境变量页增加名为 `CODE` 的环境变量,值为用英文逗号分隔的自定义密码: ``` diff --git a/app/api/openai/route.ts b/app/api/openai/route.ts index 3477fc270..261c20a85 100644 --- a/app/api/openai/route.ts +++ b/app/api/openai/route.ts @@ -17,7 +17,7 @@ async function makeRequest(req: NextRequest) { }, { status: 500, - } + }, ); } } diff --git a/app/components/chat.tsx b/app/components/chat.tsx index 4ab616444..c5c257e54 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -1,4 +1,4 @@ -import { useDebouncedCallback } from "use-debounce"; +import { useDebounce, useDebouncedCallback } from "use-debounce"; import { memo, useState, useRef, useEffect, useLayoutEffect } from "react"; import SendWhiteIcon from "../icons/send-white.svg"; @@ -27,6 +27,7 @@ import { getEmojiUrl, isMobileScreen, selectOrCopy, + autoGrowTextArea, } from "../utils"; import dynamic from "next/dynamic"; @@ -39,7 +40,7 @@ import { IconButton } from "./button"; import styles from "./home.module.scss"; import chatStyle from "./chat.module.scss"; -import { Input, Modal, showModal, showToast } from "./ui-lib"; +import { Input, Modal, showModal } from "./ui-lib"; const Markdown = dynamic( async () => memo((await import("./markdown")).Markdown), @@ -343,6 +344,7 @@ export function Chat(props: { const inputRef = useRef(null); const [userInput, setUserInput] = useState(""); + const [beforeInput, setBeforeInput] = useState(""); const [isLoading, setIsLoading] = useState(false); const { submitKey, shouldSubmit } = useSubmitHandler(); const { scrollRef, setAutoScroll } = useScrollToBottom(); @@ -380,6 +382,27 @@ export function Chat(props: { dom.scrollTop = dom.scrollHeight - dom.offsetHeight + paddingBottomNum; }; + // auto grow input + const [inputRows, setInputRows] = useState(2); + const measure = useDebouncedCallback( + () => { + const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1; + const inputRows = Math.min( + 5, + Math.max(2 + Number(!isMobileScreen()), rows), + ); + setInputRows(inputRows); + }, + 100, + { + leading: true, + trailing: true, + }, + ); + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(measure, [userInput]); + // only search prompts when user input is short const SEARCH_TEXT_LIMIT = 30; const onInput = (text: string) => { @@ -394,9 +417,6 @@ export function Chat(props: { // check if need to trigger auto completion if (text.startsWith("/")) { let searchText = text.slice(1); - if (searchText.length === 0) { - searchText = " "; - } onSearch(searchText); } } @@ -407,6 +427,7 @@ export function Chat(props: { if (userInput.length <= 0) return; setIsLoading(true); chatStore.onUserInput(userInput).then(() => setIsLoading(false)); + setBeforeInput(userInput); setUserInput(""); setPromptHints([]); if (!isMobileScreen()) inputRef.current?.focus(); @@ -420,6 +441,12 @@ export function Chat(props: { // check if should send message const onInputKeyDown = (e: React.KeyboardEvent) => { + // if ArrowUp and no userInput + if (e.key === "ArrowUp" && userInput.length <= 0) { + setUserInput(beforeInput); + e.preventDefault(); + return; + } if (shouldSubmit(e)) { onUserSubmit(); e.preventDefault(); @@ -660,7 +687,6 @@ export function Chat(props: { ref={inputRef} className={styles["chat-input"]} placeholder={Locale.Chat.Input(submitKey)} - rows={2} onInput={(e) => onInput(e.currentTarget.value)} value={userInput} onKeyDown={onInputKeyDown} @@ -670,6 +696,7 @@ export function Chat(props: { setTimeout(() => setPromptHints([]), 500); }} autoFocus={!props?.sideBarShowing} + rows={inputRows} /> } diff --git a/app/components/home.module.scss b/app/components/home.module.scss index abef999a5..e20fa63e9 100644 --- a/app/components/home.module.scss +++ b/app/components/home.module.scss @@ -406,17 +406,11 @@ background-color: var(--white); color: var(--black); font-family: inherit; - padding: 10px 14px 50px; + padding: 10px 90px 10px 14px; resize: none; outline: none; } -@media only screen and (max-width: 600px) { - .chat-input { - font-size: 16px; - } -} - .chat-input:focus { border: 1px solid var(--primary); } @@ -427,7 +421,17 @@ position: absolute; right: 30px; - bottom: 30px; + bottom: 32px; +} + +@media only screen and (max-width: 600px) { + .chat-input { + font-size: 16px; + } + + .chat-input-send { + bottom: 30px; + } } .export-content { diff --git a/app/components/markdown.tsx b/app/components/markdown.tsx index 25991d742..c8076640c 100644 --- a/app/components/markdown.tsx +++ b/app/components/markdown.tsx @@ -67,7 +67,7 @@ export function Markdown(props: { content: string }) { components={{ pre: PreCode, }} - linkTarget={'_blank'} + linkTarget={"_blank"} > {props.content} diff --git a/app/components/settings.tsx b/app/components/settings.tsx index ed1c28ee5..d1d217ead 100644 --- a/app/components/settings.tsx +++ b/app/components/settings.tsx @@ -128,6 +128,19 @@ export function Settings(props: { closeSettings: () => void }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + const keydownEvent = (e: KeyboardEvent) => { + if (e.key === "Escape") { + props.closeSettings(); + } + }; + document.addEventListener("keydown", keydownEvent); + return () => { + document.removeEventListener("keydown", keydownEvent); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return (
diff --git a/app/components/ui-lib.module.scss b/app/components/ui-lib.module.scss index 83eb614f7..67d13d3e8 100644 --- a/app/components/ui-lib.module.scss +++ b/app/components/ui-lib.module.scss @@ -9,6 +9,7 @@ .popover { position: relative; + z-index: 2; } .popover-content { diff --git a/app/locales/cn.ts b/app/locales/cn.ts index e21272a12..d97361e71 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -21,11 +21,11 @@ const cn = { Rename: "重命名对话", Typing: "正在输入…", Input: (submitKey: string) => { - var inputHints = `输入消息,${submitKey} 发送`; + var inputHints = `${submitKey} 发送`; if (submitKey === String(SubmitKey.Enter)) { inputHints += ",Shift + Enter 换行"; } - return inputHints; + return inputHints + ",/ 触发补全"; }, Send: "发送", }, @@ -126,7 +126,7 @@ const cn = { Model: "模型 (model)", Temperature: { Title: "随机性 (temperature)", - SubTitle: "值越大,回复越随机", + SubTitle: "值越大,回复越随机,大于 1 的值可能会导致乱码", }, MaxTokens: { Title: "单次回复限制 (max_tokens)", diff --git a/app/locales/en.ts b/app/locales/en.ts index 61d20b60f..3bdf491b4 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -23,11 +23,11 @@ const en: LocaleType = { Rename: "Rename Chat", Typing: "Typing…", Input: (submitKey: string) => { - var inputHints = `Type something and press ${submitKey} to send`; + var inputHints = `${submitKey} to send`; if (submitKey === String(SubmitKey.Enter)) { - inputHints += ", press Shift + Enter to newline"; + inputHints += ", Shift + Enter to wrap"; } - return inputHints; + return inputHints + ", / to search prompts"; }, Send: "Send", }, diff --git a/app/requests.ts b/app/requests.ts index 987434ed4..ee3498c11 100644 --- a/app/requests.ts +++ b/app/requests.ts @@ -9,7 +9,7 @@ const makeRequestParam = ( options?: { filterBot?: boolean; stream?: boolean; - } + }, ): ChatRequest => { let sendMessages = messages.map((v) => ({ role: v.role, @@ -20,7 +20,11 @@ const makeRequestParam = ( sendMessages = sendMessages.filter((m) => m.role !== "assistant"); } - const modelConfig = useChatStore.getState().config.modelConfig; + const modelConfig = { ...useChatStore.getState().config.modelConfig }; + + // @yidadaa: wont send max_tokens, because it is nonsense for Muggles + // @ts-expect-error + delete modelConfig.max_tokens; return { messages: sendMessages, @@ -84,7 +88,7 @@ export async function requestUsage() { const [used, subs] = await Promise.all([ requestOpenaiClient( - `dashboard/billing/usage?start_date=${startDate}&end_date=${endDate}` + `dashboard/billing/usage?start_date=${startDate}&end_date=${endDate}`, )(null, "GET"), requestOpenaiClient("dashboard/billing/subscription")(null, "GET"), ]); @@ -124,7 +128,7 @@ export async function requestChatStream( onMessage: (message: string, done: boolean) => void; onError: (error: Error, statusCode?: number) => void; onController?: (controller: AbortController) => void; - } + }, ) { const req = makeRequestParam(messages, { stream: true, @@ -213,7 +217,7 @@ export const ControllerPool = { addController( sessionIndex: number, messageId: number, - controller: AbortController + controller: AbortController, ) { const key = this.key(sessionIndex, messageId); this.controllers[key] = controller; diff --git a/app/store/app.ts b/app/store/app.ts index e72163ebb..02fb4b32a 100644 --- a/app/store/app.ts +++ b/app/store/app.ts @@ -332,18 +332,19 @@ export const useChatStore = create()( const isLastSession = get().sessions.length === 1; if (!isMobileScreen() || confirm(Locale.Home.DeleteChat)) { get().removeSession(index); + + showToast(Locale.Home.DeleteToast, { + text: Locale.Home.Revert, + onClick() { + set((state) => ({ + sessions: state.sessions + .slice(0, index) + .concat([deletedSession]) + .concat(state.sessions.slice(index + Number(isLastSession))), + })); + }, + }); } - showToast(Locale.Home.DeleteToast, { - text: Locale.Home.Revert, - onClick() { - set((state) => ({ - sessions: state.sessions - .slice(0, index) - .concat([deletedSession]) - .concat(state.sessions.slice(index + Number(isLastSession))), - })); - }, - }); }, currentSession() { diff --git a/app/store/prompt.ts b/app/store/prompt.ts index b91a38d0b..d0dd454ac 100644 --- a/app/store/prompt.ts +++ b/app/store/prompt.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import Fuse from "fuse.js"; +import { getLang } from "../locales"; export interface Prompt { id?: number; @@ -25,11 +26,13 @@ export const SearchService = { count: { builtin: 0, }, + allBuiltInPrompts: [] as Prompt[], init(prompts: Prompt[]) { if (this.ready) { return; } + this.allBuiltInPrompts = prompts; this.engine.setCollection(prompts); this.ready = true; }, @@ -78,6 +81,11 @@ export const usePromptStore = create()( }, search(text) { + if (text.length === 0) { + // return all prompts + const userPrompts = get().prompts?.values?.() ?? []; + return SearchService.allBuiltInPrompts.concat([...userPrompts]); + } return SearchService.search(text) as Prompt[]; }, }), @@ -92,7 +100,11 @@ export const usePromptStore = create()( fetch(PROMPT_URL) .then((res) => res.json()) .then((res) => { - const builtinPrompts = [res.en, res.cn] + let fetchPrompts = [res.en, res.cn]; + if (getLang() === "cn") { + fetchPrompts = fetchPrompts.reverse(); + } + const builtinPrompts = fetchPrompts .map((promptList: PromptList) => { return promptList.map( ([title, content]) => diff --git a/app/utils.ts b/app/utils.ts index 333866c7b..9a792fd52 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -51,6 +51,12 @@ export function isMobileScreen() { return window.innerWidth <= 600; } +export function isFirefox() { + return ( + typeof navigator !== "undefined" && /firefox/i.test(navigator.userAgent) + ); +} + export function selectOrCopy(el: HTMLElement, content: string) { const currentSelection = window.getSelection(); @@ -91,3 +97,51 @@ export function getCurrentVersion() { export function getEmojiUrl(unified: string, style: EmojiStyle) { return `https://cdn.staticfile.org/emoji-datasource-apple/14.0.0/img/${style}/64/${unified}.png`; } + +function getDomContentWidth(dom: HTMLElement) { + const style = window.getComputedStyle(dom); + const paddingWidth = + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight); + const width = dom.clientWidth - paddingWidth; + return width; +} + +function getOrCreateMeasureDom(id: string, init?: (dom: HTMLElement) => void) { + let dom = document.getElementById(id); + + if (!dom) { + dom = document.createElement("span"); + dom.style.position = "absolute"; + dom.style.wordBreak = "break-word"; + dom.style.fontSize = "14px"; + dom.style.transform = "translateY(-200vh)"; + dom.style.pointerEvents = "none"; + dom.style.opacity = "0"; + dom.id = id; + document.body.appendChild(dom); + init?.(dom); + } + + return dom!; +} + +export function autoGrowTextArea(dom: HTMLTextAreaElement) { + const measureDom = getOrCreateMeasureDom("__measure"); + const singleLineDom = getOrCreateMeasureDom("__single_measure", (dom) => { + dom.innerText = "TEXT_FOR_MEASURE"; + }); + + const width = getDomContentWidth(dom); + measureDom.style.width = width + "px"; + measureDom.innerHTML = dom.value.trim().length > 0 ? dom.value : "1"; + + const lineWrapCount = Math.max(0, dom.value.split("\n").length - 1); + const height = parseFloat(window.getComputedStyle(measureDom).height); + const singleLineHeight = parseFloat( + window.getComputedStyle(singleLineDom).height, + ); + + const rows = Math.round(height / singleLineHeight) + lineWrapCount; + + return rows; +} diff --git a/tsconfig.json b/tsconfig.json index 14d189328..c73eef3e8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,6 @@ "@/*": ["./*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "app/calcTextareaHeight.ts"], "exclude": ["node_modules"] }