main
Cogent Apps 2023-03-14 11:00:40 +00:00
parent 4a5e8c9e16
commit 645b66b988
104 changed files with 11064 additions and 1565 deletions

View File

@ -8,4 +8,4 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@master - uses: actions/checkout@master
- run: npm install && npm run build - run: cd app && npm install && npm run build

135
.gitignore vendored
View File

@ -1,2 +1,133 @@
node_modules *.sqlite
package-lock.json *.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.*

5
.gitpod.yml 100644
View File

@ -0,0 +1,5 @@
tasks:
- init: cd webapp && npm install
command: cd webapp && npm run start

37
Dockerfile 100644
View File

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

View File

@ -42,31 +42,15 @@ Your API key is stored only on your device and never transmitted to anyone excep
## Running on your own computer ## 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. To run on your own device, you can use Docker:
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 git clone https://github.com/cogentapps/chat-with-gpt
cd chat-with-gpt
docker-compose up
``` ```
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/ Then navigate to http://localhost:3000 to view the app.
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:
```
npm run start
```
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.
## License ## License

4
app/.gitignore vendored 100644
View File

@ -0,0 +1,4 @@
node_modules
package-lock.json
.env
build

View File

@ -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
}
]
]
}
}

View File

@ -1,7 +1,8 @@
{ {
"name": "chat-with-gpt", "name": "Chat with GPT",
"version": "0.1.1", "version": "0.2.0",
"dependencies": { "dependencies": {
"@auth0/auth0-spa-js": "^2.0.4",
"@emotion/css": "^11.10.6", "@emotion/css": "^11.10.6",
"@emotion/styled": "^11.10.6", "@emotion/styled": "^11.10.6",
"@mantine/core": "^5.10.5", "@mantine/core": "^5.10.5",
@ -10,20 +11,8 @@
"@mantine/notifications": "^5.10.5", "@mantine/notifications": "^5.10.5",
"@mantine/spotlight": "^5.10.5", "@mantine/spotlight": "^5.10.5",
"@reduxjs/toolkit": "^1.9.3", "@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", "broadcast-channel": "^4.20.2",
"craco": "^0.0.3", "csv": "^6.2.8",
"craco-wasm": "^0.0.1",
"expiry-set": "^1.0.0", "expiry-set": "^1.0.0",
"idb-keyval": "^6.2.0", "idb-keyval": "^6.2.0",
"jshashes": "^1.0.8", "jshashes": "^1.0.8",
@ -32,31 +21,31 @@
"minisearch": "^6.0.1", "minisearch": "^6.0.1",
"natural": "^6.2.0", "natural": "^6.2.0",
"openai": "^3.2.1", "openai": "^3.2.1",
"papaparse": "^5.4.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-intl": "^6.2.10",
"react-markdown": "^8.0.5", "react-markdown": "^8.0.5",
"react-redux": "^8.0.5", "react-redux": "^8.0.5",
"react-router-dom": "^6.8.2", "react-router-dom": "^6.8.2",
"react-scripts": "5.0.1",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"rehype-katex": "^6.0.2", "rehype-katex": "^6.0.2",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"remark-math": "^5.1.1", "remark-math": "^5.1.1",
"sass": "^1.58.3",
"sentence-splitter": "^4.2.0", "sentence-splitter": "^4.2.0",
"slugify": "^1.6.5", "slugify": "^1.6.5",
"sort-by": "^1.2.0", "sort-by": "^0.0.2",
"typescript": "^4.9.5",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },
"scripts": { "scripts": {
"start": "craco start", "start": "craco start",
"build": "craco build", "build": "GENERATE_SOURCEMAP=false craco build",
"test": "craco test", "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": { "eslintConfig": {
"extends": [ "extends": [
@ -75,5 +64,28 @@
"last 1 firefox version", "last 1 firefox version",
"last 1 safari 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"
} }
} }

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<!-- mobile app viewport -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site created using create-react-app" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Chat with GPT | Unofficial ChatGPT app</title>
<link rel="stylesheet" media="all" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.2/css/all.min.css" />
<link rel="stylesheet" media="all" href="https://fonts.googleapis.com/css?family=Open+Sans:100,400,300,500,700,800" />
<link rel="stylesheet" media="all" href="https://fonts.googleapis.com/css?family=Fira+Code:100,400,300,500,700,800" />
<link href="https://fonts.googleapis.com/css?family=Work+Sans:300,400,500,600,700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css"
integrity="sha384-Xi8rHCmBmhbuyyhbI88391ZKP2dmfnOl4rT9ZfRI7mLTdk1wblIUnrIq35nqwEvC" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tailwindcss/typography@0.4.1/dist/typography.min.css" />
<script defer data-domain="chatwithgpt.netlify.app" src="https://plausible.io/js/script.js"></script>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-BC7CJFCZHG"></script>
<script>
if (window.location.hostname.includes('chatwithgpt.netlify.app')) {
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', 'G-BC7CJFCZHG');
}
</script>
<style>
body {
background: #292933;
overflow: hidden;
}
#loader {
display: flex;
justify-content: center;
align-items: center;
width: 100vw;
height: 90vh;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root">
<div id="loader">
<svg width="18px" height="4.5px" viewBox="0 0 120 30" xmlns="http://www.w3.org/2000/svg" fill="#1971c2"
class="mantine-1avyp1d" role="presentation">
<circle cx="15" cy="15" r="15">
<animate attributeName="r" from="15" to="15" begin="0s" dur="0.8s" values="15;9;15" calcMode="linear"
repeatCount="indefinite"></animate>
<animate attributeName="fill-opacity" from="1" to="1" begin="0s" dur="0.8s" values="1;.5;1" calcMode="linear"
repeatCount="indefinite"></animate>
</circle>
<circle cx="60" cy="15" r="9" fill-opacity="0.3">
<animate attributeName="r" from="9" to="9" begin="0s" dur="0.8s" values="9;15;9" calcMode="linear"
repeatCount="indefinite"></animate>
<animate attributeName="fill-opacity" from="0.5" to="0.5" begin="0s" dur="0.8s" values=".5;1;.5"
calcMode="linear" repeatCount="indefinite"></animate>
</circle>
<circle cx="105" cy="15" r="15">
<animate attributeName="r" from="15" to="15" begin="0s" dur="0.8s" values="15;9;15" calcMode="linear"
repeatCount="indefinite"></animate>
<animate attributeName="fill-opacity" from="1" to="1" begin="0s" dur="0.8s" values="1;.5;1" calcMode="linear"
repeatCount="indefinite"></animate>
</circle>
</svg>
</div>
</div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@ -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 <h>to sync</h>",
"HIqSlE": "Preview voice",
"J3ca41": "Play",
"KKa5Br": "Give ChatGPT a realisic human voice by connecting your ElevenLabs account (preview the available voices below). <a>Click here to sign up.</a>",
"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 &quot;System&quot; before your first message. The <code>'{{ datetime }}'</code> 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"
}

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

155
app/src/backend.ts 100644
View File

@ -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<string | null> {
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<Chat | null> {
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();
}

View File

@ -2,7 +2,7 @@ import { BroadcastChannel } from 'broadcast-channel';
import EventEmitter from 'events'; import EventEmitter from 'events';
import MiniSearch, { SearchResult } from 'minisearch' import MiniSearch, { SearchResult } from 'minisearch'
import { v4 as uuidv4 } from 'uuid'; 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 { MessageTree } from './message-tree';
import { createStreamingChatCompletion } from './openai'; import { createStreamingChatCompletion } from './openai';
import { createTitle } from './titles'; import { createTitle } from './titles';
@ -17,6 +17,7 @@ export class ChatManager extends EventEmitter {
public search = new Search(this.chats); public search = new Search(this.chats);
private loaded = false; private loaded = false;
private changed = false; private changed = false;
private activeReplies = new Map<string, Message>();
constructor() { constructor() {
super(); super();
@ -28,10 +29,11 @@ export class ChatManager extends EventEmitter {
channel.onmessage = (message: { channel.onmessage = (message: {
type: 'chat-update', type: 'chat-update',
data: Chat, data: string,
}) => { }) => {
const id = message.data.id; const chat = deserializeChat(message.data);
this.chats.set(id, message.data); const id = chat.id;
this.chats.set(id, chat);
this.emit(id); this.emit(id);
}; };
@ -59,7 +61,7 @@ export class ChatManager extends EventEmitter {
this.chats.set(id, chat); this.chats.set(id, chat);
this.search.update(chat); this.search.update(chat);
channel.postMessage({ type: 'chat-update', data: chat }); channel.postMessage({ type: 'chat-update', data: serializeChat(chat) });
return id; return id;
} }
@ -86,7 +88,7 @@ export class ChatManager extends EventEmitter {
this.emit(chat.id); this.emit(chat.id);
this.emit('messages', [newMessage]); this.emit('messages', [newMessage]);
channel.postMessage({ type: 'chat-update', data: chat }); channel.postMessage({ type: 'chat-update', data: serializeChat(chat) });
const messages: Message[] = message.parentID const messages: Message[] = message.parentID
? chat.messages.getMessageChainTo(message.parentID) ? chat.messages.getMessageChainTo(message.parentID)
@ -127,45 +129,77 @@ export class ChatManager extends EventEmitter {
content: '', content: '',
done: false, done: false,
}; };
this.activeReplies.set(reply.id, reply);
chat.messages.addMessage(reply); chat.messages.addMessage(reply);
chat.updated = Date.now(); chat.updated = Date.now();
this.emit(chat.id); 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 messagesToSend = selectMessagesToSendSafely(messages.map(getOpenAIMessageFromMessage));
const response = await createStreamingChatCompletion(messagesToSend, requestedParameters); const { emitter, cancel } = await createStreamingChatCompletion(messagesToSend, requestedParameters);
response.on('error', () => { let lastChunkReceivedAt = Date.now();
if (!reply.content) {
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 += "\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.content = reply.content.trim();
reply.done = true; reply.done = true;
this.activeReplies.delete(reply.id);
chat.messages.updateMessage(reply); chat.messages.updateMessage(reply);
chat.updated = Date.now(); chat.updated = Date.now();
this.emit(chat.id); this.emit(chat.id);
this.emit('messages', [reply]); this.emit('messages', [reply]);
channel.postMessage({ type: 'chat-update', data: chat }); channel.postMessage({ type: 'chat-update', data: serializeChat(chat) });
} };
})
response.on('data', (data: string) => { let timer = setInterval(() => {
const sinceLastChunk = Date.now() - lastChunkReceivedAt;
if (sinceLastChunk > 10000 && !reply.done) {
onError();
}
}, 2000);
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; reply.content = data;
chat.messages.updateMessage(reply); chat.messages.updateMessage(reply);
this.emit(chat.id); 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; reply.done = true;
this.activeReplies.delete(reply.id);
chat.messages.updateMessage(reply); chat.messages.updateMessage(reply);
chat.updated = Date.now(); chat.updated = Date.now();
this.emit(chat.id); this.emit(chat.id);
this.emit('messages', [reply]); this.emit('messages', [reply]);
this.emit('update'); this.emit('update');
channel.postMessage({ type: 'chat-update', data: chat }); channel.postMessage({ type: 'chat-update', data: serializeChat(chat) });
setTimeout(() => this.search.update(chat), 500); setTimeout(() => this.search.update(chat), 500);
if (!chat.title) { if (!chat.title) {
@ -174,7 +208,7 @@ export class ChatManager extends EventEmitter {
this.emit(chat.id); this.emit(chat.id);
this.emit('title', chat.id, chat.title); this.emit('title', chat.id, chat.title);
this.emit('update'); this.emit('update');
channel.postMessage({ type: 'chat-update', data: chat }); channel.postMessage({ type: 'chat-update', data: serializeChat(chat) });
setTimeout(() => this.search.update(chat), 500); setTimeout(() => this.search.update(chat), 500);
} }
} }
@ -191,6 +225,26 @@ export class ChatManager extends EventEmitter {
await idb.set('chats', serialized); 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() { private async load() {
const serialized = await idb.get('chats'); const serialized = await idb.get('chats');
if (serialized) { if (serialized) {
@ -211,6 +265,15 @@ export class ChatManager extends EventEmitter {
if (!chat?.id) { if (!chat?.id) {
return; 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.chats.set(chat.id, chat);
this.search.update(chat); this.search.update(chat);
this.emit(chat.id); this.emit(chat.id);

View File

@ -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 <Modal opened={modal === 'login'} onClose={onClose} withCloseButton={false}>
<Container action="/chatapi/login" method="post">
<h2>
Sign in
</h2>
<input type="hidden" name="redirect_url" value={window.location.href} />
<TextInput label="Email address"
name="username"
placeholder="Enter your email address"
type="email"
required />
<PasswordInput label="Password"
name="password"
placeholder="Enter your password"
maxLength={500}
required />
<Button fullWidth type="submit">
Sign in
</Button>
<Button fullWidth variant="subtle" onClick={onCreateAccountClick}>
Or create an account
</Button>
</Container>
</Modal>
}
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 <Modal opened={modal === 'signup'} onClose={onClose} withCloseButton={false}>
<Container action="/chatapi/register" method="post">
<h2>
Create an account
</h2>
<input type="hidden" name="redirect_url" value={window.location.href} />
<TextInput label="Email address"
name="username"
placeholder="Enter your email address"
type="email"
required />
<PasswordInput label="Password"
name="password"
placeholder="Enter your password"
minLength={6}
maxLength={500}
required />
<Button fullWidth type="submit">
Sign up
</Button>
<Button fullWidth variant="subtle" onClick={onSignInClick}>
Or sign in to an existing account
</Button>
</Container>
</Modal>
}

View File

@ -1,16 +1,18 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import Helmet from 'react-helmet'; import Helmet from 'react-helmet';
import { FormattedMessage, useIntl } from 'react-intl';
import { useSpotlight } from '@mantine/spotlight'; 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 { useCallback, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { APP_NAME } from '../values';
import { useAppContext } from '../context'; import { useAppContext } from '../context';
import { backend } from '../backend'; import { backend } from '../backend';
import { MenuItem, primaryMenu, secondaryMenu } from '../menus'; import { MenuItem, secondaryMenu } from '../menus';
import { useAppDispatch, useAppSelector } from '../store'; import { useAppDispatch, useAppSelector } from '../store';
import { selectOpenAIApiKey } from '../store/api-keys'; import { selectOpenAIApiKey } from '../store/api-keys';
import { setTab } from '../store/settings-ui'; import { setTab } from '../store/settings-ui';
import { selectSidebarOpen, toggleSidebar } from '../store/sidebar';
import { openLoginModal } from '../store/ui';
const HeaderContainer = styled.div` const HeaderContainer = styled.div`
display: flex; display: flex;
@ -19,7 +21,12 @@ const HeaderContainer = styled.div`
gap: 0.5rem; gap: 0.5rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
min-height: 2.618rem; min-height: 2.618rem;
background: rgba(0, 0, 0, 0.0);
font-family: "Work Sans", sans-serif;
&.shaded {
background: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.2);
}
h1 { h1 {
@media (max-width: 40em) { @media (max-width: 40em) {
@ -51,10 +58,13 @@ const HeaderContainer = styled.div`
} }
} }
.spacer { h2 {
@media (min-width: 40em) { margin: 0 0.5rem;
flex-grow: 1; font-size: 1rem;
} }
.spacer {
flex-grow: 1;
} }
i { i {
@ -81,10 +91,8 @@ const SubHeaderContainer = styled.div`
flex-direction: row; flex-direction: row;
font-family: "Work Sans", sans-serif; font-family: "Work Sans", sans-serif;
line-height: 1.7; line-height: 1.7;
font-size: 80%;
opacity: 0.7; opacity: 0.7;
margin: 0.5rem 1rem 0 1rem; margin: 0.5rem 0.5rem 0 0.5rem;
gap: 1rem;
.spacer { .spacer {
flex-grow: 1; flex-grow: 1;
@ -94,17 +102,11 @@ const SubHeaderContainer = styled.div`
color: white; color: white;
} }
.fa {
font-size: 90%;
}
.fa + span { .fa + span {
@media (max-width: 40em) {
position: absolute; position: absolute;
left: -9999px; left: -9999px;
top: -9999px; top: -9999px;
} }
}
`; `;
function HeaderButton(props: ButtonProps & { icon?: string, onClick?: any, children?: any }) { function HeaderButton(props: ButtonProps & { icon?: string, onClick?: any, children?: any }) {
@ -134,6 +136,14 @@ export default function Header(props: HeaderProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const openAIApiKey = useAppSelector(selectOpenAIApiKey); const openAIApiKey = useAppSelector(selectOpenAIApiKey);
const dispatch = useAppDispatch(); 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 () => { const onNewChat = useCallback(async () => {
setLoading(true); setLoading(true);
@ -143,41 +153,50 @@ export default function Header(props: HeaderProps) {
const openSettings = useCallback(() => { const openSettings = useCallback(() => {
dispatch(setTab(openAIApiKey ? 'options' : 'user')); dispatch(setTab(openAIApiKey ? 'options' : 'user'));
}, [dispatch, openAIApiKey]); }, [openAIApiKey, dispatch]);
const header = useMemo(() => ( const header = useMemo(() => (
<HeaderContainer> <HeaderContainer className={context.isHome ? 'shaded' : ''}>
<Helmet> <Helmet>
<title>{props.title ? `${props.title} - ` : ''}{APP_NAME} - Unofficial ChatGPT app</title> <title>
{props.title ? `${props.title} - ` : ''}
{intl.formatMessage({ defaultMessage: "Chat with GPT - Unofficial ChatGPT app" })}
</title>
</Helmet> </Helmet>
{props.title && <h1>{props.title}</h1>} {!sidebarOpen && <Burger opened={sidebarOpen} onClick={onBurgerClick} aria-label={burgerLabel} transitionDuration={0} />}
{!props.title && (<h1> {context.isHome && <h2>{intl.formatMessage({ defaultMessage: "Chat with GPT" })}</h2>}
<div>
<strong>{APP_NAME}</strong><br />
<span>An unofficial ChatGPT app</span>
</div>
</h1>)}
<div className="spacer" /> <div className="spacer" />
<HeaderButton icon="search" onClick={spotlight.openSpotlight} /> <HeaderButton icon="search" onClick={spotlight.openSpotlight} />
<HeaderButton icon="gear" onClick={openSettings} /> <HeaderButton icon="gear" onClick={openSettings} />
{backend && !props.share && props.canShare && typeof navigator.share !== 'undefined' && <HeaderButton icon="share" onClick={props.onShare}> {backend.current && !props.share && props.canShare && typeof navigator.share !== 'undefined' && <HeaderButton icon="share" onClick={props.onShare}>
Share <FormattedMessage defaultMessage="Share" />
</HeaderButton>} </HeaderButton>}
{backend && !context.authenticated && ( {backend.current && !context.authenticated && (
<HeaderButton onClick={() => backend.current?.signIn()}>Sign in <span className="hide-on-mobile">to sync</span></HeaderButton> <HeaderButton onClick={() => {
if (process.env.REACT_APP_AUTH_PROVIDER !== 'local') {
backend.current?.signIn();
} else {
dispatch(openLoginModal());
}
}}>
<FormattedMessage defaultMessage="Sign in <h>to sync</h>"
values={{
h: (chunks: any) => <span className="hide-on-mobile">{chunks}</span>
}} />
</HeaderButton>
)} )}
<HeaderButton icon="plus" onClick={onNewChat} loading={loading} variant="light"> <HeaderButton icon="plus" onClick={onNewChat} loading={loading} variant="light">
New Chat <FormattedMessage defaultMessage="New Chat" />
</HeaderButton> </HeaderButton>
</HeaderContainer> </HeaderContainer>
), [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; return header;
} }
function SubHeaderMenuItem(props: { item: MenuItem }) { function SubHeaderMenuItem(props: { item: MenuItem }) {
return ( return (
<Button variant="light" size="xs" compact component={Link} to={props.item.link} key={props.item.link}> <Button variant="subtle" size="sm" compact component={Link} to={props.item.link} target="_blank" key={props.item.link}>
{props.item.icon && <i className={'fa fa-' + props.item.icon} />} {props.item.icon && <i className={'fa fa-' + props.item.icon} />}
<span>{props.item.label}</span> <span>{props.item.label}</span>
</Button> </Button>
@ -187,7 +206,6 @@ function SubHeaderMenuItem(props: { item: MenuItem }) {
export function SubHeader(props: any) { export function SubHeader(props: any) {
const elem = useMemo(() => ( const elem = useMemo(() => (
<SubHeaderContainer> <SubHeaderContainer>
{primaryMenu.map(item => <SubHeaderMenuItem item={item} key={item.link} />)}
<div className="spacer" /> <div className="spacer" />
{secondaryMenu.map(item => <SubHeaderMenuItem item={item} key={item.link} />)} {secondaryMenu.map(item => <SubHeaderMenuItem item={item} key={item.link} />)}
</SubHeaderContainer> </SubHeaderContainer>

View File

@ -1,6 +1,8 @@
import styled from '@emotion/styled'; 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 { useCallback, useMemo } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useAppContext } from '../context'; import { useAppContext } from '../context';
import { useAppDispatch, useAppSelector } from '../store'; import { useAppDispatch, useAppSelector } from '../store';
@ -28,17 +30,6 @@ const Container = styled.div`
export declare type OnSubmit = (name?: string) => Promise<boolean>; export declare type OnSubmit = (name?: string) => Promise<boolean>;
function PaperPlaneSubmitButton(props: { onSubmit: any, disabled?: boolean }) {
return (
<ActionIcon size="sm"
disabled={props.disabled}
loading={props.disabled}
onClick={props.onSubmit}>
<i className="fa fa-paper-plane" style={{ fontSize: '90%' }} />
</ActionIcon>
);
}
export interface MessageInputProps { export interface MessageInputProps {
disabled?: boolean; disabled?: boolean;
} }
@ -47,8 +38,11 @@ export default function MessageInput(props: MessageInputProps) {
const temperature = useAppSelector(selectTemperature); const temperature = useAppSelector(selectTemperature);
const message = useAppSelector(selectMessage); const message = useAppSelector(selectMessage);
const hasVerticalSpace = useMediaQuery('(min-height: 1000px)');
const context = useAppContext(); const context = useAppContext();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl();
const onCustomizeSystemPromptClick = useCallback(() => dispatch(openSystemPromptPanel()), [dispatch]); const onCustomizeSystemPromptClick = useCallback(() => dispatch(openSystemPromptPanel()), [dispatch]);
const onTemperatureClick = useCallback(() => dispatch(openTemperaturePanel()), [dispatch]); const onTemperatureClick = useCallback(() => dispatch(openTemperaturePanel()), [dispatch]);
@ -75,17 +69,31 @@ export default function MessageInput(props: MessageInputProps) {
return ( return (
<div style={{ <div style={{
opacity: '0.8', opacity: '0.8',
paddingRight: '0.4rem', paddingRight: '0.5rem',
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
width: '100%',
}}> }}>
<PaperPlaneSubmitButton onSubmit={onSubmit} disabled={props.disabled} /> {context.generating && (<>
<Button variant="subtle" size="xs" compact onClick={() => {
context.chat.cancelReply(context.currentChat.leaf!.id);
}}>
<FormattedMessage defaultMessage={"Cancel"} />
</Button>
<Loader size="xs" style={{ padding: '0 0.8rem 0 0.5rem' }} />
</>)}
{!context.generating && (
<ActionIcon size="xl"
onClick={onSubmit}>
<i className="fa fa-paper-plane" style={{ fontSize: '90%' }} />
</ActionIcon>
)}
</div> </div>
); );
}, [onSubmit, props.disabled]); }, [onSubmit, props.disabled, context.generating]);
const messagesToDisplay = context.currentChat.messagesToDisplay; const disabled = context.generating;
const disabled = context.generating
|| messagesToDisplay[messagesToDisplay.length - 1]?.role === 'user'
|| (messagesToDisplay.length > 0 && !messagesToDisplay[messagesToDisplay.length - 1]?.done);
const isLandingPage = pathname === '/'; const isLandingPage = pathname === '/';
if (context.isShare || (!isLandingPage && !context.id)) { if (context.isShare || (!isLandingPage && !context.id)) {
@ -96,12 +104,13 @@ export default function MessageInput(props: MessageInputProps) {
<div className="inner"> <div className="inner">
<Textarea disabled={props.disabled || disabled} <Textarea disabled={props.disabled || disabled}
autosize autosize
minRows={3} minRows={(hasVerticalSpace || context.isHome) ? 3 : 2}
maxRows={12} maxRows={12}
placeholder={"Enter a message here..."} placeholder={intl.formatMessage({ defaultMessage: "Enter a message here..." })}
value={message} value={message}
onChange={onChange} onChange={onChange}
rightSection={rightSection} rightSection={rightSection}
rightSectionWidth={context.generating ? 100 : 55}
onKeyDown={onKeyDown} /> onKeyDown={onKeyDown} />
<div> <div>
<Button variant="subtle" <Button variant="subtle"
@ -109,14 +118,19 @@ export default function MessageInput(props: MessageInputProps) {
size="xs" size="xs"
compact compact
onClick={onCustomizeSystemPromptClick}> onClick={onCustomizeSystemPromptClick}>
<span>Customize system prompt</span> <span>
<FormattedMessage defaultMessage={"Customize system prompt"} />
</span>
</Button> </Button>
<Button variant="subtle" <Button variant="subtle"
className="settings-button" className="settings-button"
size="xs" size="xs"
compact compact
onClick={onTemperatureClick}> onClick={onTemperatureClick}>
<span>Temperature: {temperature.toFixed(1)}</span> <span>
<FormattedMessage defaultMessage="Temperature: {temperature, number, ::.0}"
values={{ temperature }} />
</span>
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -6,6 +6,7 @@ import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex' import rehypeKatex from 'rehype-katex'
import { Button, CopyButton } from '@mantine/core'; import { Button, CopyButton } from '@mantine/core';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
export interface MarkdownProps { export interface MarkdownProps {
content: string; content: string;
@ -13,6 +14,8 @@ export interface MarkdownProps {
} }
export function Markdown(props: MarkdownProps) { export function Markdown(props: MarkdownProps) {
const intl = useIntl();
const classes = useMemo(() => { const classes = useMemo(() => {
const classes = ['prose', 'dark:prose-invert']; const classes = ['prose', 'dark:prose-invert'];
@ -36,7 +39,7 @@ export function Markdown(props: MarkdownProps) {
{({ copy, copied }) => ( {({ copy, copied }) => (
<Button variant="subtle" size="sm" compact onClick={copy}> <Button variant="subtle" size="sm" compact onClick={copy}>
<i className="fa fa-clipboard" /> <i className="fa fa-clipboard" />
<span>{copied ? 'Copied' : 'Copy'}</span> <span>{copied ? <FormattedMessage defaultMessage="Copied" /> : <FormattedMessage defaultMessage="Copy" />}</span>
</Button> </Button>
)} )}
</CopyButton> </CopyButton>
@ -56,7 +59,7 @@ export function Markdown(props: MarkdownProps) {
} }
}}>{props.content}</ReactMarkdown> }}>{props.content}</ReactMarkdown>
</div> </div>
), [props.content, classes]); ), [props.content, classes, intl]);
return elem; return elem;
} }

View File

@ -6,7 +6,8 @@ import { share } from '../utils';
import { ElevenLabsReaderButton } from '../tts/elevenlabs'; import { ElevenLabsReaderButton } from '../tts/elevenlabs';
import { Markdown } from './markdown'; import { Markdown } from './markdown';
import { useAppContext } from '../context'; import { useAppContext } from '../context';
import { useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
// hide for everyone but screen readers // hide for everyone but screen readers
const SROnly = styled.span` const SROnly = styled.span`
@ -17,16 +18,21 @@ const SROnly = styled.span`
const Container = styled.div` const Container = styled.div`
&.by-user { &.by-user {
background: #22232b;
} }
&.by-assistant { &.by-assistant {
background: rgba(255, 255, 255, 0.02); background: #292933;
} }
&.by-assistant + &.by-assistant, &.by-user + &.by-user { &.by-assistant + &.by-assistant, &.by-user + &.by-user {
border-top: 0.2rem dotted rgba(0, 0, 0, 0.1); border-top: 0.2rem dotted rgba(0, 0, 0, 0.1);
} }
&.by-assistant {
border-bottom: 0.2rem solid rgba(0, 0, 0, 0.1);
}
position: relative; position: relative;
padding: 1.618rem; padding: 1.618rem;
@ -182,19 +188,6 @@ const Editor = styled.div`
} }
`; `;
function getRoleName(role: string, share = false) {
switch (role) {
case 'user':
return !share ? 'You' : 'User';
case 'assistant':
return 'ChatGPT';
case 'system':
return 'System';
default:
return role;
}
}
function InlineLoader() { function InlineLoader() {
return ( return (
<Loader variant="dots" size="xs" style={{ <Loader variant="dots" size="xs" style={{
@ -209,6 +202,25 @@ export default function MessageComponent(props: { message: Message, last: boolea
const context = useAppContext(); const context = useAppContext();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const intl = useIntl();
const getRoleName = useCallback((role: string, share = false) => {
switch (role) {
case 'user':
if (share) {
return intl.formatMessage({ id: 'role-user-formal', defaultMessage: 'User' });
} else {
return intl.formatMessage({ id: 'role-user', defaultMessage: 'You' });
}
break;
case 'assistant':
return intl.formatMessage({ id: 'role-chatgpt', defaultMessage: 'ChatGPT' });
case 'system':
return intl.formatMessage({ id: 'role-system', defaultMessage: 'System' });
default:
return role;
}
}, [intl]);
const elem = useMemo(() => { const elem = useMemo(() => {
if (props.message.role === 'system') { if (props.message.role === 'system') {
@ -231,29 +243,33 @@ export default function MessageComponent(props: { message: Message, last: boolea
{({ copy, copied }) => ( {({ copy, copied }) => (
<Button variant="subtle" size="sm" compact onClick={copy} style={{ marginLeft: '1rem' }}> <Button variant="subtle" size="sm" compact onClick={copy} style={{ marginLeft: '1rem' }}>
<i className="fa fa-clipboard" /> <i className="fa fa-clipboard" />
<span>{copied ? 'Copied' : 'Copy'}</span> <span>{copied ? <FormattedMessage defaultMessage="Copied" /> : <FormattedMessage defaultMessage="Copy" />}</span>
</Button> </Button>
)} )}
</CopyButton> </CopyButton>
{typeof navigator.share !== 'undefined' && ( {typeof navigator.share !== 'undefined' && (
<Button variant="subtle" size="sm" compact onClick={() => share(props.message.content)}> <Button variant="subtle" size="sm" compact onClick={() => share(props.message.content)}>
<i className="fa fa-share" /> <i className="fa fa-share" />
<span>Share</span> <span>
<FormattedMessage defaultMessage="Share" />
</span>
</Button> </Button>
)} )}
{!context.isShare && props.message.role === 'user' && ( {!context.isShare && props.message.role === 'user' && (
<Button variant="subtle" size="sm" compact onClick={() => { <Button variant="subtle" size="sm" compact onClick={() => {
setContent(props.message.content); setContent(props.message.content);
setEditing(true); setEditing(v => !v);
}}> }}>
<i className="fa fa-edit" /> <i className="fa fa-edit" />
<span>Edit</span> <span>{editing ? <FormattedMessage defaultMessage="Cancel" /> : <FormattedMessage defaultMessage="Edit" />}</span>
</Button> </Button>
)} )}
{!context.isShare && props.message.role === 'assistant' && ( {!context.isShare && props.message.role === 'assistant' && (
<Button variant="subtle" size="sm" compact onClick={() => context.regenerateMessage(props.message)}> <Button variant="subtle" size="sm" compact onClick={() => context.regenerateMessage(props.message)}>
<i className="fa fa-refresh" /> <i className="fa fa-refresh" />
<span>Regenerate</span> <span>
<FormattedMessage defaultMessage="Regenerate" />
</span>
</Button> </Button>
)} )}
</div> </div>
@ -262,14 +278,18 @@ export default function MessageComponent(props: { message: Message, last: boolea
<Textarea value={content} <Textarea value={content}
onChange={e => setContent(e.currentTarget.value)} onChange={e => setContent(e.currentTarget.value)}
autosize={true} /> autosize={true} />
<Button variant="light" onClick={() => context.editMessage(props.message, content)}>Save changes</Button> <Button variant="light" onClick={() => context.editMessage(props.message, content)}>
<Button variant="subtle" onClick={() => setEditing(false)}>Cancel</Button> <FormattedMessage defaultMessage="Save changes" />
</Button>
<Button variant="subtle" onClick={() => setEditing(false)}>
<FormattedMessage defaultMessage="Cancel" />
</Button>
</Editor>)} </Editor>)}
</div> </div>
{props.last && <EndOfChatMarker />} {props.last && <EndOfChatMarker />}
</Container> </Container>
) )
}, [props.last, props.share, editing, content, context, props.message]); }, [props.last, props.share, editing, content, context, props.message, props.message.content]);
return elem; return elem;
} }

View File

@ -0,0 +1,90 @@
import styled from '@emotion/styled';
import { SpotlightProvider } from '@mantine/spotlight';
import { useChatSpotlightProps } from '../spotlight';
import { LoginModal, CreateAccountModal } from './auth/modals';
import Header, { HeaderProps, SubHeader } from './header';
import MessageInput from './input';
import SettingsDrawer from './settings';
import Sidebar from './sidebar';
const Container = styled.div`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #292933;
color: white;
display: flex;
flex-direction: row;
overflow: hidden;
.sidebar {
width: 0%;
height: 100%;
background: #303038;
flex-shrink: 0;
@media (min-width: 40em) {
transition: width 0.2s ease-in-out;
}
&.opened {
width: 33.33%;
@media (max-width: 40em) {
width: 100%;
flex-shrink: 0;
}
@media (min-width: 50em) {
width: 25%;
}
@media (min-width: 60em) {
width: 20%;
}
}
}
@media (max-width: 40em) {
.sidebar.opened + div {
display: none;
}
}
`;
const Main = styled.div`
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: hidden;
`;
export function Page(props: {
id: string;
headerProps?: HeaderProps;
showSubHeader?: boolean;
children: any;
}) {
const spotlightProps = useChatSpotlightProps();
return <SpotlightProvider {...spotlightProps}>
<Container>
<Sidebar />
<Main key={props.id}>
<Header share={props.headerProps?.share}
canShare={props.headerProps?.canShare}
title={props.headerProps?.title}
onShare={props.headerProps?.onShare} />
{props.showSubHeader && <SubHeader />}
{props.children}
<MessageInput key={localStorage.getItem('openai-api-key')} />
<SettingsDrawer />
<LoginModal />
<CreateAccountModal />
</Main>
</Container>
</SpotlightProvider>;
}

View File

@ -1,6 +1,7 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { Button } from '@mantine/core'; import { Button } from '@mantine/core';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { useAppDispatch, useAppSelector } from '../../store'; import { useAppDispatch, useAppSelector } from '../../store';
import { selectOpenAIApiKey } from '../../store/api-keys'; import { selectOpenAIApiKey } from '../../store/api-keys';
import { openOpenAIApiKeyPanel } from '../../store/settings-ui'; import { openOpenAIApiKeyPanel } from '../../store/settings-ui';
@ -25,10 +26,12 @@ export default function LandingPage(props: any) {
return <Page id={'landing'} showSubHeader={true}> return <Page id={'landing'} showSubHeader={true}>
<Container> <Container>
<p>Hello, how can I help you today?</p> <p>
<FormattedMessage defaultMessage={'Hello, how can I help you today?'} />
</p>
{!openAIApiKey && ( {!openAIApiKey && (
<Button size="xs" variant="light" compact onClick={onConnectButtonClick}> <Button size="xs" variant="light" compact onClick={onConnectButtonClick}>
Connect your OpenAI account to get started <FormattedMessage defaultMessage={'Connect your OpenAI account to get started'} />
</Button> </Button>
)} )}
</Container> </Container>

View File

@ -81,15 +81,13 @@ export default function SettingsDrawer(props: SettingsDrawerProps) {
const close = useCallback(() => dispatch(closeSettingsUI()), [dispatch]); const close = useCallback(() => dispatch(closeSettingsUI()), [dispatch]);
const onTabChange = useCallback((tab: string) => dispatch(setTab(tab)), [dispatch]); const onTabChange = useCallback((tab: string) => dispatch(setTab(tab)), [dispatch]);
if (!tab) {
return null;
}
return ( return (
<Drawer size="50rem" <Drawer size="50rem"
position='right' position='right'
opened={!!tab} opened={!!tab}
onClose={close} onClose={close}
transition="slide-left"
transitionDuration={200}
withCloseButton={false}> withCloseButton={false}>
<Container> <Container>
<Tabs value={tab} onTabChange={onTabChange} style={{ margin: '0rem' }}> <Tabs value={tab} onTabChange={onTabChange} style={{ margin: '0rem' }}>

View File

@ -6,8 +6,11 @@ import { defaultSystemPrompt } from "../../openai";
import { useAppDispatch, useAppSelector } from "../../store"; import { useAppDispatch, useAppSelector } from "../../store";
import { resetSystemPrompt, selectSystemPrompt, selectTemperature, setSystemPrompt, setTemperature } from "../../store/parameters"; import { resetSystemPrompt, selectSystemPrompt, selectTemperature, setSystemPrompt, setTemperature } from "../../store/parameters";
import { selectSettingsOption } from "../../store/settings-ui"; import { selectSettingsOption } from "../../store/settings-ui";
import { FormattedMessage, useIntl } from "react-intl";
export default function GenerationOptionsTab(props: any) { export default function GenerationOptionsTab(props: any) {
const intl = useIntl();
const option = useAppSelector(selectSettingsOption); const option = useAppSelector(selectSettingsOption);
const initialSystemPrompt = useAppSelector(selectSystemPrompt); const initialSystemPrompt = useAppSelector(selectSystemPrompt);
const temperature = useAppSelector(selectTemperature); const temperature = useAppSelector(selectTemperature);
@ -21,7 +24,8 @@ export default function GenerationOptionsTab(props: any) {
&& (initialSystemPrompt?.trim() !== defaultSystemPrompt.trim()); && (initialSystemPrompt?.trim() !== defaultSystemPrompt.trim());
const systemPromptOption = useMemo(() => ( const systemPromptOption = useMemo(() => (
<SettingsOption heading="System Prompt" focused={option === 'system-prompt'}> <SettingsOption heading={intl.formatMessage({ defaultMessage: "System Prompt" })}
focused={option === 'system-prompt'}>
<Textarea <Textarea
value={initialSystemPrompt || defaultSystemPrompt} value={initialSystemPrompt || defaultSystemPrompt}
onChange={onSystemPromptChange} onChange={onSystemPromptChange}
@ -29,18 +33,22 @@ export default function GenerationOptionsTab(props: any) {
maxRows={10} maxRows={10}
autosize /> autosize />
<p style={{ marginBottom: '0.7rem' }}> <p style={{ marginBottom: '0.7rem' }}>
The System Prompt is shown to ChatGPT by the "System" before your first message. The <code style={{ whiteSpace: 'nowrap' }}>{'{{ datetime }}'}</code> tag is automatically replaced by the current date and time. <FormattedMessage defaultMessage="The System Prompt is shown to ChatGPT by the &quot;System&quot; before your first message. The <code>'{{ datetime }}'</code> tag is automatically replaced by the current date and time."
values={{ code: chunk => <code style={{ whiteSpace: 'nowrap' }}>{chunk}</code> }} />
</p> </p>
{resettable && <Button size="xs" compact variant="light" onClick={onResetSystemPrompt}> {resettable && <Button size="xs" compact variant="light" onClick={onResetSystemPrompt}>
Reset to default <FormattedMessage defaultMessage="Reset to default" />
</Button>} </Button>}
</SettingsOption> </SettingsOption>
), [option, initialSystemPrompt, resettable, onSystemPromptChange, onResetSystemPrompt]); ), [option, initialSystemPrompt, resettable, onSystemPromptChange, onResetSystemPrompt]);
const temperatureOption = useMemo(() => ( const temperatureOption = useMemo(() => (
<SettingsOption heading={`Temperature (${temperature.toFixed(1)})`} focused={option === 'temperature'}> <SettingsOption heading={intl.formatMessage({ defaultMessage: "Temperature: {temperature, number, ::.0}", }, { temperature })}
focused={option === 'temperature'}>
<Slider value={temperature} onChange={onTemperatureChange} step={0.1} min={0} max={1} precision={3} /> <Slider value={temperature} onChange={onTemperatureChange} step={0.1} min={0} max={1} precision={3} />
<p>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.</p> <p>
<FormattedMessage defaultMessage="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." />
</p>
</SettingsOption> </SettingsOption>
), [temperature, option, onTemperatureChange]); ), [temperature, option, onTemperatureChange]);

View File

@ -8,8 +8,11 @@ import { selectVoice, setVoice } from "../../store/voices";
import { getVoices } from "../../tts/elevenlabs"; import { getVoices } from "../../tts/elevenlabs";
import { selectSettingsOption } from "../../store/settings-ui"; import { selectSettingsOption } from "../../store/settings-ui";
import { defaultVoiceList } from "../../tts/defaults"; import { defaultVoiceList } from "../../tts/defaults";
import { FormattedMessage, useIntl } from "react-intl";
export default function SpeechOptionsTab() { export default function SpeechOptionsTab() {
const intl = useIntl();
const option = useAppSelector(selectSettingsOption); const option = useAppSelector(selectSettingsOption);
const elevenLabsApiKey = useAppSelector(selectElevenLabsApiKey); const elevenLabsApiKey = useAppSelector(selectElevenLabsApiKey);
const voice = useAppSelector(selectVoice); const voice = useAppSelector(selectVoice);
@ -30,25 +33,39 @@ export default function SpeechOptionsTab() {
}, [elevenLabsApiKey]); }, [elevenLabsApiKey]);
const apiKeyOption = useMemo(() => ( const apiKeyOption = useMemo(() => (
<SettingsOption heading='Your ElevenLabs Text-to-Speech API Key (optional)' focused={option === 'elevenlabs-api-key'}> <SettingsOption heading={intl.formatMessage({ defaultMessage: 'Your ElevenLabs Text-to-Speech API Key (optional)' })}
<TextInput placeholder="Paste your API key here" value={elevenLabsApiKey || ''} onChange={onElevenLabsApiKeyChange} /> focused={option === 'elevenlabs-api-key'}>
<p>Give ChatGPT a realisic human voice by connecting your ElevenLabs account (preview the available voices below). <a href="https://beta.elevenlabs.io" target="_blank" rel="noreferrer">Click here to sign up.</a></p> <TextInput placeholder={intl.formatMessage({ defaultMessage: "Paste your API key here" })}
<p>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.</p> value={elevenLabsApiKey || ''} onChange={onElevenLabsApiKeyChange} />
<p>
<FormattedMessage defaultMessage="Give ChatGPT a realisic human voice by connecting your ElevenLabs account (preview the available voices below). <a>Click here to sign up.</a>"
values={{
a: (chunks: any) => <a href="https://beta.elevenlabs.io" target="_blank" rel="noreferrer">{chunks}</a>
}} />
</p>
<p>
<FormattedMessage defaultMessage="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." />
</p>
</SettingsOption> </SettingsOption>
), [option, elevenLabsApiKey, onElevenLabsApiKeyChange]); ), [option, elevenLabsApiKey, onElevenLabsApiKeyChange]);
const voiceOption = useMemo(() => ( const voiceOption = useMemo(() => (
<SettingsOption heading='Voice' focused={option === 'elevenlabs-voice'}> <SettingsOption heading={intl.formatMessage({ defaultMessage: 'Voice' })}
focused={option === 'elevenlabs-voice'}>
<Select <Select
value={voice} value={voice}
onChange={onVoiceChange} onChange={onVoiceChange}
data={voices.map(v => ({ label: v.name, value: v.voice_id }))} /> data={[
...voices.map(v => ({ label: v.name, value: v.voice_id })),
]} />
<audio controls style={{ display: 'none' }} id="voice-preview" key={voice}> <audio controls style={{ display: 'none' }} id="voice-preview" key={voice}>
<source src={voices.find(v => v.voice_id === voice)?.preview_url} type="audio/mpeg" /> <source src={voices.find(v => v.voice_id === voice)?.preview_url} type="audio/mpeg" />
</audio> </audio>
<Button onClick={() => (document.getElementById('voice-preview') as HTMLMediaElement)?.play()} variant='light' compact style={{ marginTop: '1rem' }}> <Button onClick={() => (document.getElementById('voice-preview') as HTMLMediaElement)?.play()} variant='light' compact style={{ marginTop: '1rem' }}>
<i className='fa fa-headphones' /> <i className='fa fa-headphones' />
<span>Preview voice</span> <span>
<FormattedMessage defaultMessage="Preview voice" />
</span>
</Button> </Button>
</SettingsOption> </SettingsOption>
), [option, voice, voices, onVoiceChange]); ), [option, voice, voices, onVoiceChange]);

View File

@ -5,23 +5,35 @@ import { useCallback, useMemo } from "react";
import { useAppDispatch, useAppSelector } from "../../store"; import { useAppDispatch, useAppSelector } from "../../store";
import { selectOpenAIApiKey, setOpenAIApiKeyFromEvent } from "../../store/api-keys"; import { selectOpenAIApiKey, setOpenAIApiKeyFromEvent } from "../../store/api-keys";
import { selectSettingsOption } from "../../store/settings-ui"; import { selectSettingsOption } from "../../store/settings-ui";
import { FormattedMessage, useIntl } from "react-intl";
export default function UserOptionsTab(props: any) { export default function UserOptionsTab(props: any) {
const option = useAppSelector(selectSettingsOption); const option = useAppSelector(selectSettingsOption);
const openaiApiKey = useAppSelector(selectOpenAIApiKey); const openaiApiKey = useAppSelector(selectOpenAIApiKey);
const intl = useIntl()
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const onOpenAIApiKeyChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => dispatch(setOpenAIApiKeyFromEvent(event)), [dispatch]); const onOpenAIApiKeyChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => dispatch(setOpenAIApiKeyFromEvent(event)), [dispatch]);
const elem = useMemo(() => ( const elem = useMemo(() => (
<SettingsTab name="user"> <SettingsTab name="user">
<SettingsOption heading="Your OpenAI API Key" focused={option === 'openai-api-key'}> <SettingsOption heading={intl.formatMessage({ defaultMessage: "Your OpenAI API Key" })}
focused={option === 'openai-api-key'}>
<TextInput <TextInput
placeholder="Paste your API key here" placeholder={intl.formatMessage({ defaultMessage: "Paste your API key here" })}
value={openaiApiKey || ''} value={openaiApiKey || ''}
onChange={onOpenAIApiKeyChange} /> onChange={onOpenAIApiKeyChange} />
<p><a href="https://platform.openai.com/account/api-keys" target="_blank" rel="noreferrer">Find your API key here.</a> Your API key is stored only on this device and never transmitted to anyone except OpenAI.</p> <p>
<p>OpenAI API key usage is billed at a pay-as-you-go rate, separate from your ChatGPT subscription.</p> <a href="https://platform.openai.com/account/api-keys" target="_blank" rel="noreferrer">
<FormattedMessage defaultMessage="Find your API key here." />
</a>
</p>
<p>
<FormattedMessage defaultMessage="Your API key is stored only on this device and never transmitted to anyone except OpenAI." />
</p>
<p>
<FormattedMessage defaultMessage="OpenAI API key usage is billed at a pay-as-you-go rate, separate from your ChatGPT subscription." />
</p>
</SettingsOption> </SettingsOption>
</SettingsTab> </SettingsTab>
), [option, openaiApiKey, onOpenAIApiKeyChange]); ), [option, openaiApiKey, onOpenAIApiKeyChange]);

View File

@ -0,0 +1,159 @@
import styled from '@emotion/styled';
import { ActionIcon, Avatar, Burger, Button, Menu } from '@mantine/core';
import { useElementSize } from '@mantine/hooks';
import { useCallback, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { backend } from '../../backend';
import { useAppContext } from '../../context';
import { useAppDispatch, useAppSelector } from '../../store';
import { setTab } from '../../store/settings-ui';
import { selectSidebarOpen, toggleSidebar } from '../../store/sidebar';
import RecentChats from './recent-chats';
const Container = styled.div`
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
position: relative;
font-family: "Work Sans", sans-serif;
box-shadow: 0px 0px 1rem 0.2rem rgb(0 0 0 / 5%);
.sidebar-header {
padding: 0.5rem 1rem 0.5rem 1.618rem;
min-height: 2.618rem;
display: flex;
align-items: center;
justify-content: space-between;
h2 {
font-size: 1rem;
font-weight: bold;
}
}
.sidebar-content {
flex-grow: 1;
overflow-y: scroll;
/* hide scrollbars */
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
min-width: 20vw;
padding-bottom: 2rem;
}
.sidebar-footer {
border-top: thin solid rgba(255, 255, 255, 0.1);
padding: 0.5rem 1.118rem;
padding-left: 0.5rem;
flex-shrink: 0;
display: flex;
align-items: center;
font-size: 1rem;
cursor: pointer;
.user-info {
max-width: calc(100% - 1.618rem * 2 - 2.5rem);
margin-right: 0.5rem;
}
strong, span {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
strong {
font-weight: bold;
margin-bottom: 0.2rem;
}
span {
font-size: 0.8rem;
font-weight: 100;
}
.mantine-Avatar-root {
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
overflow: hidden;
width: 2.5rem;
height: 2.5rem;
min-width: 0;
flex-grow: 0;
flex-shrink: 0;
margin: 0.5rem;
}
}
.spacer {
flex-grow: 1;
}
`;
export default function Sidebar(props: {
className?: string;
}) {
const intl = useIntl();
const dispatch = useAppDispatch();
const sidebarOpen = useAppSelector(selectSidebarOpen);
const onBurgerClick = useCallback(() => dispatch(toggleSidebar()), [dispatch]);
const { ref, width } = useElementSize();
const burgerLabel = sidebarOpen
? intl.formatMessage({ defaultMessage: "Close sidebar" })
: intl.formatMessage({ defaultMessage: "Open sidebar" });
const elem = useMemo(() => (
<Container className={"sidebar " + (sidebarOpen ? 'opened' : 'closed')} ref={ref}>
<div className="sidebar-header">
<h2>Chat History</h2>
<Burger opened={sidebarOpen} onClick={onBurgerClick} aria-label={burgerLabel} transitionDuration={0} />
</div>
<div className="sidebar-content">
<RecentChats />
</div>
{backend.current && backend.current.isAuthenticated && (
<Menu width={width - 20}>
<Menu.Target>
<div className="sidebar-footer">
<Avatar size="lg" src={backend.current!.user!.avatar} />
<div className="user-info">
<strong>{backend.current!.user!.name || backend.current!.user!.email}</strong>
{!!backend.current!.user!.name && <span>{backend.current.user!.email}</span>}
</div>
<div className="spacer" />
<ActionIcon variant="subtle">
<i className="fas fa-ellipsis" />
</ActionIcon>
</div>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => {
dispatch(setTab('user'));
}} icon={<i className="fas fa-gear" />}>
User settings
</Menu.Item>
{/*
<Menu.Divider />
<Menu.Item color="red" onClick={() => backend.current?.logout()} icon={<i className="fas fa-sign-out-alt" />}>
Sign out
</Menu.Item>
*/}
</Menu.Dropdown>
</Menu>
)}
</Container>
), [sidebarOpen, width, ref, burgerLabel, onBurgerClick, dispatch]);
return elem;
}

View File

@ -0,0 +1,95 @@
import styled from '@emotion/styled';
import { useCallback, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useAppContext } from '../../context';
import { useAppDispatch } from '../../store';
import { toggleSidebar } from '../../store/sidebar';
const Container = styled.div`
margin: calc(1.618rem - 1rem);
margin-top: -0.218rem;
`;
const Empty = styled.p`
text-align: center;
font-size: 0.8rem;
padding: 2rem;
`;
const ChatList = styled.div``;
const ChatListItem = styled(Link)`
display: block;
padding: 0.4rem 1rem;
margin: 0.218rem 0;
line-height: 1.7;
text-decoration: none;
border-radius: 0.25rem;
&:hover, &:focus, &:active {
background: rgba(0, 0, 0, 0.1);
}
&.selected {
background: #2b3d54;
}
&, * {
color: white;
}
strong {
display: block;
font-weight: 400;
font-size: 1rem;
line-height: 1.6;
}
p {
font-size: 0.8rem;
font-weight: 200;
opacity: 0.8;
}
`;
export default function RecentChats(props: any) {
const context = useAppContext();
const dispatch = useAppDispatch();
const currentChatID = context.currentChat.chat?.id;
const recentChats = context.chat.search.query('');
const onClick = useCallback(() => {
if (window.matchMedia('(max-width: 40em)').matches) {
dispatch(toggleSidebar());
}
}, [dispatch]);
useEffect(() => {
if (currentChatID) {
const el = document.querySelector(`[data-chat-id="${currentChatID}"]`);
if (el) {
el.scrollIntoView();
}
}
}, [currentChatID]);
return (
<Container>
{recentChats.length > 0 && <ChatList>
{recentChats.map(c => (
<ChatListItem key={c.chatID}
to={'/chat/' + c.chatID}
onClick={onClick}
data-chat-id={c.chatID}
className={c.chatID === currentChatID ? 'selected' : ''}>
<strong>{c.title || 'Untitled'}</strong>
</ChatListItem>
))}
</ChatList>}
{recentChats.length === 0 && <Empty>
No chats yet.
</Empty>}
</Container>
);
}

View File

@ -12,6 +12,7 @@ export interface Context {
chat: ChatManager; chat: ChatManager;
id: string | undefined | null; id: string | undefined | null;
currentChat: UseChatResult; currentChat: UseChatResult;
isHome: boolean;
isShare: boolean; isShare: boolean;
generating: boolean; generating: boolean;
onNewMessage: (message?: string) => Promise<boolean>; onNewMessage: (message?: string) => Promise<boolean>;
@ -26,6 +27,7 @@ export function useCreateAppContext(): Context {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const pathname = useLocation().pathname; const pathname = useLocation().pathname;
const isHome = pathname === '/';
const isShare = pathname.startsWith('/s/'); const isShare = pathname.startsWith('/s/');
const navigate = useNavigate(); const navigate = useNavigate();
@ -42,8 +44,6 @@ export function useCreateAppContext(): Context {
}; };
}, [updateAuth]); }, [updateAuth]);
const [generating, setGenerating] = useState(false);
const onNewMessage = useCallback(async (message?: string) => { const onNewMessage = useCallback(async (message?: string) => {
if (isShare) { if (isShare) {
return false; return false;
@ -60,8 +60,6 @@ export function useCreateAppContext(): Context {
return false; return false;
} }
setGenerating(true);
const parameters = store.getState().parameters; const parameters = store.getState().parameters;
if (id) { if (id) {
@ -88,8 +86,6 @@ export function useCreateAppContext(): Context {
navigate('/chat/' + id); navigate('/chat/' + id);
} }
setTimeout(() => setGenerating(false), 4000);
return true; return true;
}, [dispatch, chatManager, id, currentChat.leaf, navigate, isShare]); }, [dispatch, chatManager, id, currentChat.leaf, navigate, isShare]);
@ -105,8 +101,6 @@ export function useCreateAppContext(): Context {
return false; return false;
} }
setGenerating(true);
const parameters = store.getState().parameters; const parameters = store.getState().parameters;
await chatManager.current.regenerate(message, { await chatManager.current.regenerate(message, {
@ -114,8 +108,6 @@ export function useCreateAppContext(): Context {
apiKey: openaiApiKey, apiKey: openaiApiKey,
}); });
setTimeout(() => setGenerating(false), 4000);
return true; return true;
}, [dispatch, chatManager, isShare]); }, [dispatch, chatManager, isShare]);
@ -135,8 +127,6 @@ export function useCreateAppContext(): Context {
return false; return false;
} }
setGenerating(true);
const parameters = store.getState().parameters; const parameters = store.getState().parameters;
if (id) { if (id) {
@ -163,16 +153,19 @@ export function useCreateAppContext(): Context {
navigate('/chat/' + id); navigate('/chat/' + id);
} }
setTimeout(() => setGenerating(false), 4000);
return true; return true;
}, [dispatch, chatManager, id, isShare, navigate]); }, [dispatch, chatManager, id, isShare, navigate]);
const generating = currentChat?.messagesToDisplay?.length > 0
? !currentChat.messagesToDisplay[currentChat.messagesToDisplay.length - 1].done
: false;
const context = useMemo<Context>(() => ({ const context = useMemo<Context>(() => ({
authenticated, authenticated,
id, id,
chat: chatManager.current, chat: chatManager.current,
currentChat, currentChat,
isHome,
isShare, isShare,
generating, generating,
onNewMessage, onNewMessage,

View File

@ -1,19 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import {
createBrowserRouter,
RouterProvider,
} from "react-router-dom";
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import { ModalsProvider } from '@mantine/modals'; import { ModalsProvider } from '@mantine/modals';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { IntlProvider } from 'react-intl';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { PersistGate } from 'redux-persist/integration/react'; import { PersistGate } from 'redux-persist/integration/react';
import AboutPage from './components/pages/about';
import ChatPage from './components/pages/chat';
import LandingPage from './components/pages/landing';
import { AppContextProvider } from './context'; import { AppContextProvider } from './context';
import store, { persistor } from './store'; import store, { persistor } from './store';
import LandingPage from './components/pages/landing';
import ChatPage from './components/pages/chat';
import AboutPage from './components/pages/about';
import './backend';
import './index.scss'; import './index.scss';
const router = createBrowserRouter([ const router = createBrowserRouter([
@ -53,8 +53,27 @@ const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement document.getElementById('root') as HTMLElement
); );
root.render( async function loadLocaleData(locale: string) {
const messages = await fetch(`/lang/${locale}.json`);
if (!messages.ok) {
throw new Error("Failed to load locale data");
}
return messages.json()
}
async function bootstrapApplication() {
const locale = navigator.language;
let messages: any;
try {
messages = await loadLocaleData(locale.toLocaleLowerCase());
} catch (e) {
console.warn("No locale data for", locale);
}
root.render(
<React.StrictMode> <React.StrictMode>
<IntlProvider locale={navigator.language} messages={messages}>
<MantineProvider theme={{ colorScheme: "dark" }}> <MantineProvider theme={{ colorScheme: "dark" }}>
<Provider store={store}> <Provider store={store}>
<PersistGate loading={null} persistor={persistor}> <PersistGate loading={null} persistor={persistor}>
@ -64,5 +83,9 @@ root.render(
</PersistGate> </PersistGate>
</Provider> </Provider>
</MantineProvider> </MantineProvider>
</IntlProvider>
</React.StrictMode> </React.StrictMode>
); );
}
bootstrapApplication();

View File

@ -4,18 +4,6 @@ export interface MenuItem {
icon?: string; icon?: string;
} }
export const primaryMenu: MenuItem[] = [
{
label: "About this app",
link: "/about",
},
{
label: "ChatGPT Prompts",
link: "https://github.com/f/awesome-chatgpt-prompts",
icon: "external-link-alt",
},
];
export const secondaryMenu: MenuItem[] = [ export const secondaryMenu: MenuItem[] = [
{ {
label: "Discord", label: "Discord",

View File

@ -39,6 +39,10 @@ export class MessageTree {
return first; return first;
} }
public get(id: string) {
return this.nodes.get(id);
}
public addMessage(message: Message) { public addMessage(message: Message) {
if (this.nodes.get(message.id)?.content) { if (this.nodes.get(message.id)?.content) {
return; return;

View File

@ -100,6 +100,24 @@ export async function createStreamingChatCompletion(messages: OpenAIMessage[], p
}), }),
}) as SSE; }) as SSE;
// TODO: enable (optional) server-side completion
/*
const eventSource = new SSE('/chatapi/completion/streaming', {
method: "POST",
headers: {
'Accept': 'application/json, text/plain, *\/*',
'Authorization': `Bearer ${(backend.current as any).token}`,
'Content-Type': 'application/json',
},
payload: JSON.stringify({
"model": "gpt-3.5-turbo",
"messages": messagesToSend,
"temperature": parameters.temperature,
"stream": true,
}),
}) as SSE;
*/
let contents = ''; let contents = '';
eventSource.addEventListener('error', (event: any) => { eventSource.addEventListener('error', (event: any) => {
@ -127,5 +145,8 @@ export async function createStreamingChatCompletion(messages: OpenAIMessage[], p
eventSource.stream(); eventSource.stream();
return emitter; return {
emitter,
cancel: () => eventSource.close(),
};
} }

View File

@ -0,0 +1,14 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable no-undef */
const { createProxyMiddleware } = require('http-proxy-middleware');
module.exports = function (app) {
app.use(
'/chatapi',
createProxyMiddleware({
target: 'http://localhost:3001',
secure: false,
changeOrigin: true,
})
);
};

View File

@ -1,10 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useIntl } from "react-intl";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAppContext } from "./context"; import { useAppContext } from "./context";
export function useChatSpotlightProps() { export function useChatSpotlightProps() {
const navigate = useNavigate(); const navigate = useNavigate();
const context = useAppContext(); const context = useAppContext();
const intl = useIntl();
const [version, setVersion] = useState(0); const [version, setVersion] = useState(0);
@ -23,7 +25,7 @@ export function useChatSpotlightProps() {
const props = useMemo(() => ({ const props = useMemo(() => ({
shortcut: ['mod + P'], shortcut: ['mod + P'],
overlayColor: '#000000', overlayColor: '#000000',
searchPlaceholder: 'Search your chats', searchPlaceholder: intl.formatMessage({ defaultMessage: 'Search your chats' }),
searchIcon: <i className="fa fa-search" />, searchIcon: <i className="fa fa-search" />,
actions: search, actions: search,
filter: (query: string, items: any) => items, filter: (query: string, items: any) => items,

View File

@ -203,8 +203,12 @@ export default class SSE {
return; return;
} }
try {
this.xhr.abort(); this.xhr.abort();
this.xhr = null; this.xhr = null;
this._setReadyState(this.CLOSED); this._setReadyState(this.CLOSED);
} catch (e) {
console.error(e);
}
}; };
}; };

View File

@ -7,12 +7,19 @@ import parametersReducer from './parameters';
import apiKeysReducer from './api-keys'; import apiKeysReducer from './api-keys';
import voiceReducer from './voices'; import voiceReducer from './voices';
import settingsUIReducer from './settings-ui'; import settingsUIReducer from './settings-ui';
import uiReducer from './ui';
import sidebarReducer from './sidebar';
const persistConfig = { const persistConfig = {
key: 'root', key: 'root',
storage, storage,
} }
const persistSidebarConfig = {
key: 'sidebar',
storage,
}
const store = configureStore({ const store = configureStore({
reducer: { reducer: {
// auth: authReducer, // auth: authReducer,
@ -21,6 +28,8 @@ const store = configureStore({
voices: persistReducer(persistConfig, voiceReducer), voices: persistReducer(persistConfig, voiceReducer),
parameters: persistReducer(persistConfig, parametersReducer), parameters: persistReducer(persistConfig, parametersReducer),
message: messageReducer, message: messageReducer,
ui: uiReducer,
sidebar: persistReducer(persistSidebarConfig, sidebarReducer),
}, },
}) })

View File

@ -11,15 +11,12 @@ export const settingsUISlice = createSlice({
initialState, initialState,
reducers: { reducers: {
setTab: (state, action: PayloadAction<string|null>) => { setTab: (state, action: PayloadAction<string|null>) => {
console.log('set tab', action);
state.tab = action.payload || ''; state.tab = action.payload || '';
}, },
setOption: (state, action: PayloadAction<string|null>) => { setOption: (state, action: PayloadAction<string|null>) => {
console.log('set option', action);
state.option = action.payload || ''; state.option = action.payload || '';
}, },
setTabAndOption: (state, action: PayloadAction<{ tab: string | null, option: string | null }>) => { setTabAndOption: (state, action: PayloadAction<{ tab: string | null, option: string | null }>) => {
console.log('set tab and option', action);
state.tab = action.payload.tab || ''; state.tab = action.payload.tab || '';
state.option = action.payload.option || ''; state.option = action.payload.option || '';
}, },

View File

@ -0,0 +1,28 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import type { RootState } from '.';
const initialState = {
open: false,
};
export const uiSlice = createSlice({
name: 'sidebar',
initialState,
reducers: {
openSidebar(state) {
state.open = true;
},
closeSidebar(state) {
state.open = false;
},
toggleSidebar(state) {
state.open = !state.open;
},
},
})
export const { openSidebar, closeSidebar, toggleSidebar } = uiSlice.actions;
export const selectSidebarOpen = (state: RootState) => state.sidebar.open;
export default uiSlice.reducer;

View File

@ -0,0 +1,28 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import type { RootState } from '.';
const initialState = {
modal: '',
};
export const uiSlice = createSlice({
name: 'ui',
initialState,
reducers: {
openLoginModal(state) {
state.modal = 'login';
},
openSignupModal(state) {
state.modal = 'signup';
},
closeModals(state) {
state.modal = '';
},
},
})
export const { openLoginModal, openSignupModal, closeModals } = uiSlice.actions;
export const selectModal = (state: RootState) => state.ui.modal;
export default uiSlice.reducer;

View File

@ -1,7 +1,11 @@
import { encoding_for_model } from "./tiktoken/dist/tiktoken";
import { OpenAIMessage } from "./types"; import { OpenAIMessage } from "./types";
const enc = encoding_for_model("gpt-3.5-turbo"); let enc: any;
setTimeout(async () => {
const { encoding_for_model } = await import("./tiktoken/dist/tiktoken");
enc = encoding_for_model("gpt-3.5-turbo");
}, 2000);
export function getTokenCount(input: string): number { export function getTokenCount(input: string): number {
return enc.encode(input).length; return enc.encode(input).length;

View File

@ -9,6 +9,7 @@ import { selectElevenLabsApiKey } from "../store/api-keys";
import { selectVoice } from "../store/voices"; import { selectVoice } from "../store/voices";
import { openElevenLabsApiKeyPanel } from "../store/settings-ui"; import { openElevenLabsApiKeyPanel } from "../store/settings-ui";
import { defaultElevenLabsVoiceID } from "./defaults"; import { defaultElevenLabsVoiceID } from "./defaults";
import { FormattedMessage, useIntl } from "react-intl";
const endpoint = 'https://api.elevenlabs.io'; const endpoint = 'https://api.elevenlabs.io';
@ -227,6 +228,7 @@ export default class ElevenLabsReader extends EventEmitter {
export function ElevenLabsReaderButton(props: { selector: string }) { export function ElevenLabsReaderButton(props: { selector: string }) {
const elevenLabsApiKey = useAppSelector(selectElevenLabsApiKey); const elevenLabsApiKey = useAppSelector(selectElevenLabsApiKey);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl();
const voice = useAppSelector(selectVoice); const voice = useAppSelector(selectVoice);
@ -269,9 +271,15 @@ export function ElevenLabsReaderButton(props: { selector: string }) {
return ( return (
<Button variant="subtle" size="sm" compact onClickCapture={onClick} loading={status === 'init'}> <Button variant="subtle" size="sm" compact onClickCapture={onClick} loading={status === 'init'}>
{status !== 'init' && <i className="fa fa-headphones" />} {status !== 'init' && <i className="fa fa-headphones" />}
{status === 'idle' && <span>Play</span>} {status === 'idle' && <span>
{status === 'buffering' && <span>Loading audio...</span>} <FormattedMessage defaultMessage="Play" />
{status !== 'idle' && status !== 'buffering' && <span>Stop</span>} </span>}
{status === 'buffering' && <span>
<FormattedMessage defaultMessage="Loading audio..." />
</span>}
{status !== 'idle' && status !== 'buffering' && <span>
<FormattedMessage defaultMessage="Stop" />
</span>}
</Button> </Button>
); );
} }

View File

@ -43,3 +43,16 @@ export interface Chat {
created: number; created: number;
updated: number; updated: number;
} }
export function serializeChat(chat: Chat): string {
return JSON.stringify({
...chat,
messages: chat.messages.serialize(),
});
}
export function deserializeChat(serialized: string) {
const chat = JSON.parse(serialized);
chat.messages = new MessageTree(chat.messages);
return chat as Chat;
}

View File

@ -36,3 +36,26 @@ export function cloneArrayBuffer(buffer) {
new Uint8Array(newBuffer).set(new Uint8Array(buffer)); new Uint8Array(newBuffer).set(new Uint8Array(buffer));
return newBuffer; return newBuffer;
} }
export class AsyncLoop {
public cancelled = false;
constructor(private handler: any, private pauseBetween: number = 1000) {
}
public async start() {
this.loop().then(() => {});
}
private async loop() {
while (!this.cancelled) {
try {
await this.handler();
} catch (e) {
console.error(e);
}
await sleep(this.pauseBetween);
}
}
}

View File

@ -1,5 +0,0 @@
const cracoWasm = require("craco-wasm");
module.exports = {
plugins: [cracoWasm()]
}

16
docker-compose.yml 100644
View File

@ -0,0 +1,16 @@
version: '3'
services:
app:
build:
context: .
dockerfile: Dockerfile
working_dir: /app
volumes:
- ./data:/app/data
command: npm run start
ports:
- 3000:3000
environment:
- PORT=3000
- WEBAPP_PORT=3000

View File

@ -1,50 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<!-- mobile app viewport -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Chat with GPT | Unofficial ChatGPT app</title>
<link rel="stylesheet" media="all" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.2/css/all.min.css" />
<link rel="stylesheet" media="all" href="https://fonts.googleapis.com/css?family=Open+Sans:100,400,300,500,700,800" />
<link rel="stylesheet" media="all" href="https://fonts.googleapis.com/css?family=Fira+Code:100,400,300,500,700,800" />
<link href="https://fonts.googleapis.com/css?family=Work+Sans:300,400,500,600,700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css" integrity="sha384-Xi8rHCmBmhbuyyhbI88391ZKP2dmfnOl4rT9ZfRI7mLTdk1wblIUnrIq35nqwEvC" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tailwindcss/typography@0.4.1/dist/typography.min.css" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

8597
server/package-lock.json generated 100644

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,53 @@
{
"name": "Chat with GPT Server",
"version": "0.2.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "npx ts-node src/index.ts"
},
"author": "",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.282.0",
"@types/compression": "^1.7.2",
"@types/connect-sqlite3": "^0.9.2",
"@types/debug": "^4.1.7",
"@types/email-validator": "^1.0.6",
"@types/express": "^4.17.17",
"@types/express-session": "^1.17.6",
"@types/node": "^18.14.4",
"@types/passport": "^1.0.12",
"@types/passport-local": "^1.0.35",
"@types/pg": "^8.6.6",
"@types/sqlite3": "^3.1.8",
"axios": "^1.3.4",
"compression": "^1.7.4",
"connect-sqlite3": "^0.9.13",
"debug": "^4.3.4",
"dotenv": "^16.0.3",
"email-validator": "^2.0.4",
"events": "^3.3.0",
"expiry-map": "^2.0.0",
"expiry-set": "^1.0.0",
"express": "^4.18.2",
"express-openid-connect": "^2.12.1",
"express-session": "^1.17.3",
"idb-keyval": "^6.2.0",
"jsonwebtoken": "^9.0.0",
"jwks-rsa": "^3.0.1",
"launchdarkly-eventsource": "^1.4.4",
"localforage": "^1.10.0",
"match-sorter": "^6.3.1",
"nanoid": "^4.0.1",
"openai": "^3.2.1",
"passport": "^0.6.0",
"passport-local": "^1.0.0",
"pg": "^8.9.0",
"react-router-dom": "^6.8.2",
"sort-by": "^0.0.2",
"sqlite3": "^5.1.4",
"ts-node": "^10.9.1",
"xhr2": "^0.2.1"
}
}

View File

@ -0,0 +1,40 @@
import { auth, ConfigParams } from 'express-openid-connect';
import ChatServer from './index';
const config: ConfigParams = {
authRequired: false,
auth0Logout: false,
secret: process.env.AUTH_SECRET || 'keyboard cat',
baseURL: process.env.PUBLIC_URL,
clientID: process.env.AUTH0_CLIENT_ID,
issuerBaseURL: process.env.AUTH0_ISSUER,
routes: {
login: false,
logout: false,
},
};
export function configureAuth0(context: ChatServer) {
context.app.use(auth(config));
context.app.get('/chatapi/login', (req, res) => {
res.oidc.login({
returnTo: process.env.PUBLIC_URL,
authorizationParams: {
redirect_uri: process.env.PUBLIC_URL + '/chatapi/login-callback',
},
});
});
context.app.get('/chatapi/logout', (req, res) => {
res.oidc.logout({
returnTo: process.env.PUBLIC_URL,
});
});
context.app.all('/chatapi/login-callback', (req, res) => {
res.oidc.callback({
redirectUri: process.env.PUBLIC_URL!,
})
});
}

View File

@ -0,0 +1,15 @@
export default abstract class Database {
public async initialize() {}
public abstract createUser(email: string, passwordHash: Buffer, salt: Buffer): Promise<void>;
public abstract getUser(email: string): Promise<{
id: string;
email: string;
passwordHash: Buffer;
salt: Buffer;
}>;
public abstract getChats(userID: string): Promise<any[]>;
public abstract getMessages(userID: string): Promise<any[]>;
public abstract insertMessages(userID: string, messages: any[]): Promise<void>;
public abstract createShare(userID: string|null, id: string): Promise<boolean>;
public abstract setTitle(userID: string, chatID: string, title: string): Promise<void>;
}

View File

@ -0,0 +1,168 @@
import { verbose } from "sqlite3";
import { validate as validateEmailAddress } from 'email-validator';
import Database from "./index";
const sqlite3 = verbose();
const db = new sqlite3.Database('./data/chat.sqlite');
// interface ChatRow {
// id: string;
// user_id: string;
// title: string;
// }
// interface MessageRow {
// id: string;
// user_id: string;
// chat_id: string;
// data: any;
// }
// interface ShareRow {
// id: string;
// user_id: string;
// created_at: Date;
// }
export class SQLiteAdapter extends Database {
public async initialize() {
db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS authentication (
id TEXT PRIMARY KEY,
email TEXT,
password_hash BLOB,
salt BLOB
)`);
db.run(`CREATE TABLE IF NOT EXISTS chats (
id TEXT PRIMARY KEY,
user_id TEXT,
title TEXT
)`);
db.run(`CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
user_id TEXT,
chat_id TEXT,
data TEXT
)`);
db.run(`CREATE TABLE IF NOT EXISTS shares (
id TEXT PRIMARY KEY,
user_id TEXT,
created_at DATETIME
)`);
});
}
public createUser(email: string, passwordHash: Buffer, salt: Buffer): Promise<void> {
return new Promise((resolve, reject) => {
if (!validateEmailAddress(email)) {
reject(new Error('invalid email address'));
return;
}
db.run(`INSERT INTO authentication (id, email, password_hash, salt) VALUES (?, ?, ?, ?)`, [email, email, passwordHash, salt], (err) => {
if (err) {
reject(err);
console.log(`[database:sqlite] failed to create user ${email}`);
} else {
resolve();
console.log(`[database:sqlite] created user ${email}`);
}
});
});
}
public async getUser(email: string): Promise<any> {
return new Promise((resolve, reject) => {
db.get(`SELECT * FROM authentication WHERE email = ?`, [email], (err, row) => {
if (err) {
reject(err);
console.log(`[database:sqlite] failed to get user ${email}`);
} else {
resolve({
...row,
passwordHash: Buffer.from(row.password_hash),
salt: Buffer.from(row.salt),
});
console.log(`[database:sqlite] retrieved user ${email}`);
}
});
});
}
public async getChats(userID: string): Promise<any[]> {
return new Promise((resolve, reject) => {
db.all(`SELECT * FROM chats WHERE user_id = ?`, [userID], (err, rows) => {
if (err) {
reject(err);
console.log(`[database:sqlite] failed to get chats for user ${userID}`);
} else {
resolve(rows);
console.log(`[database:sqlite] retrieved ${rows.length} chats for user ${userID}`);
}
});
});
}
public async getMessages(userID: string): Promise<any[]> {
return new Promise((resolve, reject) => {
db.all(`SELECT * FROM messages WHERE user_id = ?`, [userID], (err, rows) => {
if (err) {
reject(err);
console.log(`[database:sqlite] failed to get messages for user ${userID}`);
} else {
resolve(rows.map((row) => {
row.data = JSON.parse(row.data);
return row;
}));
console.log(`[database:sqlite] retrieved ${rows.length} messages for user ${userID}`);
}
});
});
}
public async insertMessages(userID: string, messages: any[]): Promise<void> {
return new Promise((resolve, reject) => {
db.serialize(() => {
const stmt = db.prepare(`INSERT OR IGNORE INTO messages (id, user_id, chat_id, data) VALUES (?, ?, ?, ?)`);
messages.forEach((message) => {
stmt.run(message.id, userID, message.chatID, JSON.stringify(message));
});
stmt.finalize();
console.log(`[database:sqlite] inserted ${messages.length} messages`);
resolve();
});
});
}
public async createShare(userID: string|null, id: string): Promise<boolean> {
return new Promise((resolve, reject) => {
db.run(`INSERT INTO shares (id, user_id, created_at) VALUES (?, ?, ?)`, [id, userID, new Date()], (err) => {
if (err) {
reject(err);
console.log(`[database:sqlite] failed to create share ${id}`);
} else {
resolve(true);
console.log(`[database:sqlite] created share ${id}`)
}
});
});
}
public async setTitle(userID: string, chatID: string, title: string): Promise<void> {
return new Promise((resolve, reject) => {
db.run(`INSERT OR IGNORE INTO chats (id, user_id, title) VALUES (?, ?, ?)`, [chatID, userID, title], (err) => {
if (err) {
reject(err);
console.log(`[database:sqlite] failed to set title for chat ${chatID}`);
} else {
resolve();
console.log(`[database:sqlite] set title for chat ${chatID}`)
}
});
});
}
}

View File

@ -0,0 +1,45 @@
import express from 'express';
import ChatServer from '../index';
export default abstract class RequestHandler {
constructor(public context: ChatServer, private req: express.Request, private res: express.Response) {
this.callback().then(() => {});
}
public async callback() {
if (!this.userID && this.isProtected()) {
this.res.sendStatus(401);
return;
}
try {
return await this.handler(this.req, this.res);
} catch (e) {
console.error(e);
this.res.sendStatus(500);
}
}
public abstract handler(req: express.Request, res: express.Response): any;
public isProtected() {
return false;
}
public get userID(): string | null {
const request = this.req as any;
if (request.oidc) {
const user = request.oidc.user;
if (user) {
return user.sub;
}
}
const userID = request.session?.passport?.user?.id;
if (userID) {
return userID;
}
return null;
}
}

View File

@ -0,0 +1,20 @@
import express from 'express';
import { Configuration, OpenAIApi } from "openai";
import RequestHandler from "../base";
const configuration = new Configuration({
apiKey: process.env.OPENAI_API_KEY,
});
const openai = new OpenAIApi(configuration);
export default class BasicCompletionRequestHandler extends RequestHandler {
async handler(req: express.Request, res: express.Response) {
const response = await openai.createChatCompletion(req.body);
res.json(response);
}
public isProtected() {
return true;
}
}

View File

@ -0,0 +1,57 @@
// @ts-ignore
import { EventSource } from "launchdarkly-eventsource";
import express from 'express';
import RequestHandler from "../base";
export default class StreamingCompletionRequestHandler extends RequestHandler {
async handler(req: express.Request, res: express.Response) {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const eventSource = new EventSource('https://api.openai.com/v1/chat/completions', {
method: "POST",
headers: {
'Accept': 'application/json, text/plain, */*',
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
...req.body,
stream: true,
}),
});
eventSource.addEventListener('message', async (event: any) => {
res.write(`data: ${event.data}\n\n`);
res.flush();
if (event.data === '[DONE]') {
res.end();
eventSource.close();
}
});
eventSource.addEventListener('error', (event: any) => {
res.end();
});
eventSource.addEventListener('abort', (event: any) => {
res.end();
});
req.on('close', () => {
eventSource.close();
});
res.on('error', () => {
eventSource.close();
});
}
public isProtected() {
return true;
}
}

View File

@ -0,0 +1,14 @@
import express from 'express';
import RequestHandler from "./base";
export default class GetShareRequestHandler extends RequestHandler {
async handler(req: express.Request, res: express.Response) {
const id = req.params.id;
const data = await this.context.objectStore.get('chats/' + id + '.json');
if (data) {
res.json(JSON.parse(data));
} else {
res.sendStatus(404);
}
}
}

View File

@ -0,0 +1,8 @@
import express from 'express';
import RequestHandler from "./base";
export default class HealthRequestHandler extends RequestHandler {
handler(req: express.Request, res: express.Response): any {
res.json({ status: 'ok' });
}
}

View File

@ -0,0 +1,18 @@
import express from 'express';
import RequestHandler from "./base";
export default class MessagesRequestHandler extends RequestHandler {
async handler(req: express.Request, res: express.Response) {
if (!req.body.messages?.length) {
console.log("Invalid request")
res.sendStatus(400);
return;
}
await this.context.database.insertMessages(this.userID!, req.body.messages);
res.json({ status: 'ok' });
}
public isProtected() {
return true;
}
}

View File

@ -0,0 +1,37 @@
import express from 'express';
import RequestHandler from "./base";
export default class SessionRequestHandler extends RequestHandler {
async handler(req: express.Request, res: express.Response) {
const request = req as any;
if (request.oidc) {
const user = request.oidc.user;
console.log(user);
if (user) {
res.json({
authenticated: true,
userID: user.sub,
name: user.name,
email: user.email,
picture: user.picture,
});
return;
}
}
const userID = request.session?.passport?.user?.id;
if (userID) {
res.json({
authenticated: true,
userID,
email: userID,
});
return;
}
res.json({
authenticated: false,
});
}
}

View File

@ -0,0 +1,31 @@
import express from 'express';
import RequestHandler from "./base";
export default class ShareRequestHandler extends RequestHandler {
async handler(req: express.Request, res: express.Response) {
const { nanoid } = await import('nanoid'); // esm
if (!req.body.messages?.length) {
res.sendStatus(400);
return;
}
for (let length = 5; length < 24; length += 2) {
const id = nanoid(length);
if (await this.context.database.createShare(null, id)) {
await this.context.objectStore.put(
'chats/' + id + '.json',
JSON.stringify({
title: req.body.title,
messages: req.body.messages,
}),
'application/json',
);
res.json({ id });
return;
}
}
res.sendStatus(500);
}
}

View File

@ -0,0 +1,35 @@
import express from 'express';
import RequestHandler from "./base";
export default class SyncRequestHandler extends RequestHandler {
async handler(req: express.Request, res: express.Response) {
const [chats, messages] = await Promise.all([
this.context.database.getChats(this.userID!),
this.context.database.getMessages(this.userID!),
]);
const output: Record<string, any> = {};
for (const m of messages) {
const chat = output[m.chat_id] || {
messages: [],
};
chat.messages.push(m.data);
output[m.chat_id] = chat;
}
for (const c of chats) {
const chat = output[c.id] || {
messages: [],
};
chat.title = c.title;
output[c.id] = chat;
}
res.json(output);
}
public isProtected() {
return true;
}
}

View File

@ -0,0 +1,13 @@
import express from 'express';
import RequestHandler from "./base";
export default class TitleRequestHandler extends RequestHandler {
async handler(req: express.Request, res: express.Response) {
await this.context.database.setTitle(this.userID!, req.body.id, req.body.title);
res.json({ status: 'ok' });
}
public isProtected() {
return true;
}
}

107
server/src/index.ts 100644
View File

@ -0,0 +1,107 @@
require('dotenv').config()
import express from 'express';
import compression from 'compression';
import fs from 'fs';
import path from 'path';
import S3ObjectStore from './object-store/s3';
import { SQLiteAdapter } from './database/sqlite';
import SQLiteObjectStore from './object-store/sqlite';
import ObjectStore from './object-store/index';
import Database from './database/index';
import HealthRequestHandler from './endpoints/health';
import TitleRequestHandler from './endpoints/title';
import MessagesRequestHandler from './endpoints/messages';
import SyncRequestHandler from './endpoints/sync';
import ShareRequestHandler from './endpoints/share';
import BasicCompletionRequestHandler from './endpoints/completion/basic';
import StreamingCompletionRequestHandler from './endpoints/completion/streaming';
import SessionRequestHandler from './endpoints/session';
import GetShareRequestHandler from './endpoints/get-share';
import { configurePassport } from './passport';
import { configureAuth0 } from './auth0';
process.on('unhandledRejection', (reason, p) => {
console.error('Unhandled Rejection at: Promise', p, 'reason:', reason);
});
const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3001;
const webappPort = process.env.WEBAPP_PORT ? parseInt(process.env.WEBAPP_PORT, 10) : 3000;
const origins = (process.env.ALLOWED_ORIGINS || '').split(',');
if (process.env['GITPOD_WORKSPACE_URL']) {
origins.push(
process.env['GITPOD_WORKSPACE_URL']?.replace('https://', `https://${webappPort}-`)
);
}
export default class ChatServer {
app: express.Application;
objectStore: ObjectStore = process.env.S3_BUCKET ? new S3ObjectStore() : new SQLiteObjectStore();
database: Database = new SQLiteAdapter();
constructor() {
this.app = express();
this.app.use(express.urlencoded({ extended: false }));
if (process.env.AUTH0_CLIENT_ID && process.env.AUTH0_ISSUER && process.env.PUBLIC_URL) {
console.log('Configuring Auth0.');
configureAuth0(this);
} else {
console.log('Configuring Passport.');
configurePassport(this);
}
this.app.use(express.json({ limit: '1mb' }));
this.app.use(compression());
this.app.use((req, res, next) => {
res.set({
'Access-Control-Allow-Origin': origins.includes(req.headers.origin!) ? req.headers.origin : origins[0],
'Access-Control-Allow-Credentials': true.toString(),
'Access-Control-Allow-Methods': 'GET,POST,PUT,OPTIONS',
'Access-Control-Max-Age': 2592000,
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
});
next();
});
this.app.get('/chatapi/health', (req, res) => new HealthRequestHandler(this, req, res));
this.app.get('/chatapi/session', (req, res) => new SessionRequestHandler(this, req, res));
this.app.post('/chatapi/messages', (req, res) => new MessagesRequestHandler(this, req, res));
this.app.post('/chatapi/title', (req, res) => new TitleRequestHandler(this, req, res));
this.app.post('/chatapi/sync', (req, res) => new SyncRequestHandler(this, req, res));
this.app.get('/chatapi/share/:id', (req, res) => new GetShareRequestHandler(this, req, res));
this.app.post('/chatapi/share', (req, res) => new ShareRequestHandler(this, req, res));
if (process.env.ENABLE_SERVER_COMPLETION) {
this.app.post('/chatapi/completion', (req, res) => new BasicCompletionRequestHandler(this, req, res));
this.app.post('/chatapi/completion/streaming', (req, res) => new StreamingCompletionRequestHandler(this, req, res));
}
if (fs.existsSync('public')) {
this.app.use(express.static('public'));
// serve index.html for all other routes
this.app.get('*', (req, res) => {
res.sendFile('public/index.html', { root: path.resolve(__dirname, '..') });
});
}
}
async initialize() {
await this.objectStore.initialize();;
await this.database.initialize();;
try {
this.app.listen(port, () => {
console.log(`Listening on port ${port}.`);
});
} catch (e) {
console.log(e);
}
}
}
new ChatServer().initialize();

View File

@ -0,0 +1,5 @@
export default abstract class ObjectStore {
public async initialize() {}
public abstract get(key: string): Promise<string | null>;
public abstract put(key: string, value: string, contentType: string): Promise<void>;
}

View File

@ -0,0 +1,43 @@
import {
S3,
PutObjectCommand,
GetObjectCommand,
} from "@aws-sdk/client-s3";
import type {Readable} from 'stream';
import ObjectStore from "./index";
const bucket = process.env.S3_BUCKET;
const s3 = new S3({
region: process.env.DEFAULT_S3_REGION,
});
export default class S3ObjectStore extends ObjectStore {
public async get(key: string) {
const params = {
Bucket: bucket,
Key: key,
};
const data = await s3.send(new GetObjectCommand(params));
return await readStream(data.Body as Readable);
}
public async put(key: string, value: string, contentType: string) {
const params = {
Bucket: bucket,
Key: key,
Body: value,
ContentType: contentType,
StorageClass: "INTELLIGENT_TIERING",
};
await s3.send(new PutObjectCommand(params));
}
}
async function readStream(stream: Readable) {
const chunks: any[] = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks).toString('utf8');
}

View File

@ -0,0 +1,48 @@
import { verbose } from "sqlite3";
import ObjectStore from "./index";
const sqlite3 = verbose();
const db = new sqlite3.Database('./data/object-store.sqlite');
export interface StoredObject {
key: string;
value: string;
}
export default class SQLiteObjectStore extends ObjectStore {
public async initialize() {
db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS objects (
key TEXT PRIMARY KEY,
value TEXT
)`);
});
}
public async get(key: string): Promise<string | null> {
return new Promise((resolve, reject) => {
db.get(`SELECT * FROM objects WHERE key = ?`, [key], (err, row) => {
if (err) {
reject(err);
} else {
resolve(row?.value ?? null);
console.log(`[object-store:sqlite] retrieved object ${key}`)
}
});
});
}
public async put(key: string, value: string, contentType: string): Promise<void> {
return new Promise((resolve, reject) => {
db.run(`INSERT OR REPLACE INTO objects (key, value) VALUES (?, ?)`, [key, value], (err) => {
if (err) {
reject(err);
} else {
console.log(`[object-store:sqlite] stored object ${key}`)
resolve();
}
});
});
}
}

View File

@ -0,0 +1,84 @@
import crypto from 'crypto';
import passport from 'passport';
import session from 'express-session';
import createSQLiteSessionStore from 'connect-sqlite3';
import { Strategy as LocalStrategy } from 'passport-local';
import ChatServer from './index';
export function configurePassport(context: ChatServer) {
const SQLiteStore = createSQLiteSessionStore(session);
const sessionStore = new SQLiteStore({ db: 'sessions.db' });
passport.use(new LocalStrategy(async (email: string, password: string, cb: any) => {
const user = await context.database.getUser(email);
if (!user) {
return cb(null, false, { message: 'Incorrect username or password.' });
}
crypto.pbkdf2(password, user.salt, 310000, 32, 'sha256', (err, hashedPassword) => {
if (err) {
return cb(err);
}
if (!crypto.timingSafeEqual(user.passwordHash, hashedPassword)) {
return cb(null, false, { message: 'Incorrect username or password.' });
}
return cb(null, user);
});
}));
passport.serializeUser((user: any, cb: any) => {
process.nextTick(() => {
cb(null, { id: user.id, username: user.username });
});
});
passport.deserializeUser((user: any, cb: any) => {
process.nextTick(() => {
return cb(null, user);
});
});
context.app.use(session({
secret: process.env.AUTH_SECRET || 'keyboard cat',
resave: false,
saveUninitialized: false,
store: sessionStore as any,
}));
context.app.use(passport.authenticate('session'));
context.app.post('/chatapi/login', passport.authenticate('local', {
successRedirect: '/',
failureRedirect: '/?error=login'
}));
context.app.post('/chatapi/register', async (req, res, next) => {
const { username, password } = req.body;
const salt = crypto.randomBytes(32);
crypto.pbkdf2(password, salt, 310000, 32, 'sha256', async (err, hashedPassword) => {
if (err) {
return next(err);
}
try {
await context.database.createUser(username, hashedPassword, salt);
passport.authenticate('local')(req, res, () => {
res.redirect('/');
});
} catch (err) {
res.redirect('/?error=register');
}
});
});
context.app.all('/chatapi/logout', (req, res, next) => {
req.logout((err) => {
if (err) {
return next(err);
}
res.redirect('/');
});
});
}

View File

@ -0,0 +1,5 @@
import crypto from 'crypto';
export function randomID() {
return crypto.randomBytes(16).toString('hex');
}

View File

@ -0,0 +1,104 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
"moduleResolution": "Node16",
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

View File

@ -1,52 +0,0 @@
import EventEmitter from 'events';
import { Chat } from './types';
/*
Sync and login requires a backend implementation.
Example syncing:
const customBackend = new MyCustomBackend();
customBackend.register();
In your custom backend, load saved chats from the server and call chatManager.loadChat(chat);
chatManager.on('messages', async (messages: Message[]) => {
// send messages to server
});
chatManager.on('title', async (id: string, title: string) => {
// send updated chat title to server
});
*/
export let backend: {
current?: Backend | null
} = {};
export class Backend extends EventEmitter {
register() {
backend.current = this;
}
get isAuthenticated() {
// return whether the user is currently signed in
return false;
}
async signIn(options?: any) {
// sign in the user
}
async shareChat(chat: Chat): Promise<string|null> {
// create a public share from the chat, and return the share's ID
return null;
}
async getSharedChat(id: string): Promise<Chat|null> {
// load a publicly shared chat from its ID
return null;
}
}

View File

@ -1,42 +0,0 @@
import styled from '@emotion/styled';
import { SpotlightProvider } from '@mantine/spotlight';
import { useChatSpotlightProps } from '../spotlight';
import Header, { HeaderProps, SubHeader } from './header';
import MessageInput from './input';
import SettingsDrawer from './settings';
const Container = styled.div`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #292933;
color: white;
display: flex;
flex-direction: column;
overflow: hidden;
`;
export function Page(props: {
id: string;
headerProps?: HeaderProps;
showSubHeader?: boolean;
children: any;
}) {
const spotlightProps = useChatSpotlightProps();
return <SpotlightProvider {...spotlightProps}>
<Container key={props.id}>
<Header share={props.headerProps?.share}
canShare={props.headerProps?.canShare}
title={props.headerProps?.title}
onShare={props.headerProps?.onShare} />
{props.showSubHeader && <SubHeader />}
{props.children}
<MessageInput key={localStorage.getItem('openai-api-key')} />
<SettingsDrawer />
</Container>
</SpotlightProvider>;
}

View File

@ -1,134 +0,0 @@
# ⏳ tiktoken
tiktoken is a [BPE](https://en.wikipedia.org/wiki/Byte_pair_encoding) tokeniser for use with
OpenAI's models, forked from the original tiktoken library to provide NPM bindings for Node and other JS runtimes.
The open source version of `tiktoken` can be installed from NPM:
```
npm install @dqbd/tiktoken
```
## Usage
Basic usage follows:
```typescript
import assert from "node:assert";
import { get_encoding, encoding_for_model } from "@dqbd/tiktoken";
const enc = get_encoding("gpt2");
assert(
new TextDecoder().decode(enc.decode(enc.encode("hello world"))) ===
"hello world"
);
// To get the tokeniser corresponding to a specific model in the OpenAI API:
const enc = encoding_for_model("text-davinci-003");
// Extend existing encoding with custom special tokens
const enc = encoding_for_model("gpt2", {
"<|im_start|>": 100264,
"<|im_end|>": 100265,
});
// don't forget to free the encoder after it is not used
enc.free();
```
If desired, you can create a Tiktoken instance directly with custom ranks, special tokens and regex pattern:
```typescript
import { Tiktoken } from "../pkg";
import { readFileSync } from "fs";
const encoder = new Tiktoken(
readFileSync("./ranks/gpt2.tiktoken").toString("utf-8"),
{ "<|endoftext|>": 50256, "<|im_start|>": 100264, "<|im_end|>": 100265 },
"'s|'t|'re|'ve|'m|'ll|'d| ?\\p{L}+| ?\\p{N}+| ?[^\\s\\p{L}\\p{N}]+|\\s+(?!\\S)|\\s+"
);
```
## Compatibility
As this is a WASM library, there might be some issues with specific runtimes. If you encounter any issues, please open an issue.
| Runtime | Status | Notes |
| ------------------- | ------ | ------------------------------------------ |
| Node.js | ✅ | |
| Bun | ✅ | |
| Vite | ✅ | See [here](#vite) for notes |
| Next.js | ✅ | See [here](#nextjs) for notes |
| Vercel Edge Runtime | ✅ | See [here](#vercel-edge-runtime) for notes |
| Cloudflare Workers | 🚧 | Untested |
| Deno | ❌ | Currently unsupported |
### [Vite](#vite)
If you are using Vite, you will need to add both the `vite-plugin-wasm` and `vite-plugin-top-level-await`. Add the following to your `vite.config.js`:
```js
import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [wasm(), topLevelAwait()],
});
```
### [Next.js](#nextjs)
Both API routes and `/pages` are supported with the following configuration. To overcome issues with importing Node.js version, you can import the package from `@dqbd/tiktoken/bundler` instead.
```typescript
import { get_encoding } from "@dqbd/tiktoken/bundler";
import { NextApiRequest, NextApiResponse } from "next";
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const encoder = get_encoding("gpt2");
const message = encoder.encode(`Hello World ${Math.random()}`);
encoder.free();
return res.status(200).json({ message });
}
```
Additional Webpack configuration is required.
```typescript
const config = {
webpack(config, { isServer, dev }) {
config.experiments = {
asyncWebAssembly: true,
layers: true,
};
return config;
},
};
```
### [Vercel Edge Runtime](#vercel-edge-runtime)
Vercel Edge Runtime does support WASM modules by adding a `?module` suffix. Initialize the encoder with the following snippet:
```typescript
import wasm from "@dqbd/tiktoken/tiktoken_bg.wasm?module";
import { init, get_encoding } from "@dqbd/tiktoken/init";
export const config = { runtime: "edge" };
export default async function (req: Request) {
await init((imports) => WebAssembly.instantiate(wasm, imports));
const encoder = get_encoding("cl100k_base");
const tokens = encoder.encode("hello world");
encoder.free();
return new Response(`${encoder.encode("hello world")}`);
}
```
## Acknowledgements
- https://github.com/zurawiki/tiktoken-rs

View File

@ -1 +0,0 @@
export * from "./tiktoken";

View File

@ -1 +0,0 @@
export * from "./tiktoken";

View File

@ -1,8 +0,0 @@
/* tslint:disable */
/* eslint-disable */
export * from "./tiktoken";
export function init(
callback: (
imports: WebAssembly.Imports
) => Promise<WebAssembly.WebAssemblyInstantiatedSource | WebAssembly.Instance>
): Promise<void>;

View File

@ -1,20 +0,0 @@
import * as imports from "./tiktoken_bg.js";
export async function init(cb) {
const res = await cb({
"./tiktoken_bg.js": imports,
});
const instance =
"instance" in res && res.instance instanceof WebAssembly.Instance
? res.instance
: res instanceof WebAssembly.Instance
? res
: null;
if (instance == null) throw new Error("Missing instance");
imports.__wbg_set_wasm(instance.exports);
return imports;
}
export * from "./tiktoken_bg.js";

View File

@ -1,37 +0,0 @@
{
"name": "@dqbd/tiktoken",
"version": "1.0.0-alpha.1",
"description": "Javascript bindings for tiktoken",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/dqbd/tiktoken"
},
"dependencies": {
"node-fetch": "^3.3.0"
},
"files": [
"**/*"
],
"main": "tiktoken.node.js",
"types": "tiktoken.d.ts",
"exports": {
".": {
"types": "./tiktoken.d.ts",
"node": "./tiktoken.node.js",
"default": "./tiktoken.js"
},
"./bundler": {
"types": "./bundler.d.ts",
"default": "./bundler.js"
},
"./init": {
"types": "./init.d.ts",
"default": "./init.js"
},
"./tiktoken_bg.wasm": {
"types": "./tiktoken_bg.wasm.d.ts",
"default": "./tiktoken_bg.wasm"
}
}
}

View File

@ -1,108 +0,0 @@
/* tslint:disable */
/* eslint-disable */
export type TiktokenEncoding = "gpt2" | "r50k_base" | "p50k_base" | "p50k_edit" | "cl100k_base";
/**
* @param {TiktokenEncoding} encoding
* @param {Record<string, number>} [extend_special_tokens]
* @returns {Tiktoken}
*/
export function get_encoding(encoding: TiktokenEncoding, extend_special_tokens?: Record<string, number>): Tiktoken;
export type TiktokenModel =
| "text-davinci-003"
| "text-davinci-002"
| "text-davinci-001"
| "text-curie-001"
| "text-babbage-001"
| "text-ada-001"
| "davinci"
| "curie"
| "babbage"
| "ada"
| "code-davinci-002"
| "code-davinci-001"
| "code-cushman-002"
| "code-cushman-001"
| "davinci-codex"
| "cushman-codex"
| "text-davinci-edit-001"
| "code-davinci-edit-001"
| "text-embedding-ada-002"
| "text-similarity-davinci-001"
| "text-similarity-curie-001"
| "text-similarity-babbage-001"
| "text-similarity-ada-001"
| "text-search-davinci-doc-001"
| "text-search-curie-doc-001"
| "text-search-babbage-doc-001"
| "text-search-ada-doc-001"
| "code-search-babbage-code-001"
| "code-search-ada-code-001"
| "gpt2"
| "gpt-3.5-turbo"
| "gpt-3.5-turbo-0301";
/**
* @param {TiktokenModel} encoding
* @param {Record<string, number>} [extend_special_tokens]
* @returns {Tiktoken}
*/
export function encoding_for_model(model: TiktokenModel, extend_special_tokens?: Record<string, number>): Tiktoken;
/**
*/
export class Tiktoken {
free(): void;
/**
* @param {string} tiktoken_bfe
* @param {any} special_tokens
* @param {string} pat_str
*/
constructor(tiktoken_bfe: string, special_tokens: Record<string, number>, pat_str: string);
/**
* @param {string} text
* @param {any} allowed_special
* @param {any} disallowed_special
* @returns {Uint32Array}
*/
encode(text: string, allowed_special?: "all" | string[], disallowed_special?: "all" | string[]): Uint32Array;
/**
* @param {string} text
* @returns {Uint32Array}
*/
encode_ordinary(text: string): Uint32Array;
/**
* @param {string} text
* @param {any} allowed_special
* @param {any} disallowed_special
* @returns {any}
*/
encode_with_unstable(text: string, allowed_special?: "all" | string[], disallowed_special?: "all" | string[]): any;
/**
* @param {Uint8Array} bytes
* @returns {number}
*/
encode_single_token(bytes: Uint8Array): number;
/**
* @param {Uint32Array} tokens
* @returns {Uint8Array}
*/
decode(tokens: Uint32Array): Uint8Array;
/**
* @param {number} token
* @returns {Uint8Array}
*/
decode_single_token_bytes(token: number): Uint8Array;
/**
* @returns {any}
*/
token_byte_values(): Array<Array<number>>;
/**
*/
readonly name: string | undefined;
}

View File

@ -1,4 +0,0 @@
import * as wasm from "./tiktoken_bg.wasm";
import { __wbg_set_wasm } from "./tiktoken_bg.js";
__wbg_set_wasm(wasm);
export * from "./tiktoken_bg.js";

Some files were not shown because too many files have changed in this diff Show More