Merge branch 'fix-theme' of https://github.com/AprilNEA/ChatGPT-Next-Web into fix-theme

This commit is contained in:
AprilNEA
2023-03-27 17:05:15 +08:00
21 changed files with 288 additions and 77 deletions

View File

@@ -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<Uint8Array>) {
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<Uint8Array>) {
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);

View File

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

View File

@@ -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<HTMLDivElement>(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 }) {
</div>
</div>
<div
className={styles["chat-body"]}
onMouseOver={() => {
setHoveringMessage(true);
}}
onMouseOut={() => {
setHoveringMessage(false);
}}
>
<div className={styles["chat-body"]}>
{messages.map((message, i) => {
const isUser = message.role === "user";
@@ -283,13 +316,20 @@ export function Chat(props: { showSideBar?: () => void }) {
<div className={styles["chat-message-item"]}>
{!isUser && (
<div className={styles["chat-message-top-actions"]}>
{message.streaming && (
{message.streaming ? (
<div
className={styles["chat-message-top-action"]}
onClick={() => showToast(Locale.WIP)}
onClick={() => onUserStop(i)}
>
{Locale.Chat.Actions.Stop}
</div>
) : (
<div
className={styles["chat-message-top-action"]}
onClick={() => onResend(i)}
>
{Locale.Chat.Actions.Retry}
</div>
)}
<div
@@ -306,11 +346,7 @@ export function Chat(props: { showSideBar?: () => void }) {
) : (
<div
className="markdown-body"
onContextMenu={(e) => {
if (selectOrCopy(e.currentTarget, message.content)) {
e.preventDefault();
}
}}
onContextMenu={(e) => onRightClick(e, message)}
>
<Markdown content={message.content} />
</div>
@@ -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
/>
<IconButton
icon={<SendWhiteIcon />}

View File

@@ -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<HTMLPreElement>(null);
return (
<pre ref={ref}>
<span
className="copy-code-button"
onClick={() => {
if (ref.current) {
const code = ref.current.innerText;
copyToClipboard(code);
}
}}
></span>
{props.children}
</pre>
);
}
export function Markdown(props: { content: string }) {
return (
<ReactMarkdown
remarkPlugins={[RemarkMath, RemarkGfm]}
rehypePlugins={[
RehypeKatex,
[RehypePrsim, { ignoreMissing: true }],
]}
rehypePlugins={[RehypeKatex, [RehypePrsim, { ignoreMissing: true }]]}
components={{
pre: PreCode,
}}
>
{props.content}
</ReactMarkdown>

View File

@@ -257,6 +257,20 @@ export function Settings(props: { closeSettings: () => void }) {
<></>
)}
<SettingItem
title={Locale.Settings.Token.Title}
subTitle={Locale.Settings.Token.SubTitle}
>
<input
value={accessStore.token}
type="text"
placeholder={Locale.Settings.Token.Placeholder}
onChange={(e) => {
accessStore.updateToken(e.currentTarget.value);
}}
></input>
</SettingItem>
<SettingItem
title={Locale.Settings.HistoryCount.Title}
subTitle={Locale.Settings.HistoryCount.SubTitle}

View File

@@ -14,6 +14,7 @@ const cn = {
Export: "导出聊天记录",
Copy: "复制",
Stop: "停止",
Retry: "重试",
},
Typing: "正在输入…",
Input: (submitKey: string) => `输入消息,${submitKey} 发送`,
@@ -68,6 +69,11 @@ const cn = {
Title: "历史消息长度压缩阈值",
SubTitle: "当未压缩的历史消息超过该值时,将进行压缩",
},
Token: {
Title: "API Key",
SubTitle: "使用自己的 Key 可绕过受控访问限制",
Placeholder: "OpenAI API Key",
},
AccessCode: {
Title: "访问码",
SubTitle: "现在是受控访问状态",

View File

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

View File

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

View File

@@ -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<string, AbortController>,
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}`;
},
};

View File

@@ -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<AccessControlStore>()(
persist(
(set, get) => ({
token: "",
accessCode: "",
enabledAccessControl() {
return queryMeta("access") === "enabled";
@@ -21,6 +24,9 @@ export const useAccessStore = create<AccessControlStore>()(
updateCode(code: string) {
set((state) => ({ accessCode: code }));
},
updateToken(token: string) {
set((state) => ({ token }));
},
}),
{
name: ACCESS_KEY,

View File

@@ -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<ChatStore>()(
// 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<ChatStore>()(
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<ChatStore>()(
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,

View File

@@ -213,3 +213,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;
}
}
}

View File

@@ -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();
// }
// }