diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 197e2f0d..a7a29644 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -28,7 +28,7 @@ jobs: images: yidadaa/chatgpt-next-web tags: | type=raw,value=latest - type=semver,pattern={{version}} + type=ref,event=tag - name: Set up QEMU diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index 9914a603..9c7b7e6f 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -1,16 +1,30 @@ -# .github/workflows/sync.yml -name: Sync Fork +name: Upstream Sync on: - schedule: - - cron: "0 8 * * *" # 每天0点触发 + schedule: + - cron: "0 */6 * * *" # every 6 hours + workflow_dispatch: + jobs: - repo-sync: - runs-on: ubuntu-latest - steps: - - uses: TG908/fork-sync@v1.1 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} # 这个 token action 会默认配置, 这里只需这样写就行 - owner: Yidadaa # fork 上游项目 owner - head: main # fork 上游项目需要同步的分支 - base: main # 需要同步到本项目的目标分支 + 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 + - name: Checkout target repo + uses: actions/checkout@v3 + + # Step 2: run the sync action + - name: Sync upstream changes + id: sync + uses: aormsby/Fork-Sync-With-Upstream-action@v3.4 + with: + upstream_sync_repo: Yidadaa/ChatGPT-Next-Web + upstream_sync_branch: main + target_sync_branch: main + target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set + + # Set test_mode true to run tests instead of the true action!! + test_mode: false diff --git a/README.md b/README.md index d026e61a..815aba1a 100644 --- a/README.md +++ b/README.md @@ -35,22 +35,25 @@ 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 用户登陆、账号管理、消息云同步 + +- User login, accounts, cloud sync 用户登录、账号管理、消息云同步 - UI text customize 界面文字自定义 ## 开始使用 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; 3. 部署完毕后,即可开始使用; 4. (可选)[绑定自定义域名](https://vercel.com/docs/concepts/projects/domains/add-a-domain):Vercel 分配的域名 DNS 在某些区域被污染了,绑定自定义域名即可直连。 @@ -180,14 +183,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) diff --git a/app/api/chat-stream/route.ts b/app/api/chat-stream/route.ts index e7bdfc5f..f3317554 100644 --- a/app/api/chat-stream/route.ts +++ b/app/api/chat-stream/route.ts @@ -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) { diff --git a/app/api/openai/route.ts b/app/api/openai/route.ts index 5bc317e5..cc51dbfc 100644 --- a/app/api/openai/route.ts +++ b/app/api/openai/route.ts @@ -3,8 +3,10 @@ import { requestOpenai } from "../common"; async function makeRequest(req: NextRequest) { try { - const res = await requestOpenai(req); - return new Response(res.body); + const api = await requestOpenai(req); + const res = new NextResponse(api.body); + res.headers.set("Content-Type", "application/json"); + return res; } catch (e) { console.error("[OpenAI] ", req.body, e); return NextResponse.json( diff --git a/app/components/button.module.scss b/app/components/button.module.scss index b882a0c1..88da9748 100644 --- a/app/components/button.module.scss +++ b/app/components/button.module.scss @@ -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,25 +38,7 @@ } } -@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; -} \ No newline at end of file +} diff --git a/app/components/button.tsx b/app/components/button.tsx index 43b699b6..2e5707ae 100644 --- a/app/components/button.tsx +++ b/app/components/button.tsx @@ -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: {
-
{props.icon}
+
+ {props.icon} +
{props.text && (
{props.text}
)} diff --git a/app/components/chat-list.tsx b/app/components/chat-list.tsx new file mode 100644 index 00000000..8ad2b7dc --- /dev/null +++ b/app/components/chat-list.tsx @@ -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 ( +
+
{props.title}
+
+
+ {Locale.ChatItem.ChatItemCount(props.count)} +
+
{props.time}
+
+
+ +
+
+ ); +} + +export function ChatList() { + const [sessions, selectedIndex, selectSession, removeSession] = useChatStore( + (state) => [ + state.sessions, + state.currentSessionIndex, + state.selectSession, + state.removeSession, + ], + ); + + return ( +
+ {sessions.map((item, i) => ( + selectSession(i)} + onDelete={() => + (!isMobileScreen() || confirm(Locale.Home.DeleteChat)) && + removeSession(i) + } + /> + ))} +
+ ); +} diff --git a/app/components/chat.module.scss b/app/components/chat.module.scss new file mode 100644 index 00000000..5216fb25 --- /dev/null +++ b/app/components/chat.module.scss @@ -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; + } +} diff --git a/app/components/chat.tsx b/app/components/chat.tsx new file mode 100644 index 00000000..8b687c7e --- /dev/null +++ b/app/components/chat.tsx @@ -0,0 +1,646 @@ +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, + ChatSession, + 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: () => , +}); + +const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, { + loading: () => , +}); + +export function Avatar(props: { role: Message["role"] }) { + const config = useChatStore((state) => state.config); + + if (props.role !== "user") { + return ; + } + + return ( +
+ +
+ ); +} + +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: ( +
+
{mdText}
+
+ ), + actions: [ + } + bordered + text={Locale.Export.Copy} + onClick={() => copyToClipboard(mdText)} + />, + } + 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 ( +
+ {props.showToast && ( +
props.setShowModal(true)} + > + + + {Locale.Context.Toast(context.length)} + +
+ )} + {props.showModal && ( +
+ props.setShowModal(false)} + actions={[ + } + bordered + text={Locale.Memory.Copy} + onClick={() => copyToClipboard(session.memoryPrompt)} + />, + ]} + > + <> + {" "} +
+ {context.map((c, i) => ( +
+ + + updateContextPrompt(i, { + ...c, + content: e.target.value as any, + }) + } + > + } + className={chatStyle["context-delete-button"]} + onClick={() => removeContextPrompt(i)} + bordered + /> +
+ ))} + +
+ } + text={Locale.Context.Add} + bordered + className={chatStyle["context-prompt-button"]} + onClick={() => + addContextPrompt({ + role: "system", + content: "", + date: "", + }) + } + /> +
+
+
+
+ {Locale.Memory.Title} ({session.lastSummarizeIndex} of{" "} + {session.messages.length}) +
+
+ {session.memoryPrompt || Locale.Memory.EmptyContent} +
+
+ +
+
+ )} +
+ ); +} + +function useSubmitHandler() { + const config = useChatStore((state) => state.config); + const submitKey = config.submitKey; + + const shouldSubmit = (e: React.KeyboardEvent) => { + 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 ( +
+ {props.prompts.map((prompt, i) => ( +
props.onPromptSelect(prompt)} + > +
{prompt.title}
+
{prompt.content}
+
+ ))} +
+ ); +} + +function useScrollToBottom() { + // for auto-scroll + const scrollRef = useRef(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(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([]); + 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([]); + inputRef.current?.focus(); + }; + + // stop response + const onUserStop = (messageIndex: number) => { + ControllerPool.stop(sessionIndex, messageIndex); + }; + + // check if should send message + const onInputKeyDown = (e: React.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); + 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 ( +
+
+
+
{ + const newTopic = prompt(Locale.Chat.Rename, session.topic); + if (newTopic && newTopic !== session.topic) { + chatStore.updateCurrentSession( + (session) => (session.topic = newTopic!), + ); + } + }} + > + {session.topic} +
+
+ {Locale.Chat.SubTitle(session.messages.length)} +
+
+
+
+ } + bordered + title={Locale.Chat.Actions.ChatList} + onClick={props?.showSideBar} + /> +
+
+ } + bordered + title={Locale.Chat.Actions.CompressedHistory} + onClick={() => { + setShowPromptModal(true); + }} + /> +
+
+ } + bordered + title={Locale.Chat.Actions.Export} + onClick={() => { + exportMessages(session.messages, session.topic); + }} + /> +
+
+ + +
+ +
onChatBodyScroll(e.currentTarget)} + onTouchStart={() => inputRef.current?.blur()} + > + {messages.map((message, i) => { + const isUser = message.role === "user"; + + return ( +
+
+
+ +
+ {(message.preview || message.streaming) && ( +
+ {Locale.Chat.Typing} +
+ )} +
+ {!isUser && + !(message.preview || message.content.length === 0) && ( +
+ {message.streaming ? ( +
onUserStop(i)} + > + {Locale.Chat.Actions.Stop} +
+ ) : ( +
onResend(i)} + > + {Locale.Chat.Actions.Retry} +
+ )} + +
copyToClipboard(message.content)} + > + {Locale.Chat.Actions.Copy} +
+
+ )} + {(message.preview || message.content.length === 0) && + !isUser ? ( + + ) : ( +
onRightClick(e, message)} + onDoubleClickCapture={() => { + if (!isMobileScreen()) return; + setUserInput(message.content); + }} + onMouseOver={() => inputRef.current?.blur()} + > + +
+ )} +
+ {!isUser && !message.preview && ( +
+
+ {message.date.toLocaleString()} +
+
+ )} +
+
+ ); + })} +
+ +
+ +
+