├── .env.example ├── .gitignore ├── LICENSE ├── README.md ├── assets └── qr.jpg ├── components ├── Chat.js ├── Chat.module.scss ├── Config.js ├── DropDown.tsx ├── Footer.tsx ├── GitHub.tsx ├── Header.tsx ├── LoadingDots.tsx ├── Messages.js ├── Messages.module.css ├── PromptElement.js ├── PromptElement.module.css ├── Shapes.js ├── logo.svg ├── notice.js └── share.js ├── next.config.js ├── package.json ├── pages ├── _app.tsx ├── _document.tsx ├── account.js ├── account.module.css ├── api │ └── chat.ts └── index.tsx ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── 1-black.png ├── 2-black.png ├── analytics.png ├── app.png ├── assets │ ├── group-qr-8-13.jpeg │ └── qr.jpg ├── favicon.ico ├── manifest.json ├── notice.json ├── og-image.png ├── qr-prom.png ├── qr.jpg ├── robots.txt ├── screenshot.png ├── share.jpg ├── vercel.svg ├── vercelLogo.png └── writingIcon.png ├── styles ├── globals.css └── loading-dots.module.css ├── tailwind.config.js ├── tsconfig.json └── utils ├── ChatServiceBridge.js ├── ObjCacheWrap.js ├── OpenAIStream.ts ├── Others.js ├── Settings.js └── client └── ChatGPTClient.js /.env.example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY= 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | .env 38 | 39 | # idea 40 | .idea 41 | 42 | .next -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 FrontendEngineeringTeam 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FullStack-ChatGPT-WebApp 2 | 3 | [![GitHub license](https://flashpixel-1253674045.cos.ap-shanghai.myqcloud.com/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d626c7565.svg)](https://github.com/frontend-engineering/chatgpt-webapp-fullstack) 4 | ![GitHub package.json dependency version (prod)](https://img.shields.io/github/package-json/dependency-version/WeixinCloud/wxcloudrun-express/express) 5 | ![GitHub package.json dependency version (prod)](https://img.shields.io/github/package-json/dependency-version/WeixinCloud/wxcloudrun-express/sequelize) 6 | 7 | 8 | 这是一个 ChatGPT 聊天应用,包含网页端App和一个Node服务,可快速部署一套自用的完整智能聊天服务([点击体验](https://www.webinfra.cloud)) 9 | 10 | ## 功能特点 11 | 12 | * 全栈应用:包括 **网页端App** 和 **服务端Node服务**,适合全链路功能的二次开发 13 | * 快速部署:项目迁移到了Nextjs架构,前后端一键部署,无须一行代码,小白也可以发布自己的ChatGPT服务了 14 | * 无须运维:利用vercel的免费额度,摆脱繁琐的运维工作 15 | * 无须翻墙:针对国内无法直接访问OpenAI的限制,本服务部署完成后,可直接访问,无须科学上网 16 | * 多端适配:适配手机端和PC端,更多客户端功能迭代中 17 | * 上下文记忆:问了保障问答质量,缓存了提问记录 18 | * stream响应:支持stream的响应方式,更好的问答体验 19 | * 流量控制:支持单用户的调用流量限制,防止恶意盗刷Token 20 | * 用户充值:支持开启用户收费功能,用户可充值购买调用额度 21 | 22 | >> 功能开发快速迭代中,使用或部署时如果遇到任何问题,请加页面最下方微信交流群反馈 23 | 24 | https://github.com/frontend-engineering/chatgpt-webapp-fullstack/assets/9939767/eaef68ce-e73b-4dd5-9277-6b0f4f201455 25 | 26 | 27 | 28 | ## 一键部署 29 | 30 | 自动部署服务到Vercel 31 | 32 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Ffrontend-engineering%2Fchatgpt-webapp-fullstack&project-name=private-chatgpt-service&repository-name=chatgpt-webapp-fullstack&demo-title=Demo%20Page&demo-description=%E7%A4%BA%E4%BE%8B%E9%A1%B9%E7%9B%AE&demo-url=https%3A%2F%2Fwebinfra.cloud) 33 | 34 | ### 部署依赖 35 | 36 | 需要额外配置vercel Edge Config 和 Vercel KV 两个store 37 | * Edge Config 用来管理项目配置 38 | * KV 用于存储用户聊天上下文的数据缓存 39 | 40 | 其中,Edge Config的初始配置可以参考 41 | ``` 42 | { 43 | "chatGptClient": { 44 | "openaiApiKey": "sk-你的openai api key", 45 | "reverseProxyUrl": "", 46 | "modelOptions": { 47 | "model": "gpt-3.5-turbo", 48 | "max_tokens": 1000 49 | }, 50 | "proxy": "", 51 | "debug": false 52 | }, 53 | "apiOptions": { 54 | "port": 3000, 55 | "host": "0.0.0.0", 56 | "debug": false, 57 | "clientToUse": "chatgpt", 58 | "perMessageClientOptionsWhitelist": { 59 | "validClientsToUse": ["bing", "chatgpt", "chatgpt-browser"], 60 | "chatgpt": [ 61 | "promptPrefix", 62 | "userLabel", 63 | "chatGptLabel", 64 | "modelOptions.temperature" 65 | ] 66 | } 67 | }, 68 | "cacheOptions": {} 69 | } 70 | ``` 71 | 72 | ## 项目结构 73 | 74 | 项目迁移到了NextJS,开发参考 [Next.js文档](https://nextjs.org/docs) 75 | 76 | ## 项目体验 77 | 78 | 打开 [DEMO](https://www.webinfra.cloud) 79 | 或扫码 80 |

81 | demo qr 82 |

83 | 84 | 85 | 86 | ## 更多功能 87 | 更多功能正在开发中,如有需要可以私聊,或者贡献PR 88 | 89 | * 前端WebApp功能补全 90 | * BingAI等多模型支持 91 | * 其他 92 | 93 | ## 私有部署 94 | 如有部署方面的问题,可关注公众号 webinfra 需求帮助 95 | ![](https://flashpixel-1253674045.cos.ap-shanghai.myqcloud.com/%E6%89%AB%E7%A0%81_%E6%90%9C%E7%B4%A2%E8%81%94%E5%90%88%E4%BC%A0%E6%92%AD%E6%A0%B7%E5%BC%8F-%E7%99%BD%E8%89%B2%E7%89%88.bmp) 96 | 97 | 或加交流群 98 |

99 | 100 |

101 | 102 | ## License 103 | 104 | [MIT](./LICENSE) 105 | -------------------------------------------------------------------------------- /assets/qr.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frontend-engineering/chatgpt-webapp-fullstack/b43cdad8ec6e5a0466b4d63c6eab6e819bc13dbd/assets/qr.jpg -------------------------------------------------------------------------------- /components/Chat.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'; 2 | import { useRouter } from 'next/router'; 3 | import { Toast, Button, Modal, TextArea, SafeArea, NoticeBar, Tag } from 'antd-mobile' 4 | import { PlayOutline, UserSetOutline } from 'antd-mobile-icons' 5 | import Cashier from '@cashier/web'; 6 | import classnames from 'classnames'; 7 | import { callBridge } from '../utils/ChatServiceBridge'; 8 | import Messages from './Messages'; 9 | import { useLocalStorage } from '../utils/Others'; 10 | import PromotSelect from './PromptElement.js'; 11 | import styles from './Chat.module.scss'; 12 | import { appId, appToken, enableAuth } from './Config.js'; 13 | 14 | function ChatComponent(props) { 15 | const router = useRouter(); 16 | const [loginState, setLoginState] = useState(null) 17 | const [question, setQuestion] = useState(""); 18 | const [outMsgs, setOutMsgs] = useLocalStorage('chat-out-msgs', []); 19 | const [retMsgs, setRetMsgs] = useLocalStorage('chat-ret-msgs', []); 20 | const [msgId, setMsgId] = useState(''); 21 | const [convId, setConvId] = useState(''); 22 | const [typing, setTyping] = useState(false); 23 | const [answerTS, setAnswerTS] = useState(new Date().valueOf()); 24 | const [answer, setAnswer] = useState(); 25 | const [hasNotice, setHasNotice] = useState(''); 26 | const [abortSignal, setAbortSignal] = useState(null); 27 | const [selectingPrompt, setSelectingPrompt] = useState(false); 28 | const [selectedPrompt, setSelectedPrompt] = useLocalStorage('chat-selected-prompt', null); 29 | 30 | const answerTSRef = useRef(); 31 | answerTSRef.current = answerTS; 32 | 33 | const answerRef = useRef(); 34 | answerRef.current = answer; 35 | 36 | const abortSignalRef = useRef(); 37 | abortSignalRef.current = abortSignal; 38 | 39 | const messagesEndRef = useRef(null) 40 | 41 | const sdkInsta = useMemo(() => { 42 | if (!enableAuth) { 43 | console.log('skip auth'); 44 | return null; 45 | } 46 | return new Cashier({ 47 | // 应用 ID 48 | appId, 49 | appToken, 50 | // domain: 'http://localhost:3333', 51 | pageDomain: 'https://pay.freecharger.cn', 52 | mobile: true, 53 | inPage: true, 54 | root: '#sdk-root' 55 | }); 56 | }, []); 57 | 58 | const getLoginState = useCallback(async () => { 59 | if (!sdkInsta) return; 60 | try { 61 | console.log('get login state...'); 62 | let state = await sdkInsta.getUserInfo(); 63 | console.log('get loginState resp: ', state) 64 | if (!state) { 65 | state = await sdkInsta.login() 66 | } 67 | setLoginState(state); 68 | } catch (error) { 69 | console.error('login state: ', error); 70 | Toast.show({ 71 | content: `初始化失败 - ${error?.message}` 72 | }) 73 | } 74 | }, [sdkInsta]); 75 | 76 | const getLoginTokens = useCallback(async () => { 77 | if (!sdkInsta) return; 78 | let tokens = await sdkInsta.getTokens(); 79 | console.log('get tokens: ', tokens) 80 | return tokens; 81 | }, [sdkInsta]); 82 | 83 | 84 | const scrollToBottom = () => { 85 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) 86 | console.log('scroll to bottom'); 87 | } 88 | 89 | const genRandomMsgId = () => { 90 | return `msg-${new Date().valueOf()}-${Math.floor(Math.random() * 10)}`; 91 | } 92 | 93 | const onChoosePrompt = (val) => { 94 | setSelectedPrompt(val) 95 | Toast.show({ 96 | content: `预设模式更新为 - ${val.label}` 97 | }) 98 | } 99 | 100 | const inputQuestion = val => { 101 | setQuestion(val); 102 | } 103 | 104 | const onmessage = (msgObj) => { 105 | console.log('onmsg - ', msgObj) 106 | setAnswer(lastAns => (lastAns || '') + msgObj); 107 | scrollToBottom() 108 | } 109 | function nextTick() { 110 | return new Promise(resolve => setTimeout(resolve, 0)); 111 | } 112 | const onopen = async () => { 113 | console.log('open', new Date(), new Date(answerTSRef.current)); 114 | setAnswerTS(new Date().valueOf()); 115 | await nextTick(); 116 | console.log('opened', new Date(), new Date(answerTSRef.current)); 117 | } 118 | 119 | const onclose = () => { 120 | console.log('closed ', answerRef.current, new Date()) 121 | if (answerRef.current) { 122 | setRetMsgs([...retMsgs, { id: genRandomMsgId(), msg: answerRef.current, timestamp: answerTSRef.current }]) 123 | setAnswer('') 124 | setTyping(false); 125 | setAnswerTS(new Date().valueOf()); 126 | } 127 | } 128 | const onerror = (message) => { 129 | console.log("error: ", answerRef.current); 130 | if (answerRef.current) { 131 | if (answerRef.current) { 132 | setRetMsgs([...retMsgs, { id: genRandomMsgId(), msg: answerRef.current + '...', timestamp: answerTSRef.current }]) 133 | } 134 | setAnswer('') 135 | setTyping(false); 136 | } else { 137 | console.log('chatGPT error msg: ', message); 138 | throw new Error('ChatGPT 用量饱和,请稍后重试') 139 | } 140 | } 141 | 142 | const directChat = async function (e) { 143 | e.preventDefault(); 144 | if (!question) { 145 | Toast.show({ 146 | content: '请输入有效问题', 147 | }) 148 | return; 149 | } 150 | 151 | if (!loginState && enableAuth) { 152 | Toast.show({ 153 | content: '请先登录' 154 | }) 155 | return; 156 | } 157 | 158 | setQuestion(''); 159 | setOutMsgs([...outMsgs, { id: genRandomMsgId(), msg: question, timestamp: new Date().valueOf() }]) 160 | 161 | setAbortSignal(null); 162 | setTyping(true); 163 | // 向云服务发起调用 164 | try { 165 | const tokens = await getLoginTokens(); 166 | 167 | const callRes = await callBridge({ 168 | data: { 169 | uid: loginState?.id, 170 | at: tokens?.accessToken, 171 | message: question, 172 | parentMessageId: msgId, 173 | conversationId: convId, 174 | prompt: selectedPrompt?.prompt 175 | }, 176 | onmessage, 177 | onopen, 178 | onclose, 179 | onerror, 180 | getSignal: (sig) => { 181 | setAbortSignal(sig); 182 | }, 183 | debug: props.debug 184 | }) 185 | 186 | console.log('client stream result: ', abortSignalRef.current, callRes, answerTSRef.current, new Date(answerTSRef.current)); 187 | const { response, messageId, conversationId } = callRes || {} 188 | 189 | const curMsgId = messageId || genRandomMsgId() 190 | setMsgId(curMsgId); 191 | 192 | if (conversationId) { 193 | setConvId(conversationId); 194 | } 195 | 196 | setTyping(false); 197 | setRetMsgs([...retMsgs, { id: curMsgId, msg: response, timestamp: answerTSRef.current }]) 198 | setAnswer('') 199 | setAnswerTS(Date.now()) 200 | 201 | return callRes; 202 | } catch (error) { 203 | console.error('call service error: ', error); 204 | setRetMsgs([...retMsgs, { id: genRandomMsgId(), msg: error?.message || '在线人数太多啦,请稍后再试', timestamp: new Date().valueOf() }]) 205 | setTyping(false); 206 | } 207 | } 208 | 209 | const gotoAccount = (e) => { 210 | e.preventDefault(); 211 | console.log('goto account page ....') 212 | if (enableAuth) { 213 | router.push('/account') 214 | } 215 | } 216 | 217 | const deleteItem = (id, type) => { 218 | if (!id) { 219 | Toast.show({ 220 | icon: 'fail', 221 | content: '该对话已过期', 222 | }) 223 | return; 224 | } 225 | Modal.confirm({ 226 | content: '确认删除该对话', 227 | confirmText: '删除', 228 | cancelText: '取消', 229 | onCancel: () => { 230 | console.log('close modal') 231 | Modal.clear() 232 | }, 233 | onConfirm: () => { 234 | if (type === 'incoming') { 235 | setRetMsgs([...(retMsgs.filter(item => item.id !== id))]) 236 | } else { 237 | setOutMsgs([...(outMsgs.filter(item => item.id !== id))]) 238 | } 239 | 240 | Modal.clear(); 241 | } 242 | }) 243 | } 244 | const editItem = (txt) => { 245 | setQuestion(txt); 246 | } 247 | 248 | 249 | useEffect(() => { 250 | if (sdkInsta) { 251 | sdkInsta.init() 252 | .then((resp) => { 253 | if (resp) { 254 | // 加载到了操作结果数据,反馈登录/购买结果给用户 255 | console.log('found resp: ', resp); 256 | } 257 | return getLoginState() 258 | }) 259 | } 260 | }, [sdkInsta, getLoginState]); 261 | 262 | useEffect(() => { 263 | setTimeout(() => { 264 | scrollToBottom(); 265 | }, 300) 266 | }, []); 267 | 268 | const onCancelChat = (e) => { 269 | e.preventDefault(); 270 | console.log('=============user===cancel============'); 271 | abortSignalRef.current?.abort(); 272 | // TODO: Send cancel feedback to server 273 | setTyping(false); 274 | } 275 | 276 | return (
277 |
278 |
279 |
280 |
281 |

{loginState?.name?.toUpperCase().slice(0, 2) || 'W'}

282 |
283 |
WebInfra
284 |
285 | 286 |
287 |
288 | { hasNotice ?
289 | { setHasNotice('') }} /> 290 |
: null } 291 |
292 |
293 | { item && (item.type = 'incoming'); return item })} 295 | outMsgs={outMsgs.map(item => { item && (item.type = 'outgoing'); return item })} 296 | userInfo={loginState} 297 | onItemDeleted={deleteItem} 298 | onEdit={editItem} /> 299 |
300 |
301 |
302 |
303 |
304 | { setSelectingPrompt(!selectingPrompt); }}>{ selectedPrompt?.label || '默认模式' } 305 | { selectingPrompt ? { setSelectingPrompt(false) }} /> : null } 306 |
307 |
308 | {/* */} 309 |