diff --git a/README.md b/README.md index de57e4bb..f20debd0 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ One-Click to deploy your own ChatGPT web UI. -[演示 Demo](https://chat-gpt-next-web.vercel.app/) / [反馈问题 Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) +[演示 Demo](https://chat-gpt-next-web.vercel.app/) / [反馈 Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [加入 Discord](https://discord.gg/zrhvHCr79N) / [微信群](https://user-images.githubusercontent.com/16968934/227772522-b3ba3713-9206-4c8d-a81f-22300b7c313a.jpg) / [打赏开发者](https://user-images.githubusercontent.com/16968934/227772541-5bcd52d8-61b7-488c-a203-0330d8006e2b.jpg) [![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&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web) @@ -84,7 +84,7 @@ You can star or watch this project or follow author to get release notifictions code1,code2,code3 ``` -增加或修改该环境变量后,请重新部署项目使改动生效。 +增加或修改该环境变量后,请**重新部署**项目使改动生效。 This project provides limited access control. Please add an environment variable named `CODE` on the environment variables page. The value should be a custom control code separated by comma like this: @@ -109,29 +109,36 @@ OPENAI_API_KEY= ``` ### 本地开发 Local Development + > 如果你是中国大陆用户,不建议在本地进行开发,除非你能够独立解决 OpenAI API 本地代理问题。 1. 安装 nodejs 和 yarn,具体细节请询问 ChatGPT; 2. 执行 `yarn install && yarn dev` 即可。 ### 本地部署 Local Deployment + 请直接询问 ChatGPT,使用下列 Prompt: + ``` 如何使用 pm2 和 yarn 部署 nextjs 项目到 ubuntu 服务器上,项目编译命令为 yarn build,启动命令为 yarn start,启动时需要设置环境变量为 OPENAI_API_KEY,端口为 3000,使用 ngnix 做反向代理 ``` Please ask ChatGPT with prompt: + ``` how to deploy nextjs project with pm2 and yarn on my ubuntu server, the build command is `yarn build`, the start command is `yarn start`, the project must start with env var named `OPENAI_API_KEY`, the port is 3000, use ngnix ``` ### Docker Deployment + 请直接询问 ChatGPT,使用下列 Prompt: + ``` 如何使用 docker 部署 nextjs 项目到 ubuntu 服务器上,项目编译命令为 yarn build,启动命令为 yarn start,启动时需要设置环境变量为 OPENAI_API_KEY,端口为 3000,使用 ngnix 做反向代理 ``` Please ask ChatGPT with prompt: + ``` how to deploy nextjs project with docker on my ubuntu server, the build command is `yarn build`, the start command is `yarn start`, the project must start with env var named `OPENAI_API_KEY`, the port is 3000, use ngnix ``` @@ -143,14 +150,22 @@ how to deploy nextjs project with docker on my ubuntu server, the build command ![更多展示 More](./static/more.png) ## 说明 Attention + 本项目的演示地址所用的 OpenAI 账户的免费额度将于 2023-04-01 过期,届时将无法通过演示地址在线体验。 如果你想贡献出自己的 API Key,可以通过作者主页的邮箱发送给作者,并标注过期时间。 -The free trial of the OpenAI account used by the demo will expire on April 1, 2023, and the demo will not be available at that time. +The free trial of the OpenAI account used by the demo will expire on April 1, 2023, and the demo will not be available at that time. If you would like to contribute your API key, you can email it to the author and indicate the expiration date of the API key. +## 鸣谢 Special Thanks + +### 捐赠者 Sponsor + +[@mushan0x0](https://github.com/mushan0x0) +[@ClarenceDan](https://github.com/ClarenceDan) + ## LICENSE - [Anti 996 License](https://github.com/kattgu7/Anti-996-License/blob/master/LICENSE_CN_EN) diff --git a/app/api/chat-stream/route.ts b/app/api/chat-stream/route.ts index 16c5950c..8803a425 100644 --- a/app/api/chat-stream/route.ts +++ b/app/api/chat-stream/route.ts @@ -2,19 +2,25 @@ import type { ChatRequest } from "../chat/typing"; import { createParser } from "eventsource-parser"; import { NextRequest } from "next/server"; -const apiKey = process.env.OPENAI_API_KEY; - -async function createStream(payload: ReadableStream) { +async function createStream(req: NextRequest) { const encoder = new TextEncoder(); const decoder = new TextDecoder(); + let apiKey = process.env.OPENAI_API_KEY; + + const userApiKey = req.headers.get("token"); + if (userApiKey) { + apiKey = userApiKey; + console.log("[Stream] using user api key"); + } + const res = await fetch("https://api.openai.com/v1/chat/completions", { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, }, method: "POST", - body: payload, + body: req.body, }); const stream = new ReadableStream({ @@ -49,7 +55,7 @@ async function createStream(payload: ReadableStream) { export async function POST(req: NextRequest) { try { - const stream = await createStream(req.body!); + const stream = await createStream(req); return new Response(stream); } catch (error) { console.error("[Chat Stream]", error); diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index c4e41ca3..18c7db14 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,23 +1,26 @@ import { OpenAIApi, Configuration } from "openai"; import { ChatRequest } from "./typing"; -const apiKey = process.env.OPENAI_API_KEY; - -const openai = new OpenAIApi( - new Configuration({ - apiKey, - }) -); - export async function POST(req: Request) { try { - const requestBody = (await req.json()) as ChatRequest; - const completion = await openai!.createChatCompletion( - { - ...requestBody, - } + let apiKey = process.env.OPENAI_API_KEY; + + const userApiKey = req.headers.get("token"); + if (userApiKey) { + apiKey = userApiKey; + } + + const openai = new OpenAIApi( + new Configuration({ + apiKey, + }) ); + const requestBody = (await req.json()) as ChatRequest; + const completion = await openai!.createChatCompletion({ + ...requestBody, + }); + return new Response(JSON.stringify(completion.data)); } catch (e) { console.error("[Chat] ", e); diff --git a/app/components/home.tsx b/app/components/home.tsx index fd38ee8c..9f408b92 100644 --- a/app/components/home.tsx +++ b/app/components/home.tsx @@ -27,6 +27,7 @@ import Locale from "../locales"; import dynamic from "next/dynamic"; import { REPO_URL } from "../constant"; +import { ControllerPool } from "../requests"; export function Loading(props: { noLogo?: boolean }) { return ( @@ -146,28 +147,67 @@ function useSubmitHandler() { export function Chat(props: { showSideBar?: () => void }) { type RenderMessage = Message & { preview?: boolean }; - const session = useChatStore((state) => state.currentSession()); + const [session, sessionIndex] = useChatStore((state) => [ + state.currentSession(), + state.currentSessionIndex, + ]); const [userInput, setUserInput] = useState(""); const [isLoading, setIsLoading] = useState(false); const { submitKey, shouldSubmit } = useSubmitHandler(); const onUserInput = useChatStore((state) => state.onUserInput); + + // submit user input const onUserSubmit = () => { if (userInput.length <= 0) return; setIsLoading(true); onUserInput(userInput).then(() => setIsLoading(false)); setUserInput(""); }; + + // stop response + const onUserStop = (messageIndex: number) => { + console.log(ControllerPool, sessionIndex, messageIndex); + ControllerPool.stop(sessionIndex, messageIndex); + }; + + // check if should send message const onInputKeyDown = (e: KeyboardEvent) => { 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); + onUserInput(messages[i].content).then(() => setIsLoading(false)); + return; + } + } + }; + + // for auto-scroll const latestMessageRef = useRef(null); - const [hoveringMessage, setHoveringMessage] = useState(false); + // wont scroll while hovering messages + const [autoScroll, setAutoScroll] = useState(false); + // preview messages const messages = (session.messages as RenderMessage[]) .concat( isLoading @@ -194,10 +234,11 @@ export function Chat(props: { showSideBar?: () => void }) { : [] ); + // auto scroll useLayoutEffect(() => { setTimeout(() => { const dom = latestMessageRef.current; - if (dom && !isIOS() && !hoveringMessage) { + if (dom && !isIOS() && autoScroll) { dom.scrollIntoView({ behavior: "smooth", block: "end", @@ -252,15 +293,7 @@ export function Chat(props: { showSideBar?: () => void }) { -
{ - setHoveringMessage(true); - }} - onMouseOut={() => { - setHoveringMessage(false); - }} - > +
{messages.map((message, i) => { const isUser = message.role === "user"; @@ -283,13 +316,20 @@ export function Chat(props: { showSideBar?: () => void }) {
{!isUser && (
- {message.streaming && ( + {message.streaming ? (
showToast(Locale.WIP)} + onClick={() => onUserStop(i)} > {Locale.Chat.Actions.Stop}
+ ) : ( +
onResend(i)} + > + {Locale.Chat.Actions.Retry} +
)}
void }) { ) : (
{ - if (selectOrCopy(e.currentTarget, message.content)) { - e.preventDefault(); - } - }} + onContextMenu={(e) => onRightClick(e, message)} >
@@ -341,6 +377,9 @@ export function Chat(props: { showSideBar?: () => void }) { onInput={(e) => setUserInput(e.currentTarget.value)} value={userInput} onKeyDown={(e) => onInputKeyDown(e as any)} + onFocus={() => setAutoScroll(true)} + onBlur={() => setAutoScroll(false)} + autoFocus /> } diff --git a/app/components/markdown.tsx b/app/components/markdown.tsx index 83bfe4ef..6e0e6d86 100644 --- a/app/components/markdown.tsx +++ b/app/components/markdown.tsx @@ -4,15 +4,36 @@ import RemarkMath from "remark-math"; import RehypeKatex from "rehype-katex"; import RemarkGfm from "remark-gfm"; import RehypePrsim from "rehype-prism-plus"; +import { useRef } from "react"; +import { copyToClipboard } from "../utils"; + +export function PreCode(props: { children: any }) { + const ref = useRef(null); + + return ( +
+       {
+          if (ref.current) {
+            const code = ref.current.innerText;
+            copyToClipboard(code);
+          }
+        }}
+      >
+      {props.children}
+    
+ ); +} export function Markdown(props: { content: string }) { return ( {props.content} diff --git a/app/components/settings.tsx b/app/components/settings.tsx index a0a477af..56165daa 100644 --- a/app/components/settings.tsx +++ b/app/components/settings.tsx @@ -257,6 +257,20 @@ export function Settings(props: { closeSettings: () => void }) { <> )} + + { + accessStore.updateToken(e.currentTarget.value); + }} + > + + `输入消息,${submitKey} 发送`, @@ -68,6 +69,11 @@ const cn = { Title: "历史消息长度压缩阈值", SubTitle: "当未压缩的历史消息超过该值时,将进行压缩", }, + Token: { + Title: "API Key", + SubTitle: "使用自己的 Key 可绕过受控访问限制", + Placeholder: "OpenAI API Key", + }, AccessCode: { Title: "访问码", SubTitle: "现在是受控访问状态", diff --git a/app/locales/en.ts b/app/locales/en.ts index 85a0caff..5401cda4 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -17,6 +17,7 @@ const en: LocaleType = { Export: "Export All Messages as Markdown", Copy: "Copy", Stop: "Stop", + Retry: "Retry", }, Typing: "Typing…", Input: (submitKey: string) => @@ -73,6 +74,11 @@ const en: LocaleType = { SubTitle: "Will compress if uncompressed messages length exceeds the value", }, + Token: { + Title: "API Key", + SubTitle: "Use your key to ignore access code limit", + Placeholder: "OpenAI API Key", + }, AccessCode: { Title: "Access Code", SubTitle: "Access control enabled", diff --git a/app/page.tsx b/app/page.tsx index a986da3f..54300e71 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,5 @@ import { Analytics } from "@vercel/analytics/react"; -import { Home } from './components/home' +import { Home } from "./components/home"; export default function App() { return ( diff --git a/app/requests.ts b/app/requests.ts index 484fbb93..e9da8708 100644 --- a/app/requests.ts +++ b/app/requests.ts @@ -35,6 +35,10 @@ function getHeaders() { headers["access-code"] = accessStore.accessCode; } + if (accessStore.token && accessStore.token.length > 0) { + headers["token"] = accessStore.token; + } + return headers; } @@ -60,6 +64,7 @@ export async function requestChatStream( modelConfig?: ModelConfig; onMessage: (message: string, done: boolean) => void; onError: (error: Error) => void; + onController?: (controller: AbortController) => void; } ) { const req = makeRequestParam(messages, { @@ -96,12 +101,12 @@ export async function requestChatStream( controller.abort(); }; - console.log(res); - if (res.ok) { const reader = res.body?.getReader(); const decoder = new TextDecoder(); + options?.onController?.(controller); + while (true) { // handle time out, will stop if no response in 10 secs const resTimeoutId = setTimeout(() => finish(), TIME_OUT_MS); @@ -146,3 +151,34 @@ export async function requestWithPrompt(messages: Message[], prompt: string) { return res.choices.at(0)?.message?.content ?? ""; } + +// To store message streaming controller +export const ControllerPool = { + controllers: {} as Record, + + addController( + sessionIndex: number, + messageIndex: number, + controller: AbortController + ) { + const key = this.key(sessionIndex, messageIndex); + this.controllers[key] = controller; + return key; + }, + + stop(sessionIndex: number, messageIndex: number) { + const key = this.key(sessionIndex, messageIndex); + const controller = this.controllers[key]; + console.log(controller); + controller?.abort(); + }, + + remove(sessionIndex: number, messageIndex: number) { + const key = this.key(sessionIndex, messageIndex); + delete this.controllers[key]; + }, + + key(sessionIndex: number, messageIndex: number) { + return `${sessionIndex},${messageIndex}`; + }, +}; diff --git a/app/store/access.ts b/app/store/access.ts index 4ec2111c..9c61dfa0 100644 --- a/app/store/access.ts +++ b/app/store/access.ts @@ -4,7 +4,9 @@ import { queryMeta } from "../utils"; export interface AccessControlStore { accessCode: string; + token: string; + updateToken: (_: string) => void; updateCode: (_: string) => void; enabledAccessControl: () => boolean; } @@ -14,6 +16,7 @@ export const ACCESS_KEY = "access-control"; export const useAccessStore = create()( persist( (set, get) => ({ + token: "", accessCode: "", enabledAccessControl() { return queryMeta("access") === "enabled"; @@ -21,6 +24,9 @@ export const useAccessStore = create()( updateCode(code: string) { set((state) => ({ accessCode: code })); }, + updateToken(token: string) { + set((state) => ({ token })); + }, }), { name: ACCESS_KEY, diff --git a/app/store/app.ts b/app/store/app.ts index 3c4fcded..703078ad 100644 --- a/app/store/app.ts +++ b/app/store/app.ts @@ -2,7 +2,11 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { type ChatCompletionResponseMessage } from "openai"; -import { requestChatStream, requestWithPrompt } from "../requests"; +import { + ControllerPool, + requestChatStream, + requestWithPrompt, +} from "../requests"; import { trimTopic } from "../utils"; import Locale from "../locales"; @@ -45,22 +49,24 @@ export interface ChatConfig { export type ModelConfig = ChatConfig["modelConfig"]; +const ENABLE_GPT4 = true; + export const ALL_MODELS = [ { name: "gpt-4", - available: false, + available: ENABLE_GPT4, }, { name: "gpt-4-0314", - available: false, + available: ENABLE_GPT4, }, { name: "gpt-4-32k", - available: false, + available: ENABLE_GPT4, }, { name: "gpt-4-32k-0314", - available: false, + available: ENABLE_GPT4, }, { name: "gpt-3.5-turbo", @@ -296,6 +302,8 @@ export const useChatStore = create()( // get recent messages const recentMessages = get().getMessagesWithMemory(); const sendMessages = recentMessages.concat(userMessage); + const sessionIndex = get().currentSessionIndex; + const messageIndex = get().currentSession().messages.length + 1; // save user's and bot's message get().updateCurrentSession((session) => { @@ -303,13 +311,16 @@ export const useChatStore = create()( session.messages.push(botMessage); }); + // make request console.log("[User Input] ", sendMessages); requestChatStream(sendMessages, { onMessage(content, done) { + // stream response if (done) { botMessage.streaming = false; botMessage.content = content; get().onNewMessage(botMessage); + ControllerPool.remove(sessionIndex, messageIndex); } else { botMessage.content = content; set(() => ({})); @@ -319,6 +330,15 @@ export const useChatStore = create()( botMessage.content += "\n\n" + Locale.Store.Error; botMessage.streaming = false; set(() => ({})); + ControllerPool.remove(sessionIndex, messageIndex); + }, + onController(controller) { + // collect controller for stop/retry + ControllerPool.addController( + sessionIndex, + messageIndex, + controller + ); }, filterBot: !get().config.sendBotMessages, modelConfig: get().config.modelConfig, diff --git a/app/styles/globals.scss b/app/styles/globals.scss index e7d35226..46f074df 100644 --- a/app/styles/globals.scss +++ b/app/styles/globals.scss @@ -206,3 +206,36 @@ div.math { text-decoration: underline; } } + +pre { + position: relative; + + &:hover .copy-code-button { + pointer-events: all; + transform: translateX(0px); + opacity: 0.5; + } + + .copy-code-button { + position: absolute; + right: 10px; + cursor: pointer; + padding: 0px 5px; + background-color: var(--black); + color: var(--white); + border: var(--border-in-light); + border-radius: 10px; + transform: translateX(10px); + pointer-events: none; + opacity: 0; + transition: all ease 0.3s; + + &:after { + content: "copy"; + } + + &:hover { + opacity: 1; + } + } +} diff --git a/app/styles/prism.scss b/app/styles/prism.scss index 88ab1936..22b20389 100644 --- a/app/styles/prism.scss +++ b/app/styles/prism.scss @@ -1,4 +1,9 @@ .markdown-body { + pre { + background: #282a36; + color: #f8f8f2; + } + code[class*="language-"], pre[class*="language-"] { color: #f8f8f2; @@ -116,32 +121,32 @@ } } -@mixin light { - .markdown-body pre[class*="language-"] { - filter: invert(1) hue-rotate(50deg) brightness(1.3); - } -} +// @mixin light { +// .markdown-body pre[class*="language-"] { +// filter: invert(1) hue-rotate(50deg) brightness(1.3); +// } +// } -@mixin dark { - .markdown-body pre[class*="language-"] { - filter: none; - } -} +// @mixin dark { +// .markdown-body pre[class*="language-"] { +// filter: none; +// } +// } -:root { - @include light(); -} +// :root { +// @include light(); +// } -.light { - @include light(); -} +// .light { +// @include light(); +// } -.dark { - @include dark(); -} +// .dark { +// @include dark(); +// } -@media (prefers-color-scheme: dark) { - :root { - @include dark(); - } -} +// @media (prefers-color-scheme: dark) { +// :root { +// @include dark(); +// } +// } diff --git a/middleware.ts b/middleware.ts index 0ab3a101..7e671ff1 100644 --- a/middleware.ts +++ b/middleware.ts @@ -8,13 +8,14 @@ export const config = { export function middleware(req: NextRequest, res: NextResponse) { const accessCode = req.headers.get("access-code"); + const token = req.headers.get("token"); const hashedCode = md5.hash(accessCode ?? "").trim(); console.log("[Auth] allowed hashed codes: ", [...ACCESS_CODES]); console.log("[Auth] got access code:", accessCode); console.log("[Auth] hashed access code:", hashedCode); - if (ACCESS_CODES.size > 0 && !ACCESS_CODES.has(hashedCode)) { + if (ACCESS_CODES.size > 0 && !ACCESS_CODES.has(hashedCode) && !token) { return NextResponse.json( { needAccessCode: true, diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png index 2cd0df46..df44543f 100644 Binary files a/public/android-chrome-192x192.png and b/public/android-chrome-192x192.png differ diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png index 2cd0df46..283654fd 100644 Binary files a/public/android-chrome-512x512.png and b/public/android-chrome-512x512.png differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 2cd0df46..20ab9365 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png index ea527d9d..92f53492 100644 Binary files a/public/favicon-16x16.png and b/public/favicon-16x16.png differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png index 2cd0df46..fc262b9b 100644 Binary files a/public/favicon-32x32.png and b/public/favicon-32x32.png differ diff --git a/public/favicon.ico b/public/favicon.ico index 68b7b77f..a3737b35 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ