diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 480db03e..7583e7a4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,4 +8,4 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@master - - run: npm install && npm run build \ No newline at end of file + - run: cd app && npm install && npm run build \ No newline at end of file diff --git a/.gitignore b/.gitignore index d5f19d89..e5325bc3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,133 @@ -node_modules -package-lock.json +*.sqlite +*.db + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* \ No newline at end of file diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 00000000..8a7a49b8 --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,5 @@ +tasks: + - init: cd webapp && npm install + command: cd webapp && npm run start + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..28943fda --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM node:16-alpine AS build + +RUN addgroup -S app && adduser -S app -G app +RUN mkdir /app && chown app:app /app + +USER app + +WORKDIR /app + +COPY ./app/package.json ./ +COPY ./app/tsconfig.json ./ + +RUN npm install + +COPY ./app/craco.config.js ./craco.config.js +COPY ./app/public ./public +COPY ./app/src ./src + +ENV NODE_ENV=production +ENV REACT_APP_AUTH_PROVIDER=local + +RUN npm run build + +FROM node:16-alpine AS server + +RUN addgroup -S app && adduser -S app -G app + +WORKDIR /app + +COPY ./server/package.json ./ +COPY ./server/tsconfig.json ./ + +RUN npm install + +COPY ./server/src ./src + +COPY --from=build /app/build /app/public \ No newline at end of file diff --git a/README.md b/README.md index f71f908b..bce908f0 100644 --- a/README.md +++ b/README.md @@ -42,31 +42,15 @@ Your API key is stored only on your device and never transmitted to anyone excep ## Running on your own computer -1. First, you'll need to have Git installed on your computer. If you don't have it installed already, you can download it from the official Git website: https://git-scm.com/downloads. - -2. Once Git is installed, you can clone the Chat with GPT repository by running the following command in your terminal or command prompt: - -``` -git clone https://github.com/cogentapps/chat-with-gpt.git -``` - -3. Next, you'll need to have Node.js and npm (Node Package Manager) installed on your computer. You can download the latest version of Node.js from the official Node.js website: https://nodejs.org/en/download/ - -4. Once Node.js is installed, navigate to the root directory of the Chat with GPT repository in your terminal or command prompt and run the following command to install the required dependencies: - -``` -npm install -``` - -This will install all the required dependencies specified in the package.json file. - -5. Finally, run the following command to start the development server: +To run on your own device, you can use Docker: ``` -npm run start +git clone https://github.com/cogentapps/chat-with-gpt +cd chat-with-gpt +docker-compose up ``` -This will start the development server on port 3000. You can then open your web browser and navigate to http://localhost:3000 to view the Chat with GPT webapp running locally on your computer. +Then navigate to http://localhost:3000 to view the app. ## License diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 00000000..7dacd83c --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,4 @@ +node_modules +package-lock.json +.env +build \ No newline at end of file diff --git a/app/craco.config.js b/app/craco.config.js new file mode 100644 index 00000000..8e46507a --- /dev/null +++ b/app/craco.config.js @@ -0,0 +1,36 @@ +const cracoWasm = require("craco-wasm"); + +/* +{ + "plugins": [ + [ + "formatjs", + { + "idInterpolationPattern": "[sha512:contenthash:base64:6]", + "ast": true + } + ] + ] +} +*/ + +module.exports = { + plugins: [ + cracoWasm(), + ], + eslint: { + enable: false + }, + babel: { + plugins: [ + [ + 'formatjs', + { + removeDefaultMessage: false, + idInterpolationPattern: '[sha512:contenthash:base64:6]', + ast: true + } + ] + ] + } +} \ No newline at end of file diff --git a/package.json b/app/package.json similarity index 71% rename from package.json rename to app/package.json index dad3e6ca..aa564926 100644 --- a/package.json +++ b/app/package.json @@ -1,7 +1,8 @@ { - "name": "chat-with-gpt", - "version": "0.1.1", + "name": "Chat with GPT", + "version": "0.2.0", "dependencies": { + "@auth0/auth0-spa-js": "^2.0.4", "@emotion/css": "^11.10.6", "@emotion/styled": "^11.10.6", "@mantine/core": "^5.10.5", @@ -10,20 +11,8 @@ "@mantine/notifications": "^5.10.5", "@mantine/spotlight": "^5.10.5", "@reduxjs/toolkit": "^1.9.3", - "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^13.4.0", - "@testing-library/user-event": "^13.5.0", - "@types/jest": "^27.5.2", - "@types/natural": "^5.1.2", - "@types/node": "^16.18.13", - "@types/react": "^18.0.28", - "@types/react-dom": "^18.0.11", - "@types/react-helmet": "^6.1.6", - "@types/react-syntax-highlighter": "^15.5.6", - "@types/uuid": "^9.0.1", "broadcast-channel": "^4.20.2", - "craco": "^0.0.3", - "craco-wasm": "^0.0.1", + "csv": "^6.2.8", "expiry-set": "^1.0.0", "idb-keyval": "^6.2.0", "jshashes": "^1.0.8", @@ -32,31 +21,31 @@ "minisearch": "^6.0.1", "natural": "^6.2.0", "openai": "^3.2.1", + "papaparse": "^5.4.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-helmet": "^6.1.0", + "react-intl": "^6.2.10", "react-markdown": "^8.0.5", "react-redux": "^8.0.5", "react-router-dom": "^6.8.2", - "react-scripts": "5.0.1", "react-syntax-highlighter": "^15.5.0", "redux-persist": "^6.0.0", "rehype-katex": "^6.0.2", "remark-gfm": "^3.0.1", "remark-math": "^5.1.1", - "sass": "^1.58.3", "sentence-splitter": "^4.2.0", "slugify": "^1.6.5", - "sort-by": "^1.2.0", - "typescript": "^4.9.5", + "sort-by": "^0.0.2", "uuid": "^9.0.0", "web-vitals": "^2.1.4" }, "scripts": { "start": "craco start", - "build": "craco build", + "build": "GENERATE_SOURCEMAP=false craco build", "test": "craco test", - "eject": "craco eject" + "eject": "craco eject", + "extract": "formatjs extract 'src/**/*.ts*' --ignore='**/*.d.ts' --out-file public/lang/en-us.json --format simple --id-interpolation-pattern '[sha512:contenthash:base64:6]'" }, "eslintConfig": { "extends": [ @@ -75,5 +64,28 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "@craco/craco": "^7.1.0", + "@formatjs/cli": "^6.0.4", + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^13.5.0", + "@types/jest": "^27.5.2", + "@types/natural": "^5.1.2", + "@types/node": "^16.18.13", + "@types/papaparse": "^5.3.7", + "@types/react": "^18.0.28", + "@types/react-dom": "^18.0.11", + "@types/react-helmet": "^6.1.6", + "@types/react-intl": "^3.0.0", + "@types/react-syntax-highlighter": "^15.5.6", + "@types/uuid": "^9.0.1", + "babel-plugin-formatjs": "^10.4.0", + "craco-wasm": "^0.0.1", + "http-proxy-middleware": "^2.0.6", + "react-scripts": "^5.0.1", + "sass": "^1.58.3", + "typescript": "^4.9.5" } } diff --git a/public/favicon.ico b/app/public/favicon.ico similarity index 100% rename from public/favicon.ico rename to app/public/favicon.ico diff --git a/app/public/index.html b/app/public/index.html new file mode 100644 index 00000000..0ae71303 --- /dev/null +++ b/app/public/index.html @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + Chat with GPT | Unofficial ChatGPT app + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + +
+
+ + + + \ No newline at end of file diff --git a/app/public/lang/en-us.json b/app/public/lang/en-us.json new file mode 100644 index 00000000..23b4ef1a --- /dev/null +++ b/app/public/lang/en-us.json @@ -0,0 +1,41 @@ +{ + "/OKZrc": "Find your API key here.", + "3T9nRn": "Your API key is stored only on this device and never transmitted to anyone except OpenAI.", + "47FYwb": "Cancel", + "4l6vz1": "Copy", + "6PgVSe": "Regenerate", + "A4iXFN": "Temperature: {temperature, number, ::.0}", + "BdPrnc": "Chat with GPT - Unofficial ChatGPT app", + "BwIZY+": "System Prompt", + "ExZfjk": "Sign in to sync", + "HIqSlE": "Preview voice", + "J3ca41": "Play", + "KKa5Br": "Give ChatGPT a realisic human voice by connecting your ElevenLabs account (preview the available voices below). Click here to sign up.", + "KbaJTs": "Loading audio...", + "L5s+z7": "OpenAI API key usage is billed at a pay-as-you-go rate, separate from your ChatGPT subscription.", + "O83lC6": "Enter a message here...", + "OKhRC6": "Share", + "Q97T+z": "Paste your API key here", + "SRsuWF": "Close navigation", + "UT7Nkj": "New Chat", + "Ua8luY": "Hello, how can I help you today?", + "VL24Xt": "Search your chats", + "X0ha1a": "Save changes", + "Xzm66E": "Connect your OpenAI account to get started", + "c60o5M": "Your OpenAI API Key", + "jkpK/t": "Your ElevenLabs Text-to-Speech API Key (optional)", + "jtu3jt": "You can find your API key by clicking your avatar or initials in the top right of the ElevenLabs website, then clicking Profile. Your API key is stored only on this device and never transmitted to anyone except ElevenLabs.", + "mhtiX2": "Customize system prompt", + "mnJYBQ": "Voice", + "oM3yjO": "Open navigation", + "p556q3": "Copied", + "q/uwLT": "Stop", + "role-chatgpt": "ChatGPT", + "role-system": "System", + "role-user": "You", + "role-user-formal": "User", + "sPtnbA": "The System Prompt is shown to ChatGPT by the "System" before your first message. The '{{ datetime }}' tag is automatically replaced by the current date and time.", + "ss6kle": "Reset to default", + "tZdXp/": "The temperature parameter controls the randomness of the AI's responses. Lower values will make the AI more predictable, while higher values will make it more creative.", + "wEQDC6": "Edit" +} diff --git a/public/logo192.png b/app/public/logo192.png similarity index 100% rename from public/logo192.png rename to app/public/logo192.png diff --git a/public/logo512.png b/app/public/logo512.png similarity index 100% rename from public/logo512.png rename to app/public/logo512.png diff --git a/public/manifest.json b/app/public/manifest.json similarity index 100% rename from public/manifest.json rename to app/public/manifest.json diff --git a/public/robots.txt b/app/public/robots.txt similarity index 100% rename from public/robots.txt rename to app/public/robots.txt diff --git a/app/src/backend.ts b/app/src/backend.ts new file mode 100644 index 00000000..642727cd --- /dev/null +++ b/app/src/backend.ts @@ -0,0 +1,155 @@ +import EventEmitter from 'events'; +import chatManager from './chat-manager'; +import { MessageTree } from './message-tree'; +import { Chat, Message } from './types'; +import { AsyncLoop } from './utils'; + +const endpoint = '/chatapi'; + +export let backend: { + current?: Backend | null +} = {}; + +export interface User { + email?: string; + name?: string; + avatar?: string; +} + +export class Backend extends EventEmitter { + public user: User | null = null; + + private sessionInterval = new AsyncLoop(() => this.getSession(), 1000 * 30); + private syncInterval = new AsyncLoop(() => this.sync(), 1000 * 60 * 2); + + public constructor() { + super(); + + backend.current = this; + + this.sessionInterval.start(); + this.syncInterval.start(); + + chatManager.on('messages', async (messages: Message[]) => { + if (!this.isAuthenticated) { + return; + } + await this.post(endpoint + '/messages', { messages }); + }); + + chatManager.on('title', async (id: string, title: string) => { + if (!this.isAuthenticated) { + return; + } + if (!title?.trim()) { + return; + } + await this.post(endpoint + '/title', { id, title }); + }); + } + + public async getSession() { + const wasAuthenticated = this.isAuthenticated; + const session = await this.get(endpoint + '/session'); + if (session?.authenticated) { + this.user = { + email: session.email, + name: session.name, + avatar: session.picture, + }; + } else { + this.user = null; + } + if (wasAuthenticated !== this.isAuthenticated) { + this.emit('authenticated', this.isAuthenticated); + } + } + + public async sync() { + const response = await this.post(endpoint + '/sync', {}); + + for (const chatID of Object.keys(response)) { + try { + const chat = chatManager.chats.get(chatID) || { + id: chatID, + messages: new MessageTree(), + } as Chat; + chat.title = response[chatID].title || chat.title; + chat.messages.addMessages(response[chatID].messages); + chatManager.loadChat(chat); + } catch (e) { + console.error('error loading chat', e); + } + } + + chatManager.emit('update'); + } + + async signIn() { + window.location.href = endpoint + '/login'; + } + + get isAuthenticated() { + return this.user !== null; + } + + async logout() { + window.location.href = endpoint + '/logout'; + } + + async shareChat(chat: Chat): Promise { + try { + const { id } = await this.post(endpoint + '/share', { + ...chat, + messages: chat.messages.serialize(), + }); + if (typeof id === 'string') { + return id; + } + } catch (e) { + console.error(e); + } + return null; + } + + async getSharedChat(id: string): Promise { + const format = process.env.REACT_APP_SHARE_URL || (endpoint + '/share/:id'); + const url = format.replace(':id', id); + try { + const chat = await this.get(url); + if (chat?.messages?.length) { + chat.messages = new MessageTree(chat.messages); + return chat; + } + } catch (e) { + console.error(e); + } + return null; + } + + async get(url: string) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(response.statusText); + } + return response.json(); + } + + async post(url: string, data: any) { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + if (!response.ok) { + throw new Error(response.statusText); + } + return response.json(); + } +} + +if (process.env.REACT_APP_AUTH_PROVIDER) { + new Backend(); +} \ No newline at end of file diff --git a/src/chat-manager.ts b/app/src/chat-manager.ts similarity index 70% rename from src/chat-manager.ts rename to app/src/chat-manager.ts index 083ff9a6..987d0676 100644 --- a/src/chat-manager.ts +++ b/app/src/chat-manager.ts @@ -2,7 +2,7 @@ import { BroadcastChannel } from 'broadcast-channel'; import EventEmitter from 'events'; import MiniSearch, { SearchResult } from 'minisearch' import { v4 as uuidv4 } from 'uuid'; -import { Chat, getOpenAIMessageFromMessage, Message, Parameters, UserSubmittedMessage } from './types'; +import { Chat, deserializeChat, getOpenAIMessageFromMessage, Message, Parameters, serializeChat, UserSubmittedMessage } from './types'; import { MessageTree } from './message-tree'; import { createStreamingChatCompletion } from './openai'; import { createTitle } from './titles'; @@ -17,6 +17,7 @@ export class ChatManager extends EventEmitter { public search = new Search(this.chats); private loaded = false; private changed = false; + private activeReplies = new Map(); constructor() { super(); @@ -28,10 +29,11 @@ export class ChatManager extends EventEmitter { channel.onmessage = (message: { type: 'chat-update', - data: Chat, + data: string, }) => { - const id = message.data.id; - this.chats.set(id, message.data); + const chat = deserializeChat(message.data); + const id = chat.id; + this.chats.set(id, chat); this.emit(id); }; @@ -59,7 +61,7 @@ export class ChatManager extends EventEmitter { this.chats.set(id, chat); this.search.update(chat); - channel.postMessage({ type: 'chat-update', data: chat }); + channel.postMessage({ type: 'chat-update', data: serializeChat(chat) }); return id; } @@ -86,7 +88,7 @@ export class ChatManager extends EventEmitter { this.emit(chat.id); this.emit('messages', [newMessage]); - channel.postMessage({ type: 'chat-update', data: chat }); + channel.postMessage({ type: 'chat-update', data: serializeChat(chat) }); const messages: Message[] = message.parentID ? chat.messages.getMessageChainTo(message.parentID) @@ -127,45 +129,77 @@ export class ChatManager extends EventEmitter { content: '', done: false, }; + this.activeReplies.set(reply.id, reply); chat.messages.addMessage(reply); chat.updated = Date.now(); this.emit(chat.id); - channel.postMessage({ type: 'chat-update', data: chat }); + channel.postMessage({ type: 'chat-update', data: serializeChat(chat) }); const messagesToSend = selectMessagesToSendSafely(messages.map(getOpenAIMessageFromMessage)); - const response = await createStreamingChatCompletion(messagesToSend, requestedParameters); + const { emitter, cancel } = await createStreamingChatCompletion(messagesToSend, requestedParameters); - response.on('error', () => { - if (!reply.content) { - reply.content += "\n\nI'm sorry, I'm having trouble connecting to OpenAI. Please make sure you've entered your OpenAI API key correctly and try again."; - reply.content = reply.content.trim(); - reply.done = true; - chat.messages.updateMessage(reply); - chat.updated = Date.now(); - this.emit(chat.id); - this.emit('messages', [reply]); - channel.postMessage({ type: 'chat-update', data: chat }); + let lastChunkReceivedAt = Date.now(); + + const onError = () => { + if (reply.done) { + return; + } + clearInterval(timer); + cancel(); + reply.content += "\n\nI'm sorry, I'm having trouble connecting to OpenAI. Please make sure you've entered your OpenAI API key correctly and try again."; + reply.content = reply.content.trim(); + reply.done = true; + this.activeReplies.delete(reply.id); + chat.messages.updateMessage(reply); + chat.updated = Date.now(); + this.emit(chat.id); + this.emit('messages', [reply]); + channel.postMessage({ type: 'chat-update', data: serializeChat(chat) }); + }; + + let timer = setInterval(() => { + const sinceLastChunk = Date.now() - lastChunkReceivedAt; + if (sinceLastChunk > 10000 && !reply.done) { + onError(); } - }) + }, 2000); - response.on('data', (data: string) => { + emitter.on('error', () => { + if (!reply.content && !reply.done) { + lastChunkReceivedAt = Date.now(); + onError(); + } + }); + + emitter.on('data', (data: string) => { + if (reply.done) { + cancel(); + return; + } + lastChunkReceivedAt = Date.now(); reply.content = data; chat.messages.updateMessage(reply); this.emit(chat.id); - channel.postMessage({ type: 'chat-update', data: chat }); + channel.postMessage({ type: 'chat-update', data: serializeChat(chat) }); }); - response.on('done', async () => { + emitter.on('done', async () => { + if (reply.done) { + return; + } + clearInterval(timer); + lastChunkReceivedAt = Date.now(); reply.done = true; + this.activeReplies.delete(reply.id); chat.messages.updateMessage(reply); chat.updated = Date.now(); this.emit(chat.id); this.emit('messages', [reply]); this.emit('update'); - channel.postMessage({ type: 'chat-update', data: chat }); + channel.postMessage({ type: 'chat-update', data: serializeChat(chat) }); setTimeout(() => this.search.update(chat), 500); if (!chat.title) { @@ -174,7 +208,7 @@ export class ChatManager extends EventEmitter { this.emit(chat.id); this.emit('title', chat.id, chat.title); this.emit('update'); - channel.postMessage({ type: 'chat-update', data: chat }); + channel.postMessage({ type: 'chat-update', data: serializeChat(chat) }); setTimeout(() => this.search.update(chat), 500); } } @@ -191,6 +225,26 @@ export class ChatManager extends EventEmitter { await idb.set('chats', serialized); } + public cancelReply(id: string) { + const reply = this.activeReplies.get(id); + if (reply) { + reply.done = true; + this.activeReplies.delete(reply.id); + + const chat = this.chats.get(reply.chatID); + const message = chat?.messages.get(id); + if (message) { + message.done = true; + this.emit(reply.chatID); + this.emit('messages', [reply]); + this.emit('update'); + channel.postMessage({ type: 'chat-update', data: serializeChat(chat!) }); + } + } else { + console.log('failed to find reply'); + } + } + private async load() { const serialized = await idb.get('chats'); if (serialized) { @@ -211,6 +265,15 @@ export class ChatManager extends EventEmitter { if (!chat?.id) { return; } + + const existing = this.chats.get(chat.id); + if (existing && existing.title && !chat.title) { + chat.title = existing.title; + } + + chat.created = chat.messages.first?.timestamp || 0; + chat.updated = chat.messages.mostRecentLeaf().timestamp; + this.chats.set(chat.id, chat); this.search.update(chat); this.emit(chat.id); diff --git a/app/src/components/auth/modals.tsx b/app/src/components/auth/modals.tsx new file mode 100644 index 00000000..673a88ce --- /dev/null +++ b/app/src/components/auth/modals.tsx @@ -0,0 +1,100 @@ +import styled from "@emotion/styled"; +import { Button, Modal, PasswordInput, TextInput } from "@mantine/core"; +import { useCallback, useState } from "react"; +import { useAppDispatch, useAppSelector } from "../../store"; +import { closeModals, openLoginModal, openSignupModal, selectModal } from "../../store/ui"; + +const Container = styled.form` + * { + font-family: "Work Sans", sans-serif; + } + + h2 { + font-size: 1.5rem; + font-weight: bold; + } + + .mantine-TextInput-root, .mantine-PasswordInput-root { + margin-top: 1rem; + } + + .mantine-TextInput-root + .mantine-Button-root, + .mantine-PasswordInput-root + .mantine-Button-root { + margin-top: 1.618rem; + } + + .mantine-Button-root { + margin-top: 1rem; + } + + label { + margin-bottom: 0.25rem; + } +`; + +export function LoginModal(props: any) { + const modal = useAppSelector(selectModal); + const dispatch = useAppDispatch(); + + const onClose = useCallback(() => dispatch(closeModals()), [dispatch]); + const onCreateAccountClick = useCallback(() => dispatch(openSignupModal()), [dispatch]); + + return + +

+ Sign in +

+ + + + + +
+
+} + +export function CreateAccountModal(props: any) { + const modal = useAppSelector(selectModal); + const dispatch = useAppDispatch(); + + const onClose = useCallback(() => dispatch(closeModals()), [dispatch]); + const onSignInClick = useCallback(() => dispatch(openLoginModal()), [dispatch]); + + return + +

+ Create an account +

+ + + + + +
+
+} \ No newline at end of file diff --git a/src/components/header.tsx b/app/src/components/header.tsx similarity index 59% rename from src/components/header.tsx rename to app/src/components/header.tsx index 8d777d1a..36ad166e 100644 --- a/src/components/header.tsx +++ b/app/src/components/header.tsx @@ -1,16 +1,18 @@ import styled from '@emotion/styled'; import Helmet from 'react-helmet'; +import { FormattedMessage, useIntl } from 'react-intl'; import { useSpotlight } from '@mantine/spotlight'; -import { Button, ButtonProps } from '@mantine/core'; +import { Burger, Button, ButtonProps } from '@mantine/core'; import { useCallback, useMemo, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import { APP_NAME } from '../values'; import { useAppContext } from '../context'; import { backend } from '../backend'; -import { MenuItem, primaryMenu, secondaryMenu } from '../menus'; +import { MenuItem, secondaryMenu } from '../menus'; import { useAppDispatch, useAppSelector } from '../store'; import { selectOpenAIApiKey } from '../store/api-keys'; import { setTab } from '../store/settings-ui'; +import { selectSidebarOpen, toggleSidebar } from '../store/sidebar'; +import { openLoginModal } from '../store/ui'; const HeaderContainer = styled.div` display: flex; @@ -19,7 +21,12 @@ const HeaderContainer = styled.div` gap: 0.5rem; padding: 0.5rem 1rem; min-height: 2.618rem; - background: rgba(0, 0, 0, 0.2); + background: rgba(0, 0, 0, 0.0); + font-family: "Work Sans", sans-serif; + + &.shaded { + background: rgba(0, 0, 0, 0.2); + } h1 { @media (max-width: 40em) { @@ -51,10 +58,13 @@ const HeaderContainer = styled.div` } } + h2 { + margin: 0 0.5rem; + font-size: 1rem; + } + .spacer { - @media (min-width: 40em) { - flex-grow: 1; - } + flex-grow: 1; } i { @@ -81,10 +91,8 @@ const SubHeaderContainer = styled.div` flex-direction: row; font-family: "Work Sans", sans-serif; line-height: 1.7; - font-size: 80%; opacity: 0.7; - margin: 0.5rem 1rem 0 1rem; - gap: 1rem; + margin: 0.5rem 0.5rem 0 0.5rem; .spacer { flex-grow: 1; @@ -94,16 +102,10 @@ const SubHeaderContainer = styled.div` color: white; } - .fa { - font-size: 90%; - } - .fa + span { - @media (max-width: 40em) { - position: absolute; - left: -9999px; - top: -9999px; - } + position: absolute; + left: -9999px; + top: -9999px; } `; @@ -134,6 +136,14 @@ export default function Header(props: HeaderProps) { const [loading, setLoading] = useState(false); const openAIApiKey = useAppSelector(selectOpenAIApiKey); const dispatch = useAppDispatch(); + const intl = useIntl(); + + const sidebarOpen = useAppSelector(selectSidebarOpen); + const onBurgerClick = useCallback(() => dispatch(toggleSidebar()), [dispatch]); + + const burgerLabel = sidebarOpen + ? intl.formatMessage({ defaultMessage: "Close sidebar" }) + : intl.formatMessage({ defaultMessage: "Open sidebar" }); const onNewChat = useCallback(async () => { setLoading(true); @@ -143,41 +153,50 @@ export default function Header(props: HeaderProps) { const openSettings = useCallback(() => { dispatch(setTab(openAIApiKey ? 'options' : 'user')); - }, [dispatch, openAIApiKey]); + }, [openAIApiKey, dispatch]); const header = useMemo(() => ( - + - {props.title ? `${props.title} - ` : ''}{APP_NAME} - Unofficial ChatGPT app + + {props.title ? `${props.title} - ` : ''} + {intl.formatMessage({ defaultMessage: "Chat with GPT - Unofficial ChatGPT app" })} + - {props.title &&

{props.title}

} - {!props.title && (

-
- {APP_NAME}
- An unofficial ChatGPT app -
-

)} + {!sidebarOpen && } + {context.isHome &&

{intl.formatMessage({ defaultMessage: "Chat with GPT" })}

}
- {backend && !props.share && props.canShare && typeof navigator.share !== 'undefined' && - Share + {backend.current && !props.share && props.canShare && typeof navigator.share !== 'undefined' && + } - {backend && !context.authenticated && ( - backend.current?.signIn()}>Sign in to sync + {backend.current && !context.authenticated && ( + { + if (process.env.REACT_APP_AUTH_PROVIDER !== 'local') { + backend.current?.signIn(); + } else { + dispatch(openLoginModal()); + } + }}> + {chunks} + }} /> + )} - New Chat + - ), [props.title, props.share, props.canShare, props.onShare, openSettings, onNewChat, loading, context.authenticated, spotlight.openSpotlight]); + ), [sidebarOpen, onBurgerClick, props.title, props.share, props.canShare, props.onShare, openSettings, onNewChat, loading, context.authenticated, context.isHome, context.isShare, spotlight.openSpotlight]); return header; } function SubHeaderMenuItem(props: { item: MenuItem }) { return ( - @@ -187,7 +206,6 @@ function SubHeaderMenuItem(props: { item: MenuItem }) { export function SubHeader(props: any) { const elem = useMemo(() => ( - {primaryMenu.map(item => )}
{secondaryMenu.map(item => )} diff --git a/src/components/input.tsx b/app/src/components/input.tsx similarity index 63% rename from src/components/input.tsx rename to app/src/components/input.tsx index b6a6ecd7..2966aac8 100644 --- a/src/components/input.tsx +++ b/app/src/components/input.tsx @@ -1,6 +1,8 @@ import styled from '@emotion/styled'; -import { Button, ActionIcon, Textarea } from '@mantine/core'; +import { Button, ActionIcon, Textarea, Loader } from '@mantine/core'; +import { useMediaQuery } from '@mantine/hooks'; import { useCallback, useMemo } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; import { useLocation } from 'react-router-dom'; import { useAppContext } from '../context'; import { useAppDispatch, useAppSelector } from '../store'; @@ -28,17 +30,6 @@ const Container = styled.div` export declare type OnSubmit = (name?: string) => Promise; -function PaperPlaneSubmitButton(props: { onSubmit: any, disabled?: boolean }) { - return ( - - - - ); -} - export interface MessageInputProps { disabled?: boolean; } @@ -46,9 +37,12 @@ export interface MessageInputProps { export default function MessageInput(props: MessageInputProps) { const temperature = useAppSelector(selectTemperature); const message = useAppSelector(selectMessage); + + const hasVerticalSpace = useMediaQuery('(min-height: 1000px)'); const context = useAppContext(); const dispatch = useAppDispatch(); + const intl = useIntl(); const onCustomizeSystemPromptClick = useCallback(() => dispatch(openSystemPromptPanel()), [dispatch]); const onTemperatureClick = useCallback(() => dispatch(openTemperaturePanel()), [dispatch]); @@ -75,17 +69,31 @@ export default function MessageInput(props: MessageInputProps) { return (
- + {context.generating && (<> + + + )} + {!context.generating && ( + + + + )}
); - }, [onSubmit, props.disabled]); + }, [onSubmit, props.disabled, context.generating]); - const messagesToDisplay = context.currentChat.messagesToDisplay; - const disabled = context.generating - || messagesToDisplay[messagesToDisplay.length - 1]?.role === 'user' - || (messagesToDisplay.length > 0 && !messagesToDisplay[messagesToDisplay.length - 1]?.done); + const disabled = context.generating; const isLandingPage = pathname === '/'; if (context.isShare || (!isLandingPage && !context.id)) { @@ -96,12 +104,13 @@ export default function MessageInput(props: MessageInputProps) {