From bbca7c50a11aa13843b5aaf250859c05189b11cd Mon Sep 17 00:00:00 2001 From: xingsy97 <87063252+xingsy97@users.noreply.github.com> Date: Fri, 25 Aug 2023 15:51:34 +0800 Subject: [PATCH 1/2] Add chatgpt demo for Socket.IO --- .../examples/chatgpt/.gitignore | 4 + .../examples/chatgpt/README.md | 86 +++++ .../examples/chatgpt/babel.config.js | 6 + .../examples/chatgpt/package.json | 40 +++ .../examples/chatgpt/src/client/css/style.css | 173 ++++++++++ .../examples/chatgpt/src/client/js/index.js | 303 ++++++++++++++++++ .../examples/chatgpt/src/server/chatgpt.js | 131 ++++++++ .../examples/chatgpt/src/server/index.js | 101 ++++++ .../examples/chatgpt/src/server/storage.js | 82 +++++ .../examples/chatgpt/src/server/test.js | 27 ++ .../examples/chatgpt/static/index.html | 16 + .../examples/chatgpt/webpack.config.js | 44 +++ 12 files changed, 1013 insertions(+) create mode 100644 experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/.gitignore create mode 100644 experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/README.md create mode 100644 experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/babel.config.js create mode 100644 experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/package.json create mode 100644 experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/src/client/css/style.css create mode 100644 experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/src/client/js/index.js create mode 100644 experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/src/server/chatgpt.js create mode 100644 experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/src/server/index.js create mode 100644 experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/src/server/storage.js create mode 100644 experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/src/server/test.js create mode 100644 experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/static/index.html create mode 100644 experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/webpack.config.js diff --git a/experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/.gitignore b/experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/.gitignore new file mode 100644 index 000000000..8edcd9bd0 --- /dev/null +++ b/experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/.gitignore @@ -0,0 +1,4 @@ +.env +node_modules/ +public/ +sessions/ \ No newline at end of file diff --git a/experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/README.md b/experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/README.md new file mode 100644 index 000000000..849cae2f3 --- /dev/null +++ b/experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/README.md @@ -0,0 +1,86 @@ +# My ChatGPT + +A simple sample written in React.js and node.js to show how to build an OpenAI [ChatGPT](https://chat.openai.com/)-like chat bot using OpenAI chat completion [API](https://platform.openai.com/docs/guides/chat). + +Main features: +1. Dialogue based chat with ChatGPT +2. Generate chat response in a streaming way (like how OpenAI ChatGPT does it) +3. Render chat messages in rich text format (using Markdown) +4. Automatically name the chat session from content +5. Support using system message to customize the chat +6. Multiple chat sessions support +7. Persist chat history at server side +8. Support both native OpenAI API and [Azure OpenAI Service](https://azure.microsoft.com/products/cognitive-services/openai-service) API + +## How to run + +### Use native OpenAI: +You need to have an OpenAI account first. + +1. Go to your OpenAI account, open [API keys](https://platform.openai.com/account/api-keys) page, create a new secret key. +2. Set the key as environment variable: + ``` + export MODE="native" + export OPENAI_API_KEY= + ``` + Or you can save the key into `.env` file: + ``` + MODE="native" + OPENAI_API_KEY= + ``` + +### Use Azure OpenAI Service +1. Go to your Azure OpenAI service resource. +2. Set the environment variable +``` +export MODE="azure" +export AZURE_OPENAI_RESOURCE_NAME= +export AZURE_OPENAI_DEPLOYMENT_NAME= +export AZURE_OPENAI_API_KEY= +``` +Or you can save the key into `.env` file: +``` +MODE="azure" +AZURE_OPENAI_RESOURCE_NAME= +AZURE_OPENAI_DEPLOYMENT_NAME= +AZURE_OPENAI_API_KEY= +``` + +3. Run the following command: + ``` + npm install + npm run build + npm start + ``` + +Then open http://localhost:3000 in your browser to use the app. + +There is also a CLI version where you can play with the chat bot in command line window: +``` +node src/server/test.js +``` + +## Persist chat history + +This sample has a very simple [implementation](src/server/storage.js) to persist the chat history into file system (the files can be found under `sessions` directory), which is only for demo purpose and should not be used in any production environment. You can have your own storage logic by implementing the functions in `Storage` class. + +## Use Azure OpenAI Service + +Azure OpenAI service provides compatible APIs with native OpenAI ones. The main difference is the API endpoint and authentication method, which has already been abstracted into `createAzureOpenAIChat()` and `createOpenAIChat()` functions. To switch to Azure OpenAI Service, simply change to use `createAzureOpenAIChat()` in [index.js](src/server/index.js). + +You also need to set different environment variables: +``` +export AZURE_OPENAI_RESOURCE_NAME= +export AZURE_OPENAI_DEPLOYMENT_NAME= +export AZURE_OPENAI_API_KEY= +``` + +## Use WebSocket to stream messages + +By default this sample uses HTTP request to stream the messages (message from ChatGPT is written into the HTTP response in a streaming way). You may want to use WebSocket to return the messages from server since it's usually considered as more efficient for slow and streaming responses. + +To achieve this, simply change to use `setupWebSocketTransport()` in the constructor of client app. Here I use [Socket.IO](https://socket.io) which is popular javascript library for real-time communication. + +> Socket.IO is not really a WebSocket implementation (it also uses long polling when WebSocket is not available), but in most cases it uses WebSocket since WebSocket is supported everywhere now. + +> No matter which transport you're using, in the backend communication between server and OpenAI service is using [Server Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events), which is not something we can customize. Also this chat bot scenario itself is a request-response model, so there may not be big difference of using WebSocket/Socket.IO. But you may find it useful in other scenarios (e.g. in a multi-user chat room where messages may be broadcasted to all users), so I implemented it here just for the completeness of a technical demo. \ No newline at end of file diff --git a/experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/babel.config.js b/experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/babel.config.js new file mode 100644 index 000000000..10b4879bd --- /dev/null +++ b/experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/babel.config.js @@ -0,0 +1,6 @@ +export default { + presets: [ + ['@babel/preset-env', { targets: { node: 'current' } }], + '@babel/preset-react' + ] +}; \ No newline at end of file diff --git a/experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/package.json b/experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/package.json new file mode 100644 index 000000000..659a7da44 --- /dev/null +++ b/experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/package.json @@ -0,0 +1,40 @@ +{ + "name": "mychatgpt", + "version": "1.0.0", + "description": "", + "main": "index.js", + "type": "module", + "scripts": { + "start": "node src/server/index.js", + "build": "webpack build --mode production", + "dev": "webpack build --mode development", + "watch": "webpack watch --mode development" + }, + "author": "", + "license": "ISC", + "dependencies": { + "@azure/web-pubsub-socket.io": "^1.0.0-beta.5", + "dotenv": "^16.0.3", + "express": "^4.18.2", + "js-yaml": "^4.1.0", + "openai": "^4.0.0-beta.7" + }, + "devDependencies": { + "@babel/preset-env": "^7.20.2", + "@babel/preset-react": "^7.18.6", + "babel-loader": "^9.1.2", + "classnames": "^2.3.2", + "copy-webpack-plugin": "^11.0.0", + "css-loader": "^6.7.3", + "react": "^18.2.0", + "react-animate-height": "^3.1.1", + "react-dom": "^18.2.0", + "react-markdown": "^8.0.6", + "react-syntax-highlighter": "^15.5.0", + "remark-gfm": "^3.0.1", + "socket.io-client": "^4.7.1", + "style-loader": "^3.3.2", + "webpack": "^5.75.0", + "webpack-cli": "^5.0.1" + } +} diff --git a/experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/src/client/css/style.css b/experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/src/client/css/style.css new file mode 100644 index 000000000..941bf0c8b --- /dev/null +++ b/experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/src/client/css/style.css @@ -0,0 +1,173 @@ +body { + position: fixed; + height: 100%; + width: 100%; +} + +#app { + width: 100%; + height: 100%; +} + +@media screen and (max-width: 1200px) { + .limit-width { + width: 100%; + } +} + +@media screen and (min-width: 1200px) { + .limit-width { + width: 1200px; + } +} + +.borderless-button { + border: none; +} + +.limit-width { + margin: auto; + padding: 0 8px; +} + +.sessions { + max-height: calc(100% - 56px); + overflow-y: auto; +} + +.session-title { + display: flex; + height: 40px; +} + +.session-title-content { + color: #666; + cursor: pointer; + margin: auto; +} + +.session { + width: 100%; + color: #666; + cursor: pointer; +} + +.session-content { + height: 56px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.session:hover { + background: #eee !important; +} + +.current-session { + font-weight: bold; +} + +.floating-button { + display: none; + color: #888; +} + +.floating-button:hover { + color: #000; +} + +.welcome-message:hover .floating-button, +.session:hover .floating-button { + display: inline; +} + +.session .btn { + padding: 0; +} + +.update-session-input { + padding-right: 48px; +} + +.delete-session { + width: 100%; +} + +.yes-button { + margin-left: -48px; +} + +.messages { + height: calc(100% - 120px); + overflow-y: auto; +} + +.message { + display: inline-block; + max-width: 90%; + background: #f0f0f0; + border-radius: 0.5em; + color: #444; + box-shadow: 1px 1px 8px #ccc; +} + +.message p:last-child { + margin-bottom: 0; +} + +.local-message .message { + background: #f8f8f8; +} + +.message-header { + font-size: 12px; + font-weight: bold; +} + +.message-content { + text-align: justify; +} + +.local-message { + display: flex; + justify-content: flex-end; +} + +.welcome-window { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; +} + +.welcome-message { + background: #f8f8f8; + border-radius: 0.5em; + width: 50%; + color: #444; + box-shadow: 1px 1px 8px #ddd; +} + +.system-message-input { + resize: none; + overflow: hidden; +} + +.system-message { + padding: 7px 13px; +} + +.input-box { + height: 64px; + display: flex; + align-items: center; +} + +.message-input { + padding-right: 40px; +} + +.send-button { + margin-left: -40px; +} \ No newline at end of file diff --git a/experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/src/client/js/index.js b/experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/src/client/js/index.js new file mode 100644 index 000000000..b410fae2a --- /dev/null +++ b/experimental/sdk/webpubsub-socketio-extension/examples/chatgpt/src/client/js/index.js @@ -0,0 +1,303 @@ +import '../css/style.css'; +import React, { Component, Fragment } from 'react'; +import ReactDOM from 'react-dom/client'; +import cx from 'classnames'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism/index.js'; +import AnimateHeight from 'react-animate-height'; +import { io } from 'socket.io-client'; + +class App extends Component { + defaultSystemMessage = 'You are an AI assistant that helps people find information.'; + + newChat = { + name: 'New chat', + systemMessage: this.defaultSystemMessage + }; + + messages = React.createRef(); + + draft = React.createRef(); + + systemMessage = React.createRef(); + + state = { + sessions: [], + current: this.newChat, + editingSession: undefined, + editSystemMessage: false, + draftSystemMessage: '', + showSessions: false, + messages: [], + input: '' + }; + + constructor(props) { + super(props); + //this.setupHttpTransport(); + this.setupWebSocketTransport(); + } + + async setupWebSocketTransport() { + var endpoint = ""; + var negotiateResponse = await fetch(`/socket.io/negotiate`); + if (!negotiateResponse.ok) { + console.log("Failed to negotiate, status code =", negotiateResponse.status); + return ; + } + negotiateResponse = await negotiateResponse.json(); + + this.socket = io(negotiateResponse["endpoint"], { + path: negotiateResponse["path"], + }) + .on('message', c => { + let m = this.state.messages[this.state.messages.length - 1]; + if (m.from !== 'ChatGPT') this.state.messages.push(m = { from: 'ChatGPT', content: '', date: new Date() }); + m.content += c; + this.setState({}); + }) + .on('error', m => { + this.state.messages.push({ from: 'System', content: m }); + this.setState({}); + }); + this.sendMessage = (id, input) => this.socket.emit('message', id, input); + } + + async switchSession(session) { + if (session.id) { + let res = await fetch(`/chat/${session.id}/messages`); + let history = await res.json(); + this.setState({ + current: session, + editingSession: undefined, + showSessions: false, + messages: history.map(m => ({ + from: m.role === 'user' ? 'me' : 'ChatGPT', + content: m.content, + date: new Date(m.date) + })) + }); + } else this.setState({ + current: this.newChat, + editingSession: undefined, + showSessions: false, + messages: [] + }); + } + + async send() { + let input = this.state.input; + if (input) { + this.state.messages.push({ from: 'me', content: this.state.input, date: new Date() }); + this.setState({ input: '' }); + let current = this.state.current; + if (!current.id) { + let res = await fetch(`/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ systemMessage: current.systemMessage, nameHint: input }) + }); + current = await res.json(); + this.state.sessions.splice(0, 0, current); + this.setState({ current: current }); + } + + await this.sendMessage(current.id, input); + } + } + + async updateSession() { + let session = this.state.editingSession?.session; + let name = this.state.editingSession?.name; + if (session && name) { + await fetch(`/chat/${session.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name }) + }); + session.name = name; + this.setState({ editingSession: undefined }); + } + } + + async deleteSession() { + let session = this.state.editingSession?.session; + if (session) { + await fetch(`/chat/${session.id}`, { method: 'DELETE' }); + this.state.sessions = this.state.sessions.filter(s => s.id !== session.id); + if (session === this.state.current) this.switchSession(this.state.sessions[0] || this.newChat); + else this.setState({}); + } + } + + updateSystemMessage() { + this.state.current.systemMessage = this.state.draftSystemMessage; + this.setState({ editSystemMessage: false }); + } + + renderMarkdown(content) { + return ( + + : {children}; + } + }} + /> + ); + } + + getDate(date) { + let t = `${date.getHours()}:${date.getMinutes().toString().padStart(2, '0')}`; + let d = Math.floor(date.valueOf() / (24 * 3600 * 1000)); + let n = Math.floor(new Date().valueOf() / (24 * 3600 * 1000)); + if (n !== d) t = `${date.getMonth() + 1}/${date.getDate()} ${t}`; + return t; + } + + autoHeight(element) { + element.style.height = "5px"; + element.style.height = element.scrollHeight + "px"; + } + + async componentDidMount() { + let res = await fetch('/chat'); + this.setState({ sessions: (await res.json()).sort((x, y) => y.createdAt - x.createdAt) }); + } + + componentDidUpdate(prevProps, prevState) { + let m = this.messages.current; + if (m) m.scrollTo(0, m.scrollHeight); + if (prevState.current !== this.state.current) this.draft.current?.focus(); + if (!prevState.editSystemMessage && this.state.editSystemMessage && this.systemMessage.current) this.autoHeight(this.systemMessage.current); + } + + renderSession(session, key) { + let inEdit = this.state.editingSession?.session === session; + let updating = this.state.editingSession?.action === 'update'; + return ( +
{ if (!inEdit) this.switchSession(session) }}> +
+ {inEdit ? + + {updating ? + e.stopPropagation()} + onChange={e => { this.state.editingSession.name = e.target.value; this.setState({}); }} + onKeyDown={e => { if (e.key === 'Enter') this.updateSession(); }} /> + : +
{`Delete '${session.name}'?`}
+ } + + +
+ : + +
+ + {session.name} +
+ {session.id && +
+ { this.setState({ editingSession: { session: session, action: 'update', name: session.name } }); e.stopPropagation(); }} /> + { this.setState({ editingSession: { session: session, action: 'delete' } }); e.stopPropagation(); }} /> +
+ } +
+ } +
+
+ ); + } + + render() { + let messages = this.state.messages; + if (messages.length > 0 && this.state.current.systemMessage) messages = [{ from: 'System', content: this.state.current.systemMessage }].concat(messages); + return ( + + +
+ + {[this.newChat].concat(this.state.sessions).map((s, i) => this.renderSession(s, i))} + +
+
+ {messages.length > 0 ? + messages.map((m, i) => +
+
+
{m.from !== 'me' ? m.from : ''} {m.date && this.getDate(m.date)}
+
{this.renderMarkdown(m.content)}
+
+
+ ) : +
+
+
ChatGPT
+
Start chatting with ChatGPT by entering your questions. You can also customize your chat by editing the system message below.
+
+ System message + {!this.state.editSystemMessage && + + this.setState({ editSystemMessage: true, draftSystemMessage: this.state.current.systemMessage })} /> + {this.state.current.systemMessage !== this.defaultSystemMessage && + { this.state.current.systemMessage = this.defaultSystemMessage; this.setState({}); }} /> + } + + } +
+ {this.state.editSystemMessage ? +