v0.2.0
This commit is contained in:
4
app/.gitignore
vendored
Normal file
4
app/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
package-lock.json
|
||||
.env
|
||||
build
|
36
app/craco.config.js
Normal file
36
app/craco.config.js
Normal 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
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
91
app/package.json
Normal file
91
app/package.json
Normal file
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"name": "Chat with GPT",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@auth0/auth0-spa-js": "^2.0.4",
|
||||
"@emotion/css": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
"@mantine/core": "^5.10.5",
|
||||
"@mantine/hooks": "^5.10.5",
|
||||
"@mantine/modals": "^5.10.5",
|
||||
"@mantine/notifications": "^5.10.5",
|
||||
"@mantine/spotlight": "^5.10.5",
|
||||
"@reduxjs/toolkit": "^1.9.3",
|
||||
"broadcast-channel": "^4.20.2",
|
||||
"csv": "^6.2.8",
|
||||
"expiry-set": "^1.0.0",
|
||||
"idb-keyval": "^6.2.0",
|
||||
"jshashes": "^1.0.8",
|
||||
"localforage": "^1.10.0",
|
||||
"match-sorter": "^6.3.1",
|
||||
"minisearch": "^6.0.1",
|
||||
"natural": "^6.2.0",
|
||||
"openai": "^3.2.1",
|
||||
"papaparse": "^5.4.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-intl": "^6.2.10",
|
||||
"react-markdown": "^8.0.5",
|
||||
"react-redux": "^8.0.5",
|
||||
"react-router-dom": "^6.8.2",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"redux-persist": "^6.0.0",
|
||||
"rehype-katex": "^6.0.2",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-math": "^5.1.1",
|
||||
"sentence-splitter": "^4.2.0",
|
||||
"slugify": "^1.6.5",
|
||||
"sort-by": "^0.0.2",
|
||||
"uuid": "^9.0.0",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "craco start",
|
||||
"build": "GENERATE_SOURCEMAP=false craco build",
|
||||
"test": "craco test",
|
||||
"eject": "craco eject",
|
||||
"extract": "formatjs extract 'src/**/*.ts*' --ignore='**/*.d.ts' --out-file public/lang/en-us.json --format simple --id-interpolation-pattern '[sha512:contenthash:base64:6]'"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@craco/craco": "^7.1.0",
|
||||
"@formatjs/cli": "^6.0.4",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/natural": "^5.1.2",
|
||||
"@types/node": "^16.18.13",
|
||||
"@types/papaparse": "^5.3.7",
|
||||
"@types/react": "^18.0.28",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@types/react-helmet": "^6.1.6",
|
||||
"@types/react-intl": "^3.0.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.6",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"babel-plugin-formatjs": "^10.4.0",
|
||||
"craco-wasm": "^0.0.1",
|
||||
"http-proxy-middleware": "^2.0.6",
|
||||
"react-scripts": "^5.0.1",
|
||||
"sass": "^1.58.3",
|
||||
"typescript": "^4.9.5"
|
||||
}
|
||||
}
|
BIN
app/public/favicon.ico
Normal file
BIN
app/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.8 KiB |
100
app/public/index.html
Normal file
100
app/public/index.html
Normal 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>
|
41
app/public/lang/en-us.json
Normal file
41
app/public/lang/en-us.json
Normal 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 "System" 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"
|
||||
}
|
BIN
app/public/logo192.png
Normal file
BIN
app/public/logo192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.2 KiB |
BIN
app/public/logo512.png
Normal file
BIN
app/public/logo512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.4 KiB |
8
app/public/manifest.json
Normal file
8
app/public/manifest.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"short_name": "Chat with GPT",
|
||||
"name": "Chat with GPT",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
3
app/public/robots.txt
Normal file
3
app/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
155
app/src/backend.ts
Normal file
155
app/src/backend.ts
Normal 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();
|
||||
}
|
364
app/src/chat-manager.ts
Normal file
364
app/src/chat-manager.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { BroadcastChannel } from 'broadcast-channel';
|
||||
import EventEmitter from 'events';
|
||||
import MiniSearch, { SearchResult } from 'minisearch'
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Chat, deserializeChat, getOpenAIMessageFromMessage, Message, Parameters, serializeChat, UserSubmittedMessage } from './types';
|
||||
import { MessageTree } from './message-tree';
|
||||
import { createStreamingChatCompletion } from './openai';
|
||||
import { createTitle } from './titles';
|
||||
import { ellipsize, sleep } from './utils';
|
||||
import * as idb from './idb';
|
||||
import { selectMessagesToSendSafely } from './tokenizer';
|
||||
|
||||
export const channel = new BroadcastChannel('chats');
|
||||
|
||||
export class ChatManager extends EventEmitter {
|
||||
public chats = new Map<string, Chat>();
|
||||
public search = new Search(this.chats);
|
||||
private loaded = false;
|
||||
private changed = false;
|
||||
private activeReplies = new Map<string, Message>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.load();
|
||||
|
||||
this.on('update', () => {
|
||||
this.changed = true;
|
||||
});
|
||||
|
||||
channel.onmessage = (message: {
|
||||
type: 'chat-update',
|
||||
data: string,
|
||||
}) => {
|
||||
const chat = deserializeChat(message.data);
|
||||
const id = chat.id;
|
||||
this.chats.set(id, chat);
|
||||
this.emit(id);
|
||||
};
|
||||
|
||||
(async () => {
|
||||
while (true) {
|
||||
await sleep(100);
|
||||
|
||||
if (this.loaded && this.changed) {
|
||||
this.changed = false;
|
||||
await this.save();
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
public async createChat(): Promise<string> {
|
||||
const id = uuidv4();
|
||||
|
||||
const chat: Chat = {
|
||||
id,
|
||||
messages: new MessageTree(),
|
||||
created: Date.now(),
|
||||
updated: Date.now(),
|
||||
};
|
||||
|
||||
this.chats.set(id, chat);
|
||||
this.search.update(chat);
|
||||
channel.postMessage({ type: 'chat-update', data: serializeChat(chat) });
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
public async sendMessage(message: UserSubmittedMessage) {
|
||||
const chat = this.chats.get(message.chatID);
|
||||
|
||||
if (!chat) {
|
||||
throw new Error('Chat not found');
|
||||
}
|
||||
|
||||
const newMessage: Message = {
|
||||
id: uuidv4(),
|
||||
parentID: message.parentID,
|
||||
chatID: chat.id,
|
||||
timestamp: Date.now(),
|
||||
role: 'user',
|
||||
content: message.content,
|
||||
done: true,
|
||||
};
|
||||
|
||||
chat.messages.addMessage(newMessage);
|
||||
chat.updated = Date.now();
|
||||
|
||||
this.emit(chat.id);
|
||||
this.emit('messages', [newMessage]);
|
||||
channel.postMessage({ type: 'chat-update', data: serializeChat(chat) });
|
||||
|
||||
const messages: Message[] = message.parentID
|
||||
? chat.messages.getMessageChainTo(message.parentID)
|
||||
: [];
|
||||
messages.push(newMessage);
|
||||
|
||||
await this.getReply(messages, message.requestedParameters);
|
||||
}
|
||||
|
||||
public async regenerate(message: Message, requestedParameters: Parameters) {
|
||||
const chat = this.chats.get(message.chatID);
|
||||
|
||||
if (!chat) {
|
||||
throw new Error('Chat not found');
|
||||
}
|
||||
|
||||
const messages: Message[] = message.parentID
|
||||
? chat.messages.getMessageChainTo(message.parentID)
|
||||
: [];
|
||||
|
||||
await this.getReply(messages, requestedParameters);
|
||||
}
|
||||
|
||||
private async getReply(messages: Message[], requestedParameters: Parameters) {
|
||||
const latestMessage = messages[messages.length - 1];
|
||||
const chat = this.chats.get(latestMessage.chatID);
|
||||
|
||||
if (!chat) {
|
||||
throw new Error('Chat not found');
|
||||
}
|
||||
|
||||
const reply: Message = {
|
||||
id: uuidv4(),
|
||||
parentID: latestMessage.id,
|
||||
chatID: latestMessage.chatID,
|
||||
timestamp: Date.now(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
done: false,
|
||||
};
|
||||
this.activeReplies.set(reply.id, reply);
|
||||
|
||||
chat.messages.addMessage(reply);
|
||||
chat.updated = Date.now();
|
||||
|
||||
this.emit(chat.id);
|
||||
channel.postMessage({ type: 'chat-update', data: serializeChat(chat) });
|
||||
|
||||
const messagesToSend = selectMessagesToSendSafely(messages.map(getOpenAIMessageFromMessage));
|
||||
|
||||
const { emitter, cancel } = await createStreamingChatCompletion(messagesToSend, requestedParameters);
|
||||
|
||||
let lastChunkReceivedAt = Date.now();
|
||||
|
||||
const onError = () => {
|
||||
if (reply.done) {
|
||||
return;
|
||||
}
|
||||
clearInterval(timer);
|
||||
cancel();
|
||||
reply.content += "\n\nI'm sorry, I'm having trouble connecting to OpenAI. Please make sure you've entered your OpenAI API key correctly and try again.";
|
||||
reply.content = reply.content.trim();
|
||||
reply.done = true;
|
||||
this.activeReplies.delete(reply.id);
|
||||
chat.messages.updateMessage(reply);
|
||||
chat.updated = Date.now();
|
||||
this.emit(chat.id);
|
||||
this.emit('messages', [reply]);
|
||||
channel.postMessage({ type: 'chat-update', data: serializeChat(chat) });
|
||||
};
|
||||
|
||||
let timer = setInterval(() => {
|
||||
const sinceLastChunk = Date.now() - lastChunkReceivedAt;
|
||||
if (sinceLastChunk > 10000 && !reply.done) {
|
||||
onError();
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
emitter.on('error', () => {
|
||||
if (!reply.content && !reply.done) {
|
||||
lastChunkReceivedAt = Date.now();
|
||||
onError();
|
||||
}
|
||||
});
|
||||
|
||||
emitter.on('data', (data: string) => {
|
||||
if (reply.done) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
lastChunkReceivedAt = Date.now();
|
||||
reply.content = data;
|
||||
chat.messages.updateMessage(reply);
|
||||
this.emit(chat.id);
|
||||
channel.postMessage({ type: 'chat-update', data: serializeChat(chat) });
|
||||
});
|
||||
|
||||
emitter.on('done', async () => {
|
||||
if (reply.done) {
|
||||
return;
|
||||
}
|
||||
clearInterval(timer);
|
||||
lastChunkReceivedAt = Date.now();
|
||||
reply.done = true;
|
||||
this.activeReplies.delete(reply.id);
|
||||
chat.messages.updateMessage(reply);
|
||||
chat.updated = Date.now();
|
||||
this.emit(chat.id);
|
||||
this.emit('messages', [reply]);
|
||||
this.emit('update');
|
||||
channel.postMessage({ type: 'chat-update', data: serializeChat(chat) });
|
||||
setTimeout(() => this.search.update(chat), 500);
|
||||
|
||||
if (!chat.title) {
|
||||
chat.title = await createTitle(chat, requestedParameters.apiKey);
|
||||
if (chat.title) {
|
||||
this.emit(chat.id);
|
||||
this.emit('title', chat.id, chat.title);
|
||||
this.emit('update');
|
||||
channel.postMessage({ type: 'chat-update', data: serializeChat(chat) });
|
||||
setTimeout(() => this.search.update(chat), 500);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async save() {
|
||||
const serialized = Array.from(this.chats.values())
|
||||
.map((c) => {
|
||||
const serialized = { ...c } as any;
|
||||
serialized.messages = c.messages.serialize();
|
||||
return 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() {
|
||||
const serialized = await idb.get('chats');
|
||||
if (serialized) {
|
||||
for (const chat of serialized) {
|
||||
const messages = new MessageTree();
|
||||
for (const m of chat.messages) {
|
||||
messages.addMessage(m);
|
||||
}
|
||||
chat.messages = messages;
|
||||
this.loadChat(chat);
|
||||
}
|
||||
this.emit('update');
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
public loadChat(chat: Chat) {
|
||||
if (!chat?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = this.chats.get(chat.id);
|
||||
if (existing && existing.title && !chat.title) {
|
||||
chat.title = existing.title;
|
||||
}
|
||||
|
||||
chat.created = chat.messages.first?.timestamp || 0;
|
||||
chat.updated = chat.messages.mostRecentLeaf().timestamp;
|
||||
|
||||
this.chats.set(chat.id, chat);
|
||||
this.search.update(chat);
|
||||
this.emit(chat.id);
|
||||
}
|
||||
|
||||
public get(id: string): Chat | undefined {
|
||||
return this.chats.get(id);
|
||||
}
|
||||
}
|
||||
|
||||
export class Search {
|
||||
private index = new MiniSearch({
|
||||
fields: ['value'],
|
||||
storeFields: ['id', 'value'],
|
||||
});
|
||||
|
||||
constructor(private chats: Map<string, Chat>) {
|
||||
}
|
||||
|
||||
public update(chat: Chat) {
|
||||
const messages = chat.messages.serialize()
|
||||
.map((m: Message) => m.content)
|
||||
.join('\n\n');
|
||||
const doc = {
|
||||
id: chat.id,
|
||||
value: chat.title + '\n\n' + messages,
|
||||
};
|
||||
if (!this.index.has(chat.id)) {
|
||||
this.index.add(doc);
|
||||
} else {
|
||||
this.index.replace(doc);
|
||||
}
|
||||
}
|
||||
|
||||
public query(query: string) {
|
||||
if (!query?.trim().length) {
|
||||
const searchResults = Array.from(this.chats.values())
|
||||
.sort((a, b) => b.updated - a.updated)
|
||||
.slice(0, 10);
|
||||
const results = this.processSearchResults(searchResults);
|
||||
return results;
|
||||
}
|
||||
|
||||
let searchResults = this.index.search(query, { fuzzy: 0.2 });
|
||||
let output = this.processSearchResults(searchResults);
|
||||
|
||||
if (!output.length) {
|
||||
searchResults = this.index.search(query, { prefix: true });
|
||||
output = this.processSearchResults(searchResults);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private processSearchResults(searchResults: SearchResult[] | Chat[]) {
|
||||
const output: any[] = [];
|
||||
for (const item of searchResults) {
|
||||
const chatID = item.id;
|
||||
let chat = this.chats.get(chatID);
|
||||
if (!chat) {
|
||||
continue;
|
||||
}
|
||||
|
||||
chat = { ...chat };
|
||||
|
||||
let description = chat.messages?.first?.content || '';
|
||||
description = ellipsize(description, 400);
|
||||
|
||||
if (!chat.title) {
|
||||
chat.title = ellipsize(description, 100);
|
||||
}
|
||||
|
||||
if (!chat.title || !description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
output.push({
|
||||
chatID,
|
||||
title: chat.title,
|
||||
description,
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
const chatManager = new ChatManager();
|
||||
export default chatManager;
|
100
app/src/components/auth/modals.tsx
Normal file
100
app/src/components/auth/modals.tsx
Normal 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>
|
||||
}
|
215
app/src/components/header.tsx
Normal file
215
app/src/components/header.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import styled from '@emotion/styled';
|
||||
import Helmet from 'react-helmet';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useSpotlight } from '@mantine/spotlight';
|
||||
import { Burger, Button, ButtonProps } from '@mantine/core';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAppContext } from '../context';
|
||||
import { backend } from '../backend';
|
||||
import { MenuItem, secondaryMenu } from '../menus';
|
||||
import { useAppDispatch, useAppSelector } from '../store';
|
||||
import { selectOpenAIApiKey } from '../store/api-keys';
|
||||
import { setTab } from '../store/settings-ui';
|
||||
import { selectSidebarOpen, toggleSidebar } from '../store/sidebar';
|
||||
import { openLoginModal } from '../store/ui';
|
||||
|
||||
const HeaderContainer = styled.div`
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
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);
|
||||
}
|
||||
|
||||
h1 {
|
||||
@media (max-width: 40em) {
|
||||
width: 100%;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
font-family: "Work Sans", sans-serif;
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
|
||||
animation: fadein 0.5s;
|
||||
animation-fill-mode: forwards;
|
||||
|
||||
strong {
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
font-size: 70%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes fadein {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
i + span, .mantine-Button-root span.hide-on-mobile {
|
||||
@media (max-width: 40em) {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
}
|
||||
}
|
||||
|
||||
.mantine-Button-root {
|
||||
@media (max-width: 40em) {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const SubHeaderContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-family: "Work Sans", sans-serif;
|
||||
line-height: 1.7;
|
||||
opacity: 0.7;
|
||||
margin: 0.5rem 0.5rem 0 0.5rem;
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
a {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.fa + span {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
}
|
||||
`;
|
||||
|
||||
function HeaderButton(props: ButtonProps & { icon?: string, onClick?: any, children?: any }) {
|
||||
return (
|
||||
<Button size='xs'
|
||||
variant={props.variant || 'subtle'}
|
||||
onClick={props.onClick}>
|
||||
{props.icon && <i className={'fa fa-' + props.icon} />}
|
||||
{props.children && <span>
|
||||
{props.children}
|
||||
</span>}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export interface HeaderProps {
|
||||
title?: any;
|
||||
onShare?: () => void;
|
||||
share?: boolean;
|
||||
canShare?: boolean;
|
||||
}
|
||||
|
||||
export default function Header(props: HeaderProps) {
|
||||
const context = useAppContext();
|
||||
const navigate = useNavigate();
|
||||
const spotlight = useSpotlight();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const openAIApiKey = useAppSelector(selectOpenAIApiKey);
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const sidebarOpen = useAppSelector(selectSidebarOpen);
|
||||
const onBurgerClick = useCallback(() => dispatch(toggleSidebar()), [dispatch]);
|
||||
|
||||
const burgerLabel = sidebarOpen
|
||||
? intl.formatMessage({ defaultMessage: "Close sidebar" })
|
||||
: intl.formatMessage({ defaultMessage: "Open sidebar" });
|
||||
|
||||
const onNewChat = useCallback(async () => {
|
||||
setLoading(true);
|
||||
navigate(`/`);
|
||||
setLoading(false);
|
||||
}, [navigate]);
|
||||
|
||||
const openSettings = useCallback(() => {
|
||||
dispatch(setTab(openAIApiKey ? 'options' : 'user'));
|
||||
}, [openAIApiKey, dispatch]);
|
||||
|
||||
const header = useMemo(() => (
|
||||
<HeaderContainer className={context.isHome ? 'shaded' : ''}>
|
||||
<Helmet>
|
||||
<title>
|
||||
{props.title ? `${props.title} - ` : ''}
|
||||
{intl.formatMessage({ defaultMessage: "Chat with GPT - Unofficial ChatGPT app" })}
|
||||
</title>
|
||||
</Helmet>
|
||||
{!sidebarOpen && <Burger opened={sidebarOpen} onClick={onBurgerClick} aria-label={burgerLabel} transitionDuration={0} />}
|
||||
{context.isHome && <h2>{intl.formatMessage({ defaultMessage: "Chat with GPT" })}</h2>}
|
||||
<div className="spacer" />
|
||||
<HeaderButton icon="search" onClick={spotlight.openSpotlight} />
|
||||
<HeaderButton icon="gear" onClick={openSettings} />
|
||||
{backend.current && !props.share && props.canShare && typeof navigator.share !== 'undefined' && <HeaderButton icon="share" onClick={props.onShare}>
|
||||
<FormattedMessage defaultMessage="Share" />
|
||||
</HeaderButton>}
|
||||
{backend.current && !context.authenticated && (
|
||||
<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">
|
||||
<FormattedMessage defaultMessage="New Chat" />
|
||||
</HeaderButton>
|
||||
</HeaderContainer>
|
||||
), [sidebarOpen, onBurgerClick, props.title, props.share, props.canShare, props.onShare, openSettings, onNewChat, loading, context.authenticated, context.isHome, context.isShare, spotlight.openSpotlight]);
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
function SubHeaderMenuItem(props: { item: MenuItem }) {
|
||||
return (
|
||||
<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} />}
|
||||
<span>{props.item.label}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubHeader(props: any) {
|
||||
const elem = useMemo(() => (
|
||||
<SubHeaderContainer>
|
||||
<div className="spacer" />
|
||||
{secondaryMenu.map(item => <SubHeaderMenuItem item={item} key={item.link} />)}
|
||||
</SubHeaderContainer>
|
||||
), []);
|
||||
|
||||
return elem;
|
||||
}
|
138
app/src/components/input.tsx
Normal file
138
app/src/components/input.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Button, ActionIcon, Textarea, Loader } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAppContext } from '../context';
|
||||
import { useAppDispatch, useAppSelector } from '../store';
|
||||
import { selectMessage, setMessage } from '../store/message';
|
||||
import { selectTemperature } from '../store/parameters';
|
||||
import { openSystemPromptPanel, openTemperaturePanel } from '../store/settings-ui';
|
||||
|
||||
const Container = styled.div`
|
||||
background: #292933;
|
||||
border-top: thin solid #393933;
|
||||
padding: 1rem 1rem 0 1rem;
|
||||
|
||||
.inner {
|
||||
max-width: 50rem;
|
||||
margin: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.settings-button {
|
||||
margin: 0.5rem -0.4rem 0.5rem 1rem;
|
||||
font-size: 0.7rem;
|
||||
color: #999;
|
||||
}
|
||||
`;
|
||||
|
||||
export declare type OnSubmit = (name?: string) => Promise<boolean>;
|
||||
|
||||
export interface MessageInputProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function MessageInput(props: MessageInputProps) {
|
||||
const temperature = useAppSelector(selectTemperature);
|
||||
const message = useAppSelector(selectMessage);
|
||||
|
||||
const hasVerticalSpace = useMediaQuery('(min-height: 1000px)');
|
||||
|
||||
const context = useAppContext();
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const onCustomizeSystemPromptClick = useCallback(() => dispatch(openSystemPromptPanel()), [dispatch]);
|
||||
const onTemperatureClick = useCallback(() => dispatch(openTemperaturePanel()), [dispatch]);
|
||||
const onChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
dispatch(setMessage(e.target.value));
|
||||
}, [dispatch]);
|
||||
|
||||
const pathname = useLocation().pathname;
|
||||
|
||||
const onSubmit = useCallback(async () => {
|
||||
if (await context.onNewMessage(message)) {
|
||||
dispatch(setMessage(''));
|
||||
}
|
||||
}, [context, message, dispatch]);
|
||||
|
||||
const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && e.shiftKey === false && !props.disabled) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}, [onSubmit, props.disabled]);
|
||||
|
||||
const rightSection = useMemo(() => {
|
||||
return (
|
||||
<div style={{
|
||||
opacity: '0.8',
|
||||
paddingRight: '0.5rem',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
}}>
|
||||
{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>
|
||||
);
|
||||
}, [onSubmit, props.disabled, context.generating]);
|
||||
|
||||
const disabled = context.generating;
|
||||
|
||||
const isLandingPage = pathname === '/';
|
||||
if (context.isShare || (!isLandingPage && !context.id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Container>
|
||||
<div className="inner">
|
||||
<Textarea disabled={props.disabled || disabled}
|
||||
autosize
|
||||
minRows={(hasVerticalSpace || context.isHome) ? 3 : 2}
|
||||
maxRows={12}
|
||||
placeholder={intl.formatMessage({ defaultMessage: "Enter a message here..." })}
|
||||
value={message}
|
||||
onChange={onChange}
|
||||
rightSection={rightSection}
|
||||
rightSectionWidth={context.generating ? 100 : 55}
|
||||
onKeyDown={onKeyDown} />
|
||||
<div>
|
||||
<Button variant="subtle"
|
||||
className="settings-button"
|
||||
size="xs"
|
||||
compact
|
||||
onClick={onCustomizeSystemPromptClick}>
|
||||
<span>
|
||||
<FormattedMessage defaultMessage={"Customize system prompt"} />
|
||||
</span>
|
||||
</Button>
|
||||
<Button variant="subtle"
|
||||
className="settings-button"
|
||||
size="xs"
|
||||
compact
|
||||
onClick={onTemperatureClick}>
|
||||
<span>
|
||||
<FormattedMessage defaultMessage="Temperature: {temperature, number, ::.0}"
|
||||
values={{ temperature }} />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Container>;
|
||||
}
|
65
app/src/components/markdown.tsx
Normal file
65
app/src/components/markdown.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import { Button, CopyButton } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
export interface MarkdownProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Markdown(props: MarkdownProps) {
|
||||
const intl = useIntl();
|
||||
|
||||
const classes = useMemo(() => {
|
||||
const classes = ['prose', 'dark:prose-invert'];
|
||||
|
||||
if (props.className) {
|
||||
classes.push(props.className);
|
||||
}
|
||||
|
||||
return classes;
|
||||
}, [props.className])
|
||||
|
||||
const elem = useMemo(() => (
|
||||
<div className={classes.join(' ')}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
return !inline ? (
|
||||
<div>
|
||||
<CopyButton value={String(children)}>
|
||||
{({ copy, copied }) => (
|
||||
<Button variant="subtle" size="sm" compact onClick={copy}>
|
||||
<i className="fa fa-clipboard" />
|
||||
<span>{copied ? <FormattedMessage defaultMessage="Copied" /> : <FormattedMessage defaultMessage="Copy" />}</span>
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
<SyntaxHighlighter
|
||||
children={String(children).replace(/\n$/, '')}
|
||||
style={vscDarkPlus as any}
|
||||
language={match?.[1] || 'text'}
|
||||
PreTag="div"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
}}>{props.content}</ReactMarkdown>
|
||||
</div>
|
||||
), [props.content, classes, intl]);
|
||||
|
||||
return elem;
|
||||
}
|
295
app/src/components/message.tsx
Normal file
295
app/src/components/message.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Button, CopyButton, Loader, Textarea } from '@mantine/core';
|
||||
|
||||
import { Message } from "../types";
|
||||
import { share } from '../utils';
|
||||
import { ElevenLabsReaderButton } from '../tts/elevenlabs';
|
||||
import { Markdown } from './markdown';
|
||||
import { useAppContext } from '../context';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
// hide for everyone but screen readers
|
||||
const SROnly = styled.span`
|
||||
position: fixed;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
`;
|
||||
|
||||
const Container = styled.div`
|
||||
&.by-user {
|
||||
background: #22232b;
|
||||
}
|
||||
|
||||
&.by-assistant {
|
||||
background: #292933;
|
||||
}
|
||||
|
||||
&.by-assistant + &.by-assistant, &.by-user + &.by-user {
|
||||
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;
|
||||
padding: 1.618rem;
|
||||
|
||||
@media (max-width: 40em) {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.inner {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.content {
|
||||
font-family: "Open Sans", sans-serif;
|
||||
margin-top: 0rem;
|
||||
max-width: 100%;
|
||||
|
||||
* {
|
||||
color: white;
|
||||
}
|
||||
|
||||
p, ol, ul, li, h1, h2, h3, h4, h5, h6, img, blockquote, &>pre {
|
||||
max-width: 50rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 50rem;
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
ol {
|
||||
counter-reset: list-item;
|
||||
|
||||
li {
|
||||
counter-increment: list-item;
|
||||
}
|
||||
}
|
||||
|
||||
em, i {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
code {
|
||||
&, * {
|
||||
font-family: "Fira Code", monospace !important;
|
||||
}
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
margin-top: 1.618rem;
|
||||
border-spacing: 0px;
|
||||
border-collapse: collapse;
|
||||
border: thin solid rgba(255, 255, 255, 0.1);
|
||||
width: 100%;
|
||||
max-width: 55rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
td + td, th + th {
|
||||
border-left: thin solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
tr {
|
||||
border-top: thin solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
table td,
|
||||
table th {
|
||||
padding: 0.618rem 1rem;
|
||||
}
|
||||
th {
|
||||
font-weight: 600;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.metadata {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
font-family: "Work Sans", sans-serif;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
opacity: 0.6;
|
||||
max-width: 50rem;
|
||||
margin-bottom: 0.0rem;
|
||||
margin-right: -0.5rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
span + span {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.fa {
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.fa + span {
|
||||
margin-left: 0.2em;
|
||||
}
|
||||
|
||||
.mantine-Button-root {
|
||||
color: #ccc;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
|
||||
.mantine-Button-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fa {
|
||||
margin-right: 0.5em;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
`;
|
||||
|
||||
const EndOfChatMarker = styled.div`
|
||||
position: absolute;
|
||||
bottom: calc(-1.618rem - 0.5rem);
|
||||
left: 50%;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
margin-left: -0.25rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
`;
|
||||
|
||||
const Editor = styled.div`
|
||||
max-width: 50rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.mantine-Button-root {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
function InlineLoader() {
|
||||
return (
|
||||
<Loader variant="dots" size="xs" style={{
|
||||
marginLeft: '1rem',
|
||||
position: 'relative',
|
||||
top: '-0.2rem',
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
export default function MessageComponent(props: { message: Message, last: boolean, share?: boolean }) {
|
||||
const context = useAppContext();
|
||||
const [editing, setEditing] = useState(false);
|
||||
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(() => {
|
||||
if (props.message.role === 'system') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className={"message by-" + props.message.role}>
|
||||
<div className="inner">
|
||||
<div className="metadata">
|
||||
<span>
|
||||
<strong>
|
||||
{getRoleName(props.message.role, props.share)}<SROnly>:</SROnly>
|
||||
</strong>
|
||||
{props.message.role === 'assistant' && props.last && !props.message.done && <InlineLoader />}
|
||||
</span>
|
||||
{props.message.done && <ElevenLabsReaderButton selector={'.content-' + props.message.id} />}
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
<CopyButton value={props.message.content}>
|
||||
{({ copy, copied }) => (
|
||||
<Button variant="subtle" size="sm" compact onClick={copy} style={{ marginLeft: '1rem' }}>
|
||||
<i className="fa fa-clipboard" />
|
||||
<span>{copied ? <FormattedMessage defaultMessage="Copied" /> : <FormattedMessage defaultMessage="Copy" />}</span>
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
{typeof navigator.share !== 'undefined' && (
|
||||
<Button variant="subtle" size="sm" compact onClick={() => share(props.message.content)}>
|
||||
<i className="fa fa-share" />
|
||||
<span>
|
||||
<FormattedMessage defaultMessage="Share" />
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{!context.isShare && props.message.role === 'user' && (
|
||||
<Button variant="subtle" size="sm" compact onClick={() => {
|
||||
setContent(props.message.content);
|
||||
setEditing(v => !v);
|
||||
}}>
|
||||
<i className="fa fa-edit" />
|
||||
<span>{editing ? <FormattedMessage defaultMessage="Cancel" /> : <FormattedMessage defaultMessage="Edit" />}</span>
|
||||
</Button>
|
||||
)}
|
||||
{!context.isShare && props.message.role === 'assistant' && (
|
||||
<Button variant="subtle" size="sm" compact onClick={() => context.regenerateMessage(props.message)}>
|
||||
<i className="fa fa-refresh" />
|
||||
<span>
|
||||
<FormattedMessage defaultMessage="Regenerate" />
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!editing && <Markdown content={props.message.content} className={"content content-" + props.message.id} />}
|
||||
{editing && (<Editor>
|
||||
<Textarea value={content}
|
||||
onChange={e => setContent(e.currentTarget.value)}
|
||||
autosize={true} />
|
||||
<Button variant="light" onClick={() => context.editMessage(props.message, content)}>
|
||||
<FormattedMessage defaultMessage="Save changes" />
|
||||
</Button>
|
||||
<Button variant="subtle" onClick={() => setEditing(false)}>
|
||||
<FormattedMessage defaultMessage="Cancel" />
|
||||
</Button>
|
||||
</Editor>)}
|
||||
</div>
|
||||
{props.last && <EndOfChatMarker />}
|
||||
</Container>
|
||||
)
|
||||
}, [props.last, props.share, editing, content, context, props.message, props.message.content]);
|
||||
|
||||
return elem;
|
||||
}
|
90
app/src/components/page.tsx
Normal file
90
app/src/components/page.tsx
Normal 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>;
|
||||
}
|
||||
|
79
app/src/components/pages/about.tsx
Normal file
79
app/src/components/pages/about.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import styled from "@emotion/styled";
|
||||
import { Markdown } from "../markdown";
|
||||
import { Page } from "../page";
|
||||
|
||||
const title = "Learn about Chat with GPT";
|
||||
|
||||
const content = `
|
||||
# About Chat with GPT
|
||||
|
||||
Chat with GPT is an open-source, unofficial ChatGPT app with extra features and more ways to customize your experience.
|
||||
|
||||
ChatGPT is an AI assistant developed by OpenAI. It's designed to understand natural language and generate human-like responses to a wide range of questions and prompts. ChatGPT has been trained on a massive dataset of text from the internet, which allows it to draw on a vast amount of knowledge and information to answer questions and engage in conversation. ChatGPT is constantly being improved. Feel free to ask it anything!
|
||||
|
||||
[Join the Discord.](https://discord.gg/mS5QvKykvv)
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 **Fast** response times.
|
||||
- 🔎 **Search** through your past chat conversations.
|
||||
- 📄 View and customize the System Prompt - the **secret prompt** the system shows the AI before your messages.
|
||||
- 🌡 Adjust the **creativity and randomness** of responses by setting the Temperature setting. Higher temperature means more creativity.
|
||||
- 💬 Give ChatGPT AI a **realistic human voice** by connecting your ElevenLabs text-to-speech account.
|
||||
- ✉ **Share** your favorite chat sessions online using public share URLs.
|
||||
- 📋 Easily **copy-and-paste** ChatGPT messages.
|
||||
- 🖼 **Full markdown support** including code, tables, and math.
|
||||
- 🫰 Pay for only what you use with the ChatGPT API.
|
||||
|
||||
## Bring your own API keys
|
||||
|
||||
### OpenAI
|
||||
|
||||
To get started with Chat with GPT, you will need to add your OpenAI API key on the settings screen. Click "Connect your OpenAI account to get started" on the home page to begin. Once you have added your API key, you can start chatting with ChatGPT.
|
||||
|
||||
Your API key is stored only on your device and is never transmitted to anyone except OpenAI. Please note that OpenAI API key usage is billed at a pay-as-you-go rate, separate from your ChatGPT subscription.
|
||||
|
||||
### ElevenLabs
|
||||
|
||||
To use the realistic AI text-to-speech feature, you will need to add your ElevenLabs API key by clicking "Play" next to any message.
|
||||
|
||||
Your API key is stored only on your device and never transmitted to anyone except ElevenLabs.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Edit messages (coming soon)
|
||||
- Regenerate messages (coming soon)
|
||||
- [Suggest feature ideas on the Discord](https://discord.gg/mS5QvKykvv)
|
||||
`;
|
||||
|
||||
const Container = styled.div`
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 3rem;
|
||||
|
||||
.inner {
|
||||
max-width: 50rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
font-weight: "Work Sans", sans-serif;
|
||||
|
||||
* {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
border-bottom: thin solid rgba(255, 255, 255, 0.2);
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function AboutPage(props: any) {
|
||||
return <Page id={'about'} headerProps={{ title }}>
|
||||
<Container>
|
||||
<Markdown content={content} className='inner' />
|
||||
</Container>
|
||||
</Page>;
|
||||
}
|
100
app/src/components/pages/chat.tsx
Normal file
100
app/src/components/pages/chat.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import styled from '@emotion/styled';
|
||||
import slugify from 'slugify';
|
||||
import { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Loader } from '@mantine/core';
|
||||
|
||||
import Message from '../message';
|
||||
import { useAppContext } from '../../context';
|
||||
import { backend } from '../../backend';
|
||||
import { Page } from '../page';
|
||||
|
||||
const Messages = styled.div`
|
||||
max-height: 100%;
|
||||
flex-grow: 1;
|
||||
overflow-y: scroll;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const EmptyMessage = styled.div`
|
||||
flex-grow: 1;
|
||||
padding-bottom: 5vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-family: "Work Sans", sans-serif;
|
||||
line-height: 1.7;
|
||||
gap: 1rem;
|
||||
`;
|
||||
|
||||
export default function ChatPage(props: any) {
|
||||
const { id } = useParams();
|
||||
const context = useAppContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (props.share || !context.currentChat.chatLoadedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldScroll = (Date.now() - context.currentChat.chatLoadedAt) > 5000;
|
||||
|
||||
if (!shouldScroll) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.querySelector('#messages') as HTMLElement;
|
||||
const messages = document.querySelectorAll('#messages .message');
|
||||
|
||||
if (messages.length) {
|
||||
const latest = messages[messages.length - 1] as HTMLElement;
|
||||
const offset = Math.max(0, latest.offsetTop - 100);
|
||||
setTimeout(() => {
|
||||
container?.scrollTo({ top: offset, behavior: 'smooth' });
|
||||
}, 500);
|
||||
}
|
||||
}, [context.currentChat?.chatLoadedAt, context.currentChat?.messagesToDisplay.length, props.share]);
|
||||
|
||||
const messagesToDisplay = context.currentChat.messagesToDisplay;
|
||||
|
||||
const shouldShowChat = id && context.currentChat.chat && !!messagesToDisplay.length;
|
||||
|
||||
return <Page id={id || 'landing'}
|
||||
headerProps={{
|
||||
share: context.isShare,
|
||||
canShare: messagesToDisplay.length > 1,
|
||||
title: (id && messagesToDisplay.length) ? context.currentChat.chat?.title : null,
|
||||
onShare: async () => {
|
||||
if (context.currentChat.chat) {
|
||||
const id = await backend.current?.shareChat(context.currentChat.chat);
|
||||
if (id) {
|
||||
const slug = context.currentChat.chat.title
|
||||
? '/' + slugify(context.currentChat.chat.title.toLocaleLowerCase())
|
||||
: '';
|
||||
const url = window.location.origin + '/s/' + id + slug;
|
||||
navigator.share?.({
|
||||
title: context.currentChat.chat.title || undefined,
|
||||
url,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
}}>
|
||||
<Messages id="messages">
|
||||
{shouldShowChat && (
|
||||
<div style={{ paddingBottom: '4.5rem' }}>
|
||||
{messagesToDisplay.map((message) => (
|
||||
<Message key={message.id}
|
||||
message={message}
|
||||
share={props.share}
|
||||
last={context.currentChat.chat!.messages.leafs.some(n => n.id === message.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!shouldShowChat && <EmptyMessage>
|
||||
<Loader variant="dots" />
|
||||
</EmptyMessage>}
|
||||
</Messages>
|
||||
</Page>;
|
||||
}
|
39
app/src/components/pages/landing.tsx
Normal file
39
app/src/components/pages/landing.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Button } from '@mantine/core';
|
||||
import { useCallback } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useAppDispatch, useAppSelector } from '../../store';
|
||||
import { selectOpenAIApiKey } from '../../store/api-keys';
|
||||
import { openOpenAIApiKeyPanel } from '../../store/settings-ui';
|
||||
import { Page } from '../page';
|
||||
|
||||
const Container = styled.div`
|
||||
flex-grow: 1;
|
||||
padding-bottom: 5vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-family: "Work Sans", sans-serif;
|
||||
line-height: 1.7;
|
||||
gap: 1rem;
|
||||
`;
|
||||
|
||||
export default function LandingPage(props: any) {
|
||||
const openAIApiKey = useAppSelector(selectOpenAIApiKey);
|
||||
const dispatch = useAppDispatch();
|
||||
const onConnectButtonClick = useCallback(() => dispatch(openOpenAIApiKeyPanel()), [dispatch]);
|
||||
|
||||
return <Page id={'landing'} showSubHeader={true}>
|
||||
<Container>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage={'Hello, how can I help you today?'} />
|
||||
</p>
|
||||
{!openAIApiKey && (
|
||||
<Button size="xs" variant="light" compact onClick={onConnectButtonClick}>
|
||||
<FormattedMessage defaultMessage={'Connect your OpenAI account to get started'} />
|
||||
</Button>
|
||||
)}
|
||||
</Container>
|
||||
</Page>;
|
||||
}
|
111
app/src/components/settings/index.tsx
Normal file
111
app/src/components/settings/index.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Button, Drawer, Tabs } from "@mantine/core";
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useCallback } from 'react';
|
||||
import UserOptionsTab from './user';
|
||||
import GenerationOptionsTab from './options';
|
||||
import { useAppDispatch, useAppSelector } from '../../store';
|
||||
import { closeSettingsUI, selectSettingsTab, setTab } from '../../store/settings-ui';
|
||||
import SpeechOptionsTab from './speech';
|
||||
|
||||
const Container = styled.div`
|
||||
padding: .4rem 1rem 1rem 1rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
|
||||
@media (max-width: 40em) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mantine-Tabs-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100% - 3rem);
|
||||
|
||||
@media (max-width: 40em) {
|
||||
height: calc(100% - 5rem);
|
||||
}
|
||||
}
|
||||
|
||||
.mantine-Tabs-tab {
|
||||
padding: 1.2rem 1.618rem 0.8rem 1.618rem;
|
||||
|
||||
@media (max-width: 40em) {
|
||||
padding: 1rem;
|
||||
span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mantine-Tabs-panel {
|
||||
flex-grow: 1;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
margin-left: 0;
|
||||
padding: 1.2rem 0 3rem 0;
|
||||
|
||||
@media (max-width: 40em) {
|
||||
padding: 1.2rem 1rem 3rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
#save {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
opacity: 1;
|
||||
|
||||
.mantine-Button-root {
|
||||
height: 3rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export interface SettingsDrawerProps {
|
||||
}
|
||||
|
||||
export default function SettingsDrawer(props: SettingsDrawerProps) {
|
||||
const tab = useAppSelector(selectSettingsTab);
|
||||
const small = useMediaQuery('(max-width: 40em)');
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const close = useCallback(() => dispatch(closeSettingsUI()), [dispatch]);
|
||||
const onTabChange = useCallback((tab: string) => dispatch(setTab(tab)), [dispatch]);
|
||||
|
||||
return (
|
||||
<Drawer size="50rem"
|
||||
position='right'
|
||||
opened={!!tab}
|
||||
onClose={close}
|
||||
transition="slide-left"
|
||||
transitionDuration={200}
|
||||
withCloseButton={false}>
|
||||
<Container>
|
||||
<Tabs value={tab} onTabChange={onTabChange} style={{ margin: '0rem' }}>
|
||||
<Tabs.List grow={small}>
|
||||
<Tabs.Tab value="options">Options</Tabs.Tab>
|
||||
<Tabs.Tab value="user">User</Tabs.Tab>
|
||||
<Tabs.Tab value="speech">Speech</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<UserOptionsTab />
|
||||
<GenerationOptionsTab />
|
||||
<SpeechOptionsTab />
|
||||
</Tabs>
|
||||
<div id="save">
|
||||
<Button variant="light" fullWidth size="md" onClick={close}>
|
||||
Save and Close
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
13
app/src/components/settings/option.tsx
Normal file
13
app/src/components/settings/option.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
export default function SettingsOption(props: {
|
||||
focused?: boolean;
|
||||
heading?: string;
|
||||
children?: any;
|
||||
span?: number;
|
||||
}) {
|
||||
return (
|
||||
<section className={props.focused ? 'focused' : ''}>
|
||||
{props.heading && <h3>{props.heading}</h3>}
|
||||
{props.children}
|
||||
</section>
|
||||
);
|
||||
}
|
63
app/src/components/settings/options.tsx
Normal file
63
app/src/components/settings/options.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import SettingsTab from "./tab";
|
||||
import SettingsOption from "./option";
|
||||
import { Button, Slider, Textarea } from "@mantine/core";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { defaultSystemPrompt } from "../../openai";
|
||||
import { useAppDispatch, useAppSelector } from "../../store";
|
||||
import { resetSystemPrompt, selectSystemPrompt, selectTemperature, setSystemPrompt, setTemperature } from "../../store/parameters";
|
||||
import { selectSettingsOption } from "../../store/settings-ui";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
export default function GenerationOptionsTab(props: any) {
|
||||
const intl = useIntl();
|
||||
|
||||
const option = useAppSelector(selectSettingsOption);
|
||||
const initialSystemPrompt = useAppSelector(selectSystemPrompt);
|
||||
const temperature = useAppSelector(selectTemperature);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const onSystemPromptChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => dispatch(setSystemPrompt(event.target.value)), [dispatch]);
|
||||
const onResetSystemPrompt = useCallback(() => dispatch(resetSystemPrompt()), [dispatch]);
|
||||
const onTemperatureChange = useCallback((value: number) => dispatch(setTemperature(value)), [dispatch]);
|
||||
|
||||
const resettable = initialSystemPrompt
|
||||
&& (initialSystemPrompt?.trim() !== defaultSystemPrompt.trim());
|
||||
|
||||
const systemPromptOption = useMemo(() => (
|
||||
<SettingsOption heading={intl.formatMessage({ defaultMessage: "System Prompt" })}
|
||||
focused={option === 'system-prompt'}>
|
||||
<Textarea
|
||||
value={initialSystemPrompt || defaultSystemPrompt}
|
||||
onChange={onSystemPromptChange}
|
||||
minRows={5}
|
||||
maxRows={10}
|
||||
autosize />
|
||||
<p style={{ marginBottom: '0.7rem' }}>
|
||||
<FormattedMessage defaultMessage="The System Prompt is shown to ChatGPT by the "System" 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>
|
||||
{resettable && <Button size="xs" compact variant="light" onClick={onResetSystemPrompt}>
|
||||
<FormattedMessage defaultMessage="Reset to default" />
|
||||
</Button>}
|
||||
</SettingsOption>
|
||||
), [option, initialSystemPrompt, resettable, onSystemPromptChange, onResetSystemPrompt]);
|
||||
|
||||
const temperatureOption = useMemo(() => (
|
||||
<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} />
|
||||
<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>
|
||||
), [temperature, option, onTemperatureChange]);
|
||||
|
||||
const elem = useMemo(() => (
|
||||
<SettingsTab name="options">
|
||||
{systemPromptOption}
|
||||
{temperatureOption}
|
||||
</SettingsTab>
|
||||
), [systemPromptOption, temperatureOption]);
|
||||
|
||||
return elem;
|
||||
}
|
81
app/src/components/settings/speech.tsx
Normal file
81
app/src/components/settings/speech.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import SettingsTab from "./tab";
|
||||
import SettingsOption from "./option";
|
||||
import { Button, Select, TextInput } from "@mantine/core";
|
||||
import { useAppDispatch, useAppSelector } from "../../store";
|
||||
import { selectElevenLabsApiKey, setElevenLabsApiKey } from "../../store/api-keys";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { selectVoice, setVoice } from "../../store/voices";
|
||||
import { getVoices } from "../../tts/elevenlabs";
|
||||
import { selectSettingsOption } from "../../store/settings-ui";
|
||||
import { defaultVoiceList } from "../../tts/defaults";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
export default function SpeechOptionsTab() {
|
||||
const intl = useIntl();
|
||||
|
||||
const option = useAppSelector(selectSettingsOption);
|
||||
const elevenLabsApiKey = useAppSelector(selectElevenLabsApiKey);
|
||||
const voice = useAppSelector(selectVoice);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const onElevenLabsApiKeyChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => dispatch(setElevenLabsApiKey(event.target.value)), [dispatch]);
|
||||
const onVoiceChange = useCallback((value: string) => dispatch(setVoice(value)), [dispatch]);
|
||||
|
||||
const [voices, setVoices] = useState<any[]>(defaultVoiceList);
|
||||
useEffect(() => {
|
||||
if (elevenLabsApiKey) {
|
||||
getVoices().then(data => {
|
||||
if (data?.voices?.length) {
|
||||
setVoices(data.voices);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [elevenLabsApiKey]);
|
||||
|
||||
const apiKeyOption = useMemo(() => (
|
||||
<SettingsOption heading={intl.formatMessage({ defaultMessage: 'Your ElevenLabs Text-to-Speech API Key (optional)' })}
|
||||
focused={option === 'elevenlabs-api-key'}>
|
||||
<TextInput placeholder={intl.formatMessage({ defaultMessage: "Paste your API key here" })}
|
||||
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>
|
||||
), [option, elevenLabsApiKey, onElevenLabsApiKeyChange]);
|
||||
|
||||
const voiceOption = useMemo(() => (
|
||||
<SettingsOption heading={intl.formatMessage({ defaultMessage: 'Voice' })}
|
||||
focused={option === 'elevenlabs-voice'}>
|
||||
<Select
|
||||
value={voice}
|
||||
onChange={onVoiceChange}
|
||||
data={[
|
||||
...voices.map(v => ({ label: v.name, value: v.voice_id })),
|
||||
]} />
|
||||
<audio controls style={{ display: 'none' }} id="voice-preview" key={voice}>
|
||||
<source src={voices.find(v => v.voice_id === voice)?.preview_url} type="audio/mpeg" />
|
||||
</audio>
|
||||
<Button onClick={() => (document.getElementById('voice-preview') as HTMLMediaElement)?.play()} variant='light' compact style={{ marginTop: '1rem' }}>
|
||||
<i className='fa fa-headphones' />
|
||||
<span>
|
||||
<FormattedMessage defaultMessage="Preview voice" />
|
||||
</span>
|
||||
</Button>
|
||||
</SettingsOption>
|
||||
), [option, voice, voices, onVoiceChange]);
|
||||
|
||||
const elem = useMemo(() => (
|
||||
<SettingsTab name="speech">
|
||||
{apiKeyOption}
|
||||
{voices.length > 0 && voiceOption}
|
||||
</SettingsTab>
|
||||
), [apiKeyOption, voiceOption, voices.length]);
|
||||
|
||||
return elem;
|
||||
}
|
64
app/src/components/settings/tab.tsx
Normal file
64
app/src/components/settings/tab.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import styled from "@emotion/styled";
|
||||
import { Tabs } from "@mantine/core";
|
||||
|
||||
const Settings = styled.div`
|
||||
font-family: "Work Sans", sans-serif;
|
||||
color: white;
|
||||
|
||||
section {
|
||||
margin-bottom: .618rem;
|
||||
padding: 0.618rem;
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.7;
|
||||
margin-top: 0.8rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: white;
|
||||
text-decoration : underline;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "Fira Code", monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.focused {
|
||||
border: thin solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.25rem;
|
||||
animation: flash 3s;
|
||||
}
|
||||
|
||||
@keyframes flash {
|
||||
0% {
|
||||
border-color: rgba(255, 0, 0, 0);
|
||||
}
|
||||
50% {
|
||||
border-color: rgba(255, 0, 0, 1);
|
||||
}
|
||||
100% {
|
||||
border-color: rgba(255, 255, 255, .1);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function SettingsTab(props: {
|
||||
name: string;
|
||||
children?: any;
|
||||
}) {
|
||||
return (
|
||||
<Tabs.Panel value={props.name}>
|
||||
<Settings>
|
||||
{props.children}
|
||||
</Settings>
|
||||
</Tabs.Panel>
|
||||
);
|
||||
}
|
42
app/src/components/settings/user.tsx
Normal file
42
app/src/components/settings/user.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import SettingsTab from "./tab";
|
||||
import SettingsOption from "./option";
|
||||
import { TextInput } from "@mantine/core";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "../../store";
|
||||
import { selectOpenAIApiKey, setOpenAIApiKeyFromEvent } from "../../store/api-keys";
|
||||
import { selectSettingsOption } from "../../store/settings-ui";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
export default function UserOptionsTab(props: any) {
|
||||
const option = useAppSelector(selectSettingsOption);
|
||||
const openaiApiKey = useAppSelector(selectOpenAIApiKey);
|
||||
const intl = useIntl()
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const onOpenAIApiKeyChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => dispatch(setOpenAIApiKeyFromEvent(event)), [dispatch]);
|
||||
|
||||
const elem = useMemo(() => (
|
||||
<SettingsTab name="user">
|
||||
<SettingsOption heading={intl.formatMessage({ defaultMessage: "Your OpenAI API Key" })}
|
||||
focused={option === 'openai-api-key'}>
|
||||
<TextInput
|
||||
placeholder={intl.formatMessage({ defaultMessage: "Paste your API key here" })}
|
||||
value={openaiApiKey || ''}
|
||||
onChange={onOpenAIApiKeyChange} />
|
||||
<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>
|
||||
</SettingsTab>
|
||||
), [option, openaiApiKey, onOpenAIApiKeyChange]);
|
||||
|
||||
return elem;
|
||||
}
|
159
app/src/components/sidebar/index.tsx
Normal file
159
app/src/components/sidebar/index.tsx
Normal 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;
|
||||
}
|
95
app/src/components/sidebar/recent-chats.tsx
Normal file
95
app/src/components/sidebar/recent-chats.tsx
Normal 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>
|
||||
);
|
||||
}
|
186
app/src/context.tsx
Normal file
186
app/src/context.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import React, { useState, useRef, useMemo, useEffect, useCallback } from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { backend } from "./backend";
|
||||
import ChatManagerInstance, { ChatManager } from "./chat-manager";
|
||||
import store, { useAppDispatch } from "./store";
|
||||
import { openOpenAIApiKeyPanel } from "./store/settings-ui";
|
||||
import { Message } from "./types";
|
||||
import { useChat, UseChatResult } from "./use-chat";
|
||||
|
||||
export interface Context {
|
||||
authenticated: boolean;
|
||||
chat: ChatManager;
|
||||
id: string | undefined | null;
|
||||
currentChat: UseChatResult;
|
||||
isHome: boolean;
|
||||
isShare: boolean;
|
||||
generating: boolean;
|
||||
onNewMessage: (message?: string) => Promise<boolean>;
|
||||
regenerateMessage: (message: Message) => Promise<boolean>;
|
||||
editMessage: (message: Message, content: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const AppContext = React.createContext<Context>({} as any);
|
||||
|
||||
export function useCreateAppContext(): Context {
|
||||
const { id } = useParams();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const pathname = useLocation().pathname;
|
||||
const isHome = pathname === '/';
|
||||
const isShare = pathname.startsWith('/s/');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const chatManager = useRef(ChatManagerInstance);
|
||||
const currentChat = useChat(chatManager.current, id, isShare);
|
||||
const [authenticated, setAuthenticated] = useState(backend.current?.isAuthenticated || false);
|
||||
|
||||
const updateAuth = useCallback((authenticated: boolean) => setAuthenticated(authenticated), []);
|
||||
|
||||
useEffect(() => {
|
||||
backend.current?.on('authenticated', updateAuth);
|
||||
return () => {
|
||||
backend.current?.off('authenticated', updateAuth)
|
||||
};
|
||||
}, [updateAuth]);
|
||||
|
||||
const onNewMessage = useCallback(async (message?: string) => {
|
||||
if (isShare) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!message?.trim().length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const openaiApiKey = store.getState().apiKeys.openAIApiKey;
|
||||
|
||||
if (!openaiApiKey) {
|
||||
dispatch(openOpenAIApiKeyPanel());
|
||||
return false;
|
||||
}
|
||||
|
||||
const parameters = store.getState().parameters;
|
||||
|
||||
if (id) {
|
||||
await chatManager.current.sendMessage({
|
||||
chatID: id,
|
||||
content: message.trim(),
|
||||
requestedParameters: {
|
||||
...parameters,
|
||||
apiKey: openaiApiKey,
|
||||
},
|
||||
parentID: currentChat.leaf?.id,
|
||||
});
|
||||
} else {
|
||||
const id = await chatManager.current.createChat();
|
||||
await chatManager.current.sendMessage({
|
||||
chatID: id,
|
||||
content: message.trim(),
|
||||
requestedParameters: {
|
||||
...parameters,
|
||||
apiKey: openaiApiKey,
|
||||
},
|
||||
parentID: currentChat.leaf?.id,
|
||||
});
|
||||
navigate('/chat/' + id);
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [dispatch, chatManager, id, currentChat.leaf, navigate, isShare]);
|
||||
|
||||
const regenerateMessage = useCallback(async (message: Message) => {
|
||||
if (isShare) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const openaiApiKey = store.getState().apiKeys.openAIApiKey;
|
||||
|
||||
if (!openaiApiKey) {
|
||||
dispatch(openOpenAIApiKeyPanel());
|
||||
return false;
|
||||
}
|
||||
|
||||
const parameters = store.getState().parameters;
|
||||
|
||||
await chatManager.current.regenerate(message, {
|
||||
...parameters,
|
||||
apiKey: openaiApiKey,
|
||||
});
|
||||
|
||||
return true;
|
||||
}, [dispatch, chatManager, isShare]);
|
||||
|
||||
const editMessage = useCallback(async (message: Message, content: string) => {
|
||||
if (isShare) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!content?.trim().length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const openaiApiKey = store.getState().apiKeys.openAIApiKey;
|
||||
|
||||
if (!openaiApiKey) {
|
||||
dispatch(openOpenAIApiKeyPanel());
|
||||
return false;
|
||||
}
|
||||
|
||||
const parameters = store.getState().parameters;
|
||||
|
||||
if (id) {
|
||||
await chatManager.current.sendMessage({
|
||||
chatID: id,
|
||||
content: content.trim(),
|
||||
requestedParameters: {
|
||||
...parameters,
|
||||
apiKey: openaiApiKey,
|
||||
},
|
||||
parentID: message.parentID,
|
||||
});
|
||||
} else {
|
||||
const id = await chatManager.current.createChat();
|
||||
await chatManager.current.sendMessage({
|
||||
chatID: id,
|
||||
content: content.trim(),
|
||||
requestedParameters: {
|
||||
...parameters,
|
||||
apiKey: openaiApiKey,
|
||||
},
|
||||
parentID: message.parentID,
|
||||
});
|
||||
navigate('/chat/' + id);
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [dispatch, chatManager, id, isShare, navigate]);
|
||||
|
||||
const generating = currentChat?.messagesToDisplay?.length > 0
|
||||
? !currentChat.messagesToDisplay[currentChat.messagesToDisplay.length - 1].done
|
||||
: false;
|
||||
|
||||
const context = useMemo<Context>(() => ({
|
||||
authenticated,
|
||||
id,
|
||||
chat: chatManager.current,
|
||||
currentChat,
|
||||
isHome,
|
||||
isShare,
|
||||
generating,
|
||||
onNewMessage,
|
||||
regenerateMessage,
|
||||
editMessage,
|
||||
}), [chatManager, authenticated, generating, onNewMessage, regenerateMessage, editMessage, currentChat, id, isShare]);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useAppContext() {
|
||||
return React.useContext(AppContext);
|
||||
}
|
||||
|
||||
export function AppContextProvider(props: { children: React.ReactNode }) {
|
||||
const context = useCreateAppContext();
|
||||
return <AppContext.Provider value={context}>{props.children}</AppContext.Provider>;
|
||||
}
|
103
app/src/idb.ts
Normal file
103
app/src/idb.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as idb from 'idb-keyval';
|
||||
|
||||
let supported = true;
|
||||
const inMemoryCache = new Map<string, any>();
|
||||
|
||||
const testDB = indexedDB.open('idb-test');
|
||||
testDB.onerror = () => {
|
||||
supported = false;
|
||||
};
|
||||
|
||||
export async function keys() {
|
||||
if (supported) {
|
||||
try {
|
||||
const keys = await idb.keys();
|
||||
return Array.from(keys).map(k => k.toString());
|
||||
} catch (e) {}
|
||||
}
|
||||
return Array.from(inMemoryCache.keys());
|
||||
}
|
||||
|
||||
export async function set(key, value) {
|
||||
// all values are saved in memory in case IDB fails later, but only retrieved after IDB fails.
|
||||
inMemoryCache.set(key, value);
|
||||
|
||||
if (supported) {
|
||||
try {
|
||||
await idb.set(key, value);
|
||||
return;
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function get(key) {
|
||||
if (supported) {
|
||||
try {
|
||||
return await idb.get(key);
|
||||
}
|
||||
catch (e) {}
|
||||
}
|
||||
return inMemoryCache.get(key);
|
||||
}
|
||||
|
||||
export async function getMany(keys) {
|
||||
if (supported) {
|
||||
try {
|
||||
return await idb.getMany(keys);
|
||||
}
|
||||
catch (e) {}
|
||||
}
|
||||
const values: any[] = [];
|
||||
for (const key of keys) {
|
||||
values.push(inMemoryCache.get(key));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export async function setMany(items: [string, any][]) {
|
||||
// all values are saved in memory in case IDB fails later, but only retrieved after IDB fails.
|
||||
for (const [key, value] of items) {
|
||||
inMemoryCache.set(key, value);
|
||||
}
|
||||
if (supported) {
|
||||
try {
|
||||
await idb.setMany(items);
|
||||
return;
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function entries() {
|
||||
if (supported) {
|
||||
try {
|
||||
const entries = await idb.entries();
|
||||
return Array.from(entries)
|
||||
.map(([key, value]) => [key.toString(), value]);
|
||||
} catch (e) {}
|
||||
}
|
||||
return Array.from(inMemoryCache.entries());
|
||||
}
|
||||
|
||||
export async function del(key: string) {
|
||||
// all values are saved in memory in case IDB fails later, but only retrieved after IDB fails.
|
||||
inMemoryCache.delete(key);
|
||||
if (supported) {
|
||||
try {
|
||||
await idb.del(key);
|
||||
return;
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function delMany(keys: string[]) {
|
||||
// all values are saved in memory in case IDB fails later, but only retrieved after IDB fails.
|
||||
for (const key of keys) {
|
||||
inMemoryCache.delete(key);
|
||||
}
|
||||
if (supported) {
|
||||
try {
|
||||
await idb.delMany(keys);
|
||||
return;
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
73
app/src/index.scss
Normal file
73
app/src/index.scss
Normal file
@@ -0,0 +1,73 @@
|
||||
/* http://meyerweb.com/eric/tools/css/reset/
|
||||
v2.0 | 20110126
|
||||
License: none (public domain)
|
||||
*/
|
||||
|
||||
html, body, div, span, applet, object, iframe,
|
||||
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
|
||||
a, abbr, acronym, address, big, cite, code,
|
||||
del, dfn, em, img, ins, kbd, q, s, samp,
|
||||
small, strike, strong, sub, sup, tt, var,
|
||||
b, u, i, center,
|
||||
dl, dt, dd, ol, ul, li,
|
||||
fieldset, form, label, legend,
|
||||
table, caption, tbody, tfoot, thead, tr, th, td,
|
||||
article, aside, canvas, details, embed,
|
||||
figure, figcaption, footer, header, hgroup,
|
||||
menu, nav, output, ruby, section, summary,
|
||||
time, mark, audio, video {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 100%;
|
||||
font: inherit;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
/* HTML5 display-role reset for older browsers */
|
||||
article, aside, details, figcaption, figure,
|
||||
footer, header, hgroup, menu, nav, section {
|
||||
display: block;
|
||||
}
|
||||
body {
|
||||
line-height: 1;
|
||||
}
|
||||
ol, ul {
|
||||
list-style: none;
|
||||
}
|
||||
blockquote, q {
|
||||
quotes: none;
|
||||
}
|
||||
blockquote:before, blockquote:after,
|
||||
q:before, q:after {
|
||||
content: '';
|
||||
content: none;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: "Open Sans", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
font-size: 110%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fa + span {
|
||||
margin-left: 0.25rem;
|
||||
}
|
91
app/src/index.tsx
Normal file
91
app/src/index.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
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 { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
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 store, { persistor } from './store';
|
||||
|
||||
import './backend';
|
||||
import './index.scss';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <AppContextProvider>
|
||||
<LandingPage landing={true} />
|
||||
</AppContextProvider>,
|
||||
},
|
||||
{
|
||||
path: "/chat/:id",
|
||||
element: <AppContextProvider>
|
||||
<ChatPage />
|
||||
</AppContextProvider>,
|
||||
},
|
||||
{
|
||||
path: "/s/:id",
|
||||
element: <AppContextProvider>
|
||||
<ChatPage share={true} />
|
||||
</AppContextProvider>,
|
||||
},
|
||||
{
|
||||
path: "/s/:id/*",
|
||||
element: <AppContextProvider>
|
||||
<ChatPage share={true} />
|
||||
</AppContextProvider>,
|
||||
},
|
||||
{
|
||||
path: "/about",
|
||||
element: <AppContextProvider>
|
||||
<AboutPage />
|
||||
</AppContextProvider>,
|
||||
},
|
||||
]);
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
|
||||
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>
|
||||
<IntlProvider locale={navigator.language} messages={messages}>
|
||||
<MantineProvider theme={{ colorScheme: "dark" }}>
|
||||
<Provider store={store}>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<ModalsProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ModalsProvider>
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
</MantineProvider>
|
||||
</IntlProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
bootstrapApplication();
|
18
app/src/menus.ts
Normal file
18
app/src/menus.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface MenuItem {
|
||||
label: string;
|
||||
link: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export const secondaryMenu: MenuItem[] = [
|
||||
{
|
||||
label: "Discord",
|
||||
link: "https://discord.gg/mS5QvKykvv",
|
||||
icon: "discord fab",
|
||||
},
|
||||
{
|
||||
label: "GitHub",
|
||||
link: "https://github.com/cogentapps/chat-with-gpt",
|
||||
icon: "github fab",
|
||||
},
|
||||
];
|
132
app/src/message-tree.ts
Normal file
132
app/src/message-tree.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Message } from "./types";
|
||||
|
||||
export interface Node extends Message {
|
||||
parent: Node | null;
|
||||
children: Set<Node>;
|
||||
}
|
||||
|
||||
export function createNode(message: Message): Node {
|
||||
return {
|
||||
...message,
|
||||
parent: null,
|
||||
children: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
export class MessageTree {
|
||||
public nodes: Map<string, Node> = new Map();
|
||||
|
||||
constructor(messages: (Message | Node)[] = []) {
|
||||
this.addMessages(messages);
|
||||
}
|
||||
|
||||
public get roots(): Node[] {
|
||||
return Array.from(this.nodes.values())
|
||||
.filter((node) => node.parent === null);
|
||||
}
|
||||
|
||||
public get leafs(): Node[] {
|
||||
return Array.from(this.nodes.values())
|
||||
.filter((node) => node.children.size === 0);
|
||||
}
|
||||
|
||||
public get first(): Node | null {
|
||||
const leaf = this.mostRecentLeaf();
|
||||
let first: Node | null = leaf;
|
||||
while (first?.parent) {
|
||||
first = first.parent;
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
public get(id: string) {
|
||||
return this.nodes.get(id);
|
||||
}
|
||||
|
||||
public addMessage(message: Message) {
|
||||
if (this.nodes.get(message.id)?.content) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = createNode(message);
|
||||
|
||||
this.nodes.set(node.id, node);
|
||||
|
||||
if (node.parentID) {
|
||||
let parent = this.nodes.get(node.parentID);
|
||||
|
||||
if (!parent) {
|
||||
parent = createNode({
|
||||
id: node.parentID,
|
||||
} as Message);
|
||||
|
||||
this.nodes.set(parent.id, parent);
|
||||
}
|
||||
|
||||
parent.children.add(node);
|
||||
node.parent = parent;
|
||||
}
|
||||
|
||||
for (const other of Array.from(this.nodes.values())) {
|
||||
if (other.parentID === node.id) {
|
||||
node.children.add(other);
|
||||
other.parent = node;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public addMessages(messages: Message[]) {
|
||||
for (const message of messages) {
|
||||
try {
|
||||
this.addMessage(message);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public updateMessage(message: Message) {
|
||||
const node = this.nodes.get(message.id);
|
||||
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
node.content = message.content;
|
||||
node.timestamp = message.timestamp;
|
||||
node.done = message.done;
|
||||
}
|
||||
|
||||
public getMessageChainTo(messageID: string) {
|
||||
const message = this.nodes.get(messageID);
|
||||
|
||||
if (!message) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const chain = [message];
|
||||
|
||||
let current = message;
|
||||
|
||||
while (current.parent) {
|
||||
chain.unshift(current.parent);
|
||||
current = current.parent;
|
||||
}
|
||||
|
||||
return chain;
|
||||
}
|
||||
|
||||
public serialize() {
|
||||
return Array.from(this.nodes.values())
|
||||
.map((node) => {
|
||||
const n: any = { ...node };
|
||||
delete n.parent;
|
||||
delete n.children;
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
public mostRecentLeaf() {
|
||||
return this.leafs.sort((a, b) => b.timestamp - a.timestamp)[0];
|
||||
}
|
||||
}
|
152
app/src/openai.ts
Normal file
152
app/src/openai.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import EventEmitter from "events";
|
||||
import { Configuration, OpenAIApi } from "openai";
|
||||
import SSE from "./sse";
|
||||
import { OpenAIMessage, Parameters } from "./types";
|
||||
|
||||
export const defaultSystemPrompt = `
|
||||
You are ChatGPT, a large language model trained by OpenAI.
|
||||
Knowledge cutoff: 2021-09
|
||||
Current date and time: {{ datetime }}
|
||||
`.trim();
|
||||
|
||||
export interface OpenAIResponseChunk {
|
||||
id?: string;
|
||||
done: boolean;
|
||||
choices?: {
|
||||
delta: {
|
||||
content: string;
|
||||
};
|
||||
index: number;
|
||||
finish_reason: string | null;
|
||||
}[];
|
||||
model?: string;
|
||||
}
|
||||
|
||||
function parseResponseChunk(buffer: any): OpenAIResponseChunk {
|
||||
const chunk = buffer.toString().replace('data: ', '').trim();
|
||||
|
||||
if (chunk === '[DONE]') {
|
||||
return {
|
||||
done: true,
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(chunk);
|
||||
|
||||
return {
|
||||
id: parsed.id,
|
||||
done: false,
|
||||
choices: parsed.choices,
|
||||
model: parsed.model,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createChatCompletion(messages: OpenAIMessage[], parameters: Parameters): Promise<string> {
|
||||
if (!parameters.apiKey) {
|
||||
throw new Error('No API key provided');
|
||||
}
|
||||
|
||||
const configuration = new Configuration({
|
||||
apiKey: parameters.apiKey,
|
||||
});
|
||||
|
||||
const openai = new OpenAIApi(configuration);
|
||||
|
||||
const response = await openai.createChatCompletion({
|
||||
model: 'gpt-3.5-turbo',
|
||||
temperature: parameters.temperature,
|
||||
messages: messages as any,
|
||||
});
|
||||
|
||||
return response.data.choices[0].message?.content?.trim() || '';
|
||||
}
|
||||
|
||||
export async function createStreamingChatCompletion(messages: OpenAIMessage[], parameters: Parameters) {
|
||||
if (!parameters.apiKey) {
|
||||
throw new Error('No API key provided');
|
||||
}
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
|
||||
const messagesToSend = [...messages].filter(m => m.role !== 'app');
|
||||
|
||||
for (let i = messagesToSend.length - 1; i >= 0; i--) {
|
||||
const m = messagesToSend[i];
|
||||
if (m.role === 'user') {
|
||||
break;
|
||||
}
|
||||
if (m.role === 'assistant') {
|
||||
messagesToSend.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
messagesToSend.unshift({
|
||||
role: 'system',
|
||||
content: (parameters.initialSystemPrompt || defaultSystemPrompt).replace('{{ datetime }}', new Date().toLocaleString()),
|
||||
});
|
||||
|
||||
const eventSource = new SSE('https://api.openai.com/v1/chat/completions', {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Authorization': `Bearer ${parameters.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
payload: JSON.stringify({
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": messagesToSend,
|
||||
"temperature": parameters.temperature,
|
||||
"stream": true,
|
||||
}),
|
||||
}) 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 = '';
|
||||
|
||||
eventSource.addEventListener('error', (event: any) => {
|
||||
if (!contents) {
|
||||
emitter.emit('error');
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('message', async (event: any) => {
|
||||
if (event.data === '[DONE]') {
|
||||
emitter.emit('done');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const chunk = parseResponseChunk(event.data);
|
||||
if (chunk.choices && chunk.choices.length > 0) {
|
||||
contents += chunk.choices[0]?.delta?.content || '';
|
||||
emitter.emit('data', contents);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.stream();
|
||||
|
||||
return {
|
||||
emitter,
|
||||
cancel: () => eventSource.close(),
|
||||
};
|
||||
}
|
33
app/src/parameters.ts
Normal file
33
app/src/parameters.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Parameters } from "./types";
|
||||
|
||||
export const defaultParameters: Parameters = {
|
||||
temperature: 0.5,
|
||||
};
|
||||
|
||||
export function loadParameters(id: string | null | undefined = null): Parameters {
|
||||
const apiKey = localStorage.getItem('openai-api-key') || undefined;
|
||||
const key = id ? `parameters-${id}` : 'parameters';
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw) {
|
||||
const parameters = JSON.parse(raw) as Parameters;
|
||||
parameters.apiKey = apiKey;
|
||||
return parameters;
|
||||
}
|
||||
} catch (e) { }
|
||||
return id ? loadParameters() : { ...defaultParameters, apiKey };
|
||||
}
|
||||
|
||||
export function saveParameters(id: string, parameters: Parameters) {
|
||||
if (parameters) {
|
||||
const apiKey = parameters.apiKey;
|
||||
delete parameters.apiKey;
|
||||
|
||||
localStorage.setItem(`parameters-${id}`, JSON.stringify(parameters));
|
||||
localStorage.setItem('parameters', JSON.stringify(parameters));
|
||||
|
||||
if (apiKey) {
|
||||
localStorage.setItem(`openai-api-key`, apiKey);
|
||||
}
|
||||
}
|
||||
}
|
1
app/src/react-app-env.d.ts
vendored
Normal file
1
app/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
14
app/src/setupProxy.js
Normal file
14
app/src/setupProxy.js
Normal 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,
|
||||
})
|
||||
);
|
||||
};
|
35
app/src/spotlight.tsx
Normal file
35
app/src/spotlight.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAppContext } from "./context";
|
||||
|
||||
export function useChatSpotlightProps() {
|
||||
const navigate = useNavigate();
|
||||
const context = useAppContext();
|
||||
const intl = useIntl();
|
||||
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
context.chat.on('update', () => setVersion(v => v + 1));
|
||||
}, [context.chat]);
|
||||
|
||||
const search = useCallback((query: string) => {
|
||||
return context.chat.search.query(query)
|
||||
.map((result: any) => ({
|
||||
...result,
|
||||
onTrigger: () => navigate('/chat/' + result.chatID + (result.messageID ? '#msg-' + result.messageID : '')),
|
||||
}))
|
||||
}, [context.chat, navigate, version]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const props = useMemo(() => ({
|
||||
shortcut: ['mod + P'],
|
||||
overlayColor: '#000000',
|
||||
searchPlaceholder: intl.formatMessage({ defaultMessage: 'Search your chats' }),
|
||||
searchIcon: <i className="fa fa-search" />,
|
||||
actions: search,
|
||||
filter: (query: string, items: any) => items,
|
||||
}), [search]);
|
||||
|
||||
return props;
|
||||
}
|
214
app/src/sse.ts
Normal file
214
app/src/sse.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Copyright (C) 2016 Maxime Petazzoni <maxime.petazzoni@bulix.org>.
|
||||
* All rights reserved.
|
||||
*/
|
||||
|
||||
export default class SSE {
|
||||
public INITIALIZING = -1;
|
||||
public CONNECTING = 0;
|
||||
public OPEN = 1;
|
||||
public CLOSED = 2;
|
||||
|
||||
public headers = this.options.headers || {};
|
||||
public payload = this.options.payload !== undefined ? this.options.payload : '';
|
||||
public method = this.options.method ? this.options.method : (this.payload ? 'POST' : 'GET');
|
||||
public withCredentials = !!this.options.withCredentials;
|
||||
|
||||
public FIELD_SEPARATOR = ':';
|
||||
public listeners: any = {};
|
||||
|
||||
public xhr: any = null;
|
||||
public readyState = this.INITIALIZING;
|
||||
public progress = 0;
|
||||
public chunk = '';
|
||||
|
||||
public constructor(public url: string, public options: any) {}
|
||||
|
||||
public addEventListener = (type: string, listener: any) => {
|
||||
if (this.listeners[type] === undefined) {
|
||||
this.listeners[type] = [];
|
||||
}
|
||||
|
||||
if (this.listeners[type].indexOf(listener) === -1) {
|
||||
this.listeners[type].push(listener);
|
||||
}
|
||||
};
|
||||
|
||||
public removeEventListener = (type: string, listener: any) => {
|
||||
if (this.listeners[type] === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
var filtered: any[] = [];
|
||||
this.listeners[type].forEach((element: any) => {
|
||||
if (element !== listener) {
|
||||
filtered.push(element);
|
||||
}
|
||||
});
|
||||
if (filtered.length === 0) {
|
||||
delete this.listeners[type];
|
||||
} else {
|
||||
this.listeners[type] = filtered;
|
||||
}
|
||||
};
|
||||
|
||||
public dispatchEvent = (e: any) => {
|
||||
if (!e) {
|
||||
return true;
|
||||
}
|
||||
|
||||
e.source = this;
|
||||
|
||||
var onHandler = 'on' + e.type;
|
||||
if (this.hasOwnProperty(onHandler)) {
|
||||
// @ts-ignore
|
||||
this[onHandler].call(this, e);
|
||||
if (e.defaultPrevented) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.listeners[e.type]) {
|
||||
return this.listeners[e.type].every((callback: (arg0: any) => void) => {
|
||||
callback(e);
|
||||
return !e.defaultPrevented;
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
public _setReadyState = (state: number) => {
|
||||
var event = new CustomEvent<any>('readystatechange');
|
||||
// @ts-ignore
|
||||
event.readyState = state;
|
||||
this.readyState = state;
|
||||
this.dispatchEvent(event);
|
||||
};
|
||||
|
||||
public _onStreamFailure = (e: { currentTarget: { response: any; }; }) => {
|
||||
var event = new CustomEvent('error');
|
||||
// @ts-ignore
|
||||
event.data = e.currentTarget.response;
|
||||
this.dispatchEvent(event);
|
||||
this.close();
|
||||
}
|
||||
|
||||
public _onStreamAbort = (e: any) => {
|
||||
this.dispatchEvent(new CustomEvent('abort'));
|
||||
this.close();
|
||||
}
|
||||
|
||||
public _onStreamProgress = (e: any) => {
|
||||
if (!this.xhr) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.xhr.status !== 200) {
|
||||
this._onStreamFailure(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.readyState === this.CONNECTING) {
|
||||
this.dispatchEvent(new CustomEvent('open'));
|
||||
this._setReadyState(this.OPEN);
|
||||
}
|
||||
|
||||
var data = this.xhr.responseText.substring(this.progress);
|
||||
this.progress += data.length;
|
||||
data.split(/(\r\n|\r|\n){2}/g).forEach((part: string) => {
|
||||
if (part.trim().length === 0) {
|
||||
this.dispatchEvent(this._parseEventChunk(this.chunk.trim()));
|
||||
this.chunk = '';
|
||||
} else {
|
||||
this.chunk += part;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
public _onStreamLoaded = (e: any) => {
|
||||
this._onStreamProgress(e);
|
||||
|
||||
// Parse the last chunk.
|
||||
this.dispatchEvent(this._parseEventChunk(this.chunk));
|
||||
this.chunk = '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a received SSE event chunk into a constructed event object.
|
||||
*/
|
||||
public _parseEventChunk = (chunk: string) => {
|
||||
if (!chunk || chunk.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var e: any = { 'id': null, 'retry': null, 'data': '', 'event': 'message' };
|
||||
chunk.split(/\n|\r\n|\r/).forEach((line: string) => {
|
||||
line = line.trimRight();
|
||||
var index = line.indexOf(this.FIELD_SEPARATOR);
|
||||
if (index <= 0) {
|
||||
// Line was either empty, or started with a separator and is a comment.
|
||||
// Either way, ignore.
|
||||
return;
|
||||
}
|
||||
|
||||
var field = line.substring(0, index);
|
||||
if (!(field in e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var value = line.substring(index + 1).trimLeft();
|
||||
if (field === 'data') {
|
||||
e[field] += value;
|
||||
} else {
|
||||
e[field] = value;
|
||||
}
|
||||
});
|
||||
|
||||
var event: any = new CustomEvent(e.event);
|
||||
event.data = e.data;
|
||||
event.id = e.id;
|
||||
return event;
|
||||
};
|
||||
|
||||
public _checkStreamClosed = () => {
|
||||
if (!this.xhr) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.xhr.readyState === XMLHttpRequest.DONE) {
|
||||
this._setReadyState(this.CLOSED);
|
||||
}
|
||||
};
|
||||
|
||||
public stream = () => {
|
||||
this._setReadyState(this.CONNECTING);
|
||||
|
||||
this.xhr = new XMLHttpRequest();
|
||||
this.xhr.addEventListener('progress', this._onStreamProgress);
|
||||
this.xhr.addEventListener('load', this._onStreamLoaded);
|
||||
this.xhr.addEventListener('readystatechange', this._checkStreamClosed);
|
||||
this.xhr.addEventListener('error', this._onStreamFailure);
|
||||
this.xhr.addEventListener('abort', this._onStreamAbort);
|
||||
this.xhr.open(this.method, this.url);
|
||||
for (var header in this.headers) {
|
||||
this.xhr.setRequestHeader(header, this.headers[header]);
|
||||
}
|
||||
this.xhr.withCredentials = this.withCredentials;
|
||||
this.xhr.send(this.payload);
|
||||
};
|
||||
|
||||
public close = () => {
|
||||
if (this.readyState === this.CLOSED) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.xhr.abort();
|
||||
this.xhr = null;
|
||||
this._setReadyState(this.CLOSED);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
};
|
33
app/src/store/api-keys.ts
Normal file
33
app/src/store/api-keys.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { RootState } from '.';
|
||||
|
||||
const initialState: {
|
||||
openAIApiKey?: string | null | undefined;
|
||||
elevenLabsApiKey?: string | null | undefined;
|
||||
} = {
|
||||
openAIApiKey: localStorage.getItem('openai-api-key'),
|
||||
elevenLabsApiKey: localStorage.getItem('elevenlabs-api-key'),
|
||||
};
|
||||
|
||||
export const apiKeysSlice = createSlice({
|
||||
name: 'apiKeys',
|
||||
initialState,
|
||||
reducers: {
|
||||
setOpenAIApiKey: (state, action: PayloadAction<string>) => {
|
||||
state.openAIApiKey = action.payload;
|
||||
},
|
||||
setElevenLabsApiKey: (state, action: PayloadAction<string>) => {
|
||||
state.elevenLabsApiKey = action.payload;
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const { setOpenAIApiKey, setElevenLabsApiKey } = apiKeysSlice.actions;
|
||||
|
||||
export const setOpenAIApiKeyFromEvent = (event: React.ChangeEvent<HTMLInputElement>) => apiKeysSlice.actions.setOpenAIApiKey(event.target.value);
|
||||
export const setElevenLabsApiKeyFromEvent = (event: React.ChangeEvent<HTMLInputElement>) => apiKeysSlice.actions.setElevenLabsApiKey(event.target.value);
|
||||
|
||||
export const selectOpenAIApiKey = (state: RootState) => state.apiKeys.openAIApiKey;
|
||||
export const selectElevenLabsApiKey = (state: RootState) => state.apiKeys.elevenLabsApiKey;
|
||||
|
||||
export default apiKeysSlice.reducer;
|
44
app/src/store/index.ts
Normal file
44
app/src/store/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
|
||||
import storage from 'redux-persist/lib/storage';
|
||||
import { persistReducer, persistStore } from 'redux-persist';
|
||||
import messageReducer from './message';
|
||||
import parametersReducer from './parameters';
|
||||
import apiKeysReducer from './api-keys';
|
||||
import voiceReducer from './voices';
|
||||
import settingsUIReducer from './settings-ui';
|
||||
import uiReducer from './ui';
|
||||
import sidebarReducer from './sidebar';
|
||||
|
||||
const persistConfig = {
|
||||
key: 'root',
|
||||
storage,
|
||||
}
|
||||
|
||||
const persistSidebarConfig = {
|
||||
key: 'sidebar',
|
||||
storage,
|
||||
}
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
// auth: authReducer,
|
||||
apiKeys: persistReducer(persistConfig, apiKeysReducer),
|
||||
settingsUI: settingsUIReducer,
|
||||
voices: persistReducer(persistConfig, voiceReducer),
|
||||
parameters: persistReducer(persistConfig, parametersReducer),
|
||||
message: messageReducer,
|
||||
ui: uiReducer,
|
||||
sidebar: persistReducer(persistSidebarConfig, sidebarReducer),
|
||||
},
|
||||
})
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
||||
export const useAppDispatch: () => AppDispatch = useDispatch;
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
|
||||
export const persistor = persistStore(store);
|
||||
|
||||
export default store;
|
22
app/src/store/message.ts
Normal file
22
app/src/store/message.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { RootState } from '.';
|
||||
|
||||
const initialState = {
|
||||
message: '',
|
||||
};
|
||||
|
||||
export const messageSlice = createSlice({
|
||||
name: 'message',
|
||||
initialState,
|
||||
reducers: {
|
||||
setMessage: (state, action: PayloadAction<string>) => {
|
||||
state.message = action.payload;
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const { setMessage } = messageSlice.actions;
|
||||
|
||||
export const selectMessage = (state: RootState) => state.message.message;
|
||||
|
||||
export default messageSlice.reducer;
|
30
app/src/store/parameters.ts
Normal file
30
app/src/store/parameters.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import type { RootState } from '.';
|
||||
import { defaultSystemPrompt } from '../openai';
|
||||
import { defaultParameters } from '../parameters';
|
||||
import { Parameters } from '../types';
|
||||
|
||||
const initialState: Parameters = defaultParameters;
|
||||
|
||||
export const parametersSlice = createSlice({
|
||||
name: 'parameters',
|
||||
initialState,
|
||||
reducers: {
|
||||
setSystemPrompt: (state, action: PayloadAction<string>) => {
|
||||
state.initialSystemPrompt = action.payload;
|
||||
},
|
||||
resetSystemPrompt: (state) => {
|
||||
state.initialSystemPrompt = defaultSystemPrompt;
|
||||
},
|
||||
setTemperature: (state, action: PayloadAction<number>) => {
|
||||
state.temperature = action.payload;
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const { setSystemPrompt, setTemperature, resetSystemPrompt } = parametersSlice.actions;
|
||||
|
||||
export const selectSystemPrompt = (state: RootState) => state.parameters.initialSystemPrompt;
|
||||
export const selectTemperature = (state: RootState) => state.parameters.temperature;
|
||||
|
||||
export default parametersSlice.reducer;
|
38
app/src/store/settings-ui.ts
Normal file
38
app/src/store/settings-ui.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import type { RootState } from '.';
|
||||
|
||||
const initialState = {
|
||||
tab: '',
|
||||
option: '',
|
||||
};
|
||||
|
||||
export const settingsUISlice = createSlice({
|
||||
name: 'settingsUI',
|
||||
initialState,
|
||||
reducers: {
|
||||
setTab: (state, action: PayloadAction<string|null>) => {
|
||||
state.tab = action.payload || '';
|
||||
},
|
||||
setOption: (state, action: PayloadAction<string|null>) => {
|
||||
state.option = action.payload || '';
|
||||
},
|
||||
setTabAndOption: (state, action: PayloadAction<{ tab: string | null, option: string | null }>) => {
|
||||
state.tab = action.payload.tab || '';
|
||||
state.option = action.payload.option || '';
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const { setTab, setOption, setTabAndOption } = settingsUISlice.actions;
|
||||
|
||||
export const closeSettingsUI = () => settingsUISlice.actions.setTabAndOption({ tab: '', option: '' });
|
||||
|
||||
export const selectSettingsTab = (state: RootState) => state.settingsUI.tab;
|
||||
export const selectSettingsOption = (state: RootState) => state.settingsUI.option;
|
||||
|
||||
export const openOpenAIApiKeyPanel = () => settingsUISlice.actions.setTabAndOption({ tab: 'user', option: 'openai-api-key' });
|
||||
export const openElevenLabsApiKeyPanel = () => settingsUISlice.actions.setTabAndOption({ tab: 'speech', option: 'elevenlabs-api-key' });
|
||||
export const openSystemPromptPanel = () => settingsUISlice.actions.setTabAndOption({ tab: 'options', option: 'system-prompt' });
|
||||
export const openTemperaturePanel = () => settingsUISlice.actions.setTabAndOption({ tab: 'options', option: 'temperature' });
|
||||
|
||||
export default settingsUISlice.reducer;
|
28
app/src/store/sidebar.ts
Normal file
28
app/src/store/sidebar.ts
Normal 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;
|
28
app/src/store/ui.ts
Normal file
28
app/src/store/ui.ts
Normal 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;
|
23
app/src/store/voices.ts
Normal file
23
app/src/store/voices.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { RootState } from '.';
|
||||
import { defaultElevenLabsVoiceID } from '../tts/defaults';
|
||||
|
||||
const initialState = {
|
||||
voice: defaultElevenLabsVoiceID,
|
||||
};
|
||||
|
||||
export const voicesSlice = createSlice({
|
||||
name: 'voices',
|
||||
initialState,
|
||||
reducers: {
|
||||
setVoice: (state, action: PayloadAction<string|null>) => {
|
||||
state.voice = action.payload || '';
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const { setVoice } = voicesSlice.actions;
|
||||
|
||||
export const selectVoice = (state: RootState) => state.voices.voice;
|
||||
|
||||
export default voicesSlice.reducer;
|
31
app/src/tiktoken/package.json
Normal file
31
app/src/tiktoken/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@dqbd/tiktoken",
|
||||
"version": "1.0.0-alpha.1",
|
||||
"description": "Javascript bindings for tiktoken",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "run-s build:*",
|
||||
"build:cleanup": "rm -rf dist/",
|
||||
"build:rank": "tsx scripts/inline_ranks.ts",
|
||||
"build:wasm": "run-s wasm:*",
|
||||
"build:postprocess": "tsx scripts/post_process.ts",
|
||||
"wasm:bundler": "wasm-pack build --target bundler --release --out-dir dist && rm -rf dist/.gitignore dist/README.md dist/package.json",
|
||||
"wasm:node": "wasm-pack build --target nodejs --release --out-dir dist/node && rm -rf dist/node/.gitignore dist/node/README.md dist/node/package.json",
|
||||
"test": "yarn vitest"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dqbd/tiktoken"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-fetch": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.14.4",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"ts-morph": "^17.0.1",
|
||||
"tsx": "^3.12.3",
|
||||
"typescript": "^4.9.5",
|
||||
"vitest": "^0.28.5"
|
||||
}
|
||||
}
|
14
app/src/tiktoken/tsconfig.json
Normal file
14
app/src/tiktoken/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.js"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
61
app/src/titles.ts
Normal file
61
app/src/titles.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { createChatCompletion } from "./openai";
|
||||
import { OpenAIMessage, Chat } from "./types";
|
||||
|
||||
const systemPrompt = `
|
||||
Please read the following exchange and write a short, concise title describing the topic.
|
||||
`.trim();
|
||||
|
||||
const userPrompt = (user: string, assistant: string) => `
|
||||
Message: ${user}
|
||||
|
||||
Response: ${assistant}
|
||||
|
||||
Title:
|
||||
`.trim();
|
||||
|
||||
export async function createTitle(chat: Chat, apiKey: string | undefined | null, attempt = 0): Promise<string|null> {
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodes = Array.from(chat.messages.nodes.values());
|
||||
|
||||
const firstUserMessage = nodes.find(m => m.role === 'user');
|
||||
const firstAssistantMessage = nodes.find(m => m.role === 'assistant');
|
||||
|
||||
if (!firstUserMessage || !firstAssistantMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messages: OpenAIMessage[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content: systemPrompt,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: userPrompt(firstUserMessage!.content, firstAssistantMessage!.content),
|
||||
},
|
||||
];
|
||||
|
||||
let title = await createChatCompletion(messages as any, { temperature: 0.5, apiKey });
|
||||
|
||||
if (!title?.length) {
|
||||
if (firstUserMessage.content.trim().length > 2 && firstUserMessage.content.trim().length < 250) {
|
||||
return firstUserMessage.content.trim();
|
||||
}
|
||||
|
||||
if (attempt === 0) {
|
||||
return createTitle(chat, apiKey, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// remove periods at the end of the title
|
||||
title = title.replace(/(\w)\.$/g, '$1');
|
||||
|
||||
if (title.length > 250) {
|
||||
title = title.substring(0, 250) + '...';
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
104
app/src/tokenizer.ts
Normal file
104
app/src/tokenizer.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { OpenAIMessage } from "./types";
|
||||
|
||||
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 {
|
||||
return enc.encode(input).length;
|
||||
}
|
||||
|
||||
export function shortenStringToTokenCount(input: string, targetTokenCount: number) {
|
||||
const tokens = enc.encode(input);
|
||||
const buffer = enc.decode(tokens.slice(0, targetTokenCount));
|
||||
return new TextDecoder().decode(buffer) + "(...)";
|
||||
}
|
||||
|
||||
function serializeChatMLMessage(role: string, content: string) {
|
||||
const encodedContent = JSON.stringify(content)
|
||||
.replace(/^"/g, '').replace(/"$/g, '');
|
||||
|
||||
let chatml = '';
|
||||
chatml += `{"token": "<|im_start|>"},\n `;
|
||||
chatml += `"${role.toLocaleLowerCase}\\n${encodedContent}",\n `;
|
||||
chatml += `{"token": "<|im_end|>"}, "\\n"`;
|
||||
|
||||
return chatml;
|
||||
}
|
||||
|
||||
export function getTokenCountForMessages(messages: OpenAIMessage[]): number {
|
||||
let chatml = '[\n';
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const m = messages[i];
|
||||
const serializeMessage = serializeChatMLMessage(m.role, m.content);
|
||||
|
||||
chatml += ' ' + serializeMessage;
|
||||
|
||||
if (i < messages.length - 1) {
|
||||
chatml += ',';
|
||||
}
|
||||
chatml += '\n';
|
||||
}
|
||||
chatml += ']';
|
||||
return getTokenCount(chatml);
|
||||
}
|
||||
|
||||
export function selectMessagesToSendSafely(messages: OpenAIMessage[]) {
|
||||
const maxTokens = 2048;
|
||||
|
||||
if (getTokenCountForMessages(messages) <= maxTokens) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
const insertedSystemMessage = serializeChatMLMessage('system', 'Several messages not included due to space constraints');
|
||||
const insertedSystemMessageTokenCount = getTokenCount(insertedSystemMessage);
|
||||
const targetTokens = maxTokens - insertedSystemMessageTokenCount;
|
||||
const firstUserMessageIndex = messages.findIndex(m => m.role === 'user');
|
||||
let output = [...messages];
|
||||
|
||||
let removed = false;
|
||||
|
||||
// first, remove items in the 'middle' of the conversation until we're under the limit
|
||||
for (let i = firstUserMessageIndex + 1; i < messages.length - 1; i++) {
|
||||
if (getTokenCountForMessages(output) > targetTokens) {
|
||||
output.splice(i, 1);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// if we're still over the limit, trim message contents from oldest to newest (excluding the latest)
|
||||
if (getTokenCountForMessages(output) > targetTokens) {
|
||||
for (let i = 0; i < output.length - 1 && getTokenCountForMessages(output) > targetTokens; i++) {
|
||||
output[i].content = shortenStringToTokenCount(output[i].content, 20);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// if that still didn't work, just keep the system prompt and the latest message (truncated as needed)
|
||||
if (getTokenCountForMessages(output) > targetTokens) {
|
||||
const systemMessage = output.find(m => m.role === 'system')!;
|
||||
const latestMessage = { ...messages[messages.length - 1] };
|
||||
output = [systemMessage, latestMessage];
|
||||
removed = true;
|
||||
|
||||
const excessTokens = Math.max(0, getTokenCountForMessages(output) - targetTokens);
|
||||
|
||||
if (excessTokens) {
|
||||
const tokens = enc.encode(latestMessage.content);
|
||||
const buffer = enc.decode(tokens.slice(0, Math.max(0, tokens.length - excessTokens)));
|
||||
latestMessage.content = new TextDecoder().decode(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
if (removed) {
|
||||
output.splice(1, 0, {
|
||||
role: 'system',
|
||||
content: 'Several messages not included due to space constraints',
|
||||
});
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
49
app/src/tts/defaults.ts
Normal file
49
app/src/tts/defaults.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export const defaultVoiceList = [
|
||||
{
|
||||
"voice_id": "21m00Tcm4TlvDq8ikWAM",
|
||||
"name": "Rachel",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/21m00Tcm4TlvDq8ikWAM/6edb9076-c3e4-420c-b6ab-11d43fe341c8.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "AZnzlk1XvdvUeBnXmlld",
|
||||
"name": "Domi",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/AZnzlk1XvdvUeBnXmlld/69c5373f-0dc2-4efd-9232-a0140182c0a9.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "EXAVITQu4vr4xnSDxMaL",
|
||||
"name": "Bella",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/EXAVITQu4vr4xnSDxMaL/04365bce-98cc-4e99-9f10-56b60680cda9.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "ErXwobaYiN019PkySvjV",
|
||||
"name": "Antoni",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/ErXwobaYiN019PkySvjV/38d8f8f0-1122-4333-b323-0b87478d506a.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "MF3mGyEYCl7XYWbV9V6O",
|
||||
"name": "Elli",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/MF3mGyEYCl7XYWbV9V6O/f9fd64c3-5d62-45cd-b0dc-ad722ee3284e.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "TxGEqnHWrfWFTfGW9XjX",
|
||||
"name": "Josh",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/TxGEqnHWrfWFTfGW9XjX/c6c80dcd-5fe5-4a4c-a74c-b3fec4c62c67.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "VR6AewLTigWG4xSOukaG",
|
||||
"name": "Arnold",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/VR6AewLTigWG4xSOukaG/66e83dc2-6543-4897-9283-e028ac5ae4aa.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "pNInz6obpgDQGcFmaJgB",
|
||||
"name": "Adam",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/pNInz6obpgDQGcFmaJgB/e0b45450-78db-49b9-aaa4-d5358a6871bd.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "yoZ06aMxZJJ28mfd3POQ",
|
||||
"name": "Sam",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/yoZ06aMxZJJ28mfd3POQ/1c4d417c-ba80-4de8-874a-a1c57987ea63.mp3",
|
||||
}
|
||||
];
|
||||
|
||||
export const defaultElevenLabsVoiceID = defaultVoiceList.find(voice => voice.name === "Bella")!.voice_id;
|
285
app/src/tts/elevenlabs.tsx
Normal file
285
app/src/tts/elevenlabs.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { Button } from "@mantine/core";
|
||||
import EventEmitter from "events";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { split } from 'sentence-splitter';
|
||||
import { cloneArrayBuffer, md5, sleep } from "../utils";
|
||||
import * as idb from '../idb';
|
||||
import { useAppDispatch, useAppSelector } from "../store";
|
||||
import { selectElevenLabsApiKey } from "../store/api-keys";
|
||||
import { selectVoice } from "../store/voices";
|
||||
import { openElevenLabsApiKeyPanel } from "../store/settings-ui";
|
||||
import { defaultElevenLabsVoiceID } from "./defaults";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
const endpoint = 'https://api.elevenlabs.io';
|
||||
|
||||
let currentReader: ElevenLabsReader | null = null;
|
||||
|
||||
const cache = new Map<string, ArrayBuffer>();
|
||||
|
||||
export function createHeaders(apiKey = localStorage.getItem('elevenlabs-api-key') || '') {
|
||||
return {
|
||||
'xi-api-key': apiKey,
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
export async function getVoices() {
|
||||
const response = await fetch(`${endpoint}/v1/voices`, {
|
||||
headers: createHeaders(),
|
||||
});
|
||||
const json = await response.json();
|
||||
return json;
|
||||
}
|
||||
|
||||
const audioContext = new AudioContext();
|
||||
|
||||
export default class ElevenLabsReader extends EventEmitter {
|
||||
private apiKey: string;
|
||||
private initialized = false;
|
||||
private cancelled = false;
|
||||
private textSegments: string[] = [];
|
||||
private currentTrack: number = -1;
|
||||
private nextTrack: number = 0;
|
||||
private audios: (AudioBuffer | null)[] = [];
|
||||
private element: HTMLElement | undefined | null;
|
||||
private voiceID = defaultElevenLabsVoiceID;
|
||||
currentSource: AudioBufferSourceNode | undefined;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.apiKey = localStorage.getItem('elevenlabs-api-key') || '';
|
||||
}
|
||||
|
||||
private async createAudio() {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
this.initialized = true;
|
||||
|
||||
const chunkSize = 3;
|
||||
for (let i = 0; i < this.textSegments.length && !this.cancelled; i += chunkSize) {
|
||||
const chunk = this.textSegments.slice(i, i + chunkSize);
|
||||
await Promise.all(chunk.map((_, index) => this.createAudioForTextSegment(i + index)));
|
||||
}
|
||||
}
|
||||
|
||||
private async createAudioForTextSegment(index: number) {
|
||||
if (this.audios[index] || this.cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hash = await md5(this.textSegments[index]);
|
||||
const cacheKey = `audio:${this.voiceID}:${hash}`;
|
||||
|
||||
let buffer = cache.get(cacheKey);
|
||||
|
||||
if (!buffer) {
|
||||
buffer = await idb.get(cacheKey);
|
||||
}
|
||||
|
||||
if (!buffer) {
|
||||
const url = endpoint + '/v1/text-to-speech/' + this.voiceID;
|
||||
const maxAttempts = 3;
|
||||
|
||||
for (let i = 0; i < maxAttempts && !this.cancelled; i++) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: createHeaders(this.apiKey),
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
text: this.textSegments[index],
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
buffer = await response.arrayBuffer();
|
||||
cache.set(cacheKey, cloneArrayBuffer(buffer));
|
||||
idb.set(cacheKey, cloneArrayBuffer(buffer));
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
await sleep(2000 + i * 5000); // increasing backoff time
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer) {
|
||||
const data = await audioContext.decodeAudioData(buffer);
|
||||
this.audios[index] = data;
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForAudio(index: number, timeoutSeconds = 30) {
|
||||
if (!this.initialized) {
|
||||
this.createAudio().then(() => { });
|
||||
}
|
||||
|
||||
const timeoutAt = Date.now() + timeoutSeconds * 1000;
|
||||
while (Date.now() < timeoutAt && !this.cancelled) {
|
||||
if (this.audios[index]) {
|
||||
return;
|
||||
}
|
||||
this.emit('buffering');
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
this.cancelled = true;
|
||||
this.emit('error', new Error('Timed out waiting for audio'));
|
||||
}
|
||||
|
||||
public async play(element: HTMLElement, voiceID: string = defaultElevenLabsVoiceID, apiKey = this.apiKey) {
|
||||
this.element = element;
|
||||
this.voiceID = voiceID;
|
||||
this.apiKey = apiKey;
|
||||
|
||||
if (!this.element || !this.voiceID) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit('init');
|
||||
|
||||
if (currentReader != null) {
|
||||
await currentReader.stop();
|
||||
}
|
||||
currentReader = this;
|
||||
|
||||
this.cancelled = false;
|
||||
|
||||
if (!this.textSegments?.length) {
|
||||
this.textSegments = this.extractTextSegments();
|
||||
}
|
||||
|
||||
await this.next(true);
|
||||
}
|
||||
|
||||
private async next(play = false) {
|
||||
if (this.cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!play && this.nextTrack === 0) {
|
||||
this.emit('done');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTrack = this.nextTrack;
|
||||
this.currentTrack = currentTrack;
|
||||
|
||||
const nextTrack = (this.nextTrack + 1) % this.textSegments.length;
|
||||
this.nextTrack = nextTrack;
|
||||
|
||||
await this.waitForAudio(currentTrack);
|
||||
|
||||
if (this.cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit('playing');
|
||||
|
||||
try {
|
||||
this.currentSource = audioContext.createBufferSource();
|
||||
this.currentSource.buffer = this.audios[currentTrack];
|
||||
this.currentSource.connect(audioContext.destination);
|
||||
this.currentSource.onended = () => {
|
||||
this.next();
|
||||
};
|
||||
this.currentSource.start();
|
||||
} catch (e) {
|
||||
console.error('failed to play', e);
|
||||
this.emit('done');
|
||||
}
|
||||
}
|
||||
|
||||
public stop() {
|
||||
if (this.currentSource) {
|
||||
this.currentSource.stop();
|
||||
}
|
||||
this.audios = [];
|
||||
this.textSegments = [];
|
||||
this.nextTrack = 0;
|
||||
this.cancelled = true;
|
||||
this.initialized = false;
|
||||
this.emit('done');
|
||||
}
|
||||
|
||||
private extractTextSegments() {
|
||||
const selector = 'p, li, th, td, blockquote, pre code, h1, h2, h3, h3, h5, h6';
|
||||
const nodes = Array.from(this.element?.querySelectorAll(selector) || []);
|
||||
const lines: string[] = [];
|
||||
const blocks = nodes.filter(node => !node.parentElement?.closest(selector) && node.textContent);
|
||||
for (const block of blocks) {
|
||||
const tagName = block.tagName.toLowerCase();
|
||||
if (tagName === 'p' || tagName === 'li' || tagName === 'blockquote') {
|
||||
const sentences = split(block.textContent!);
|
||||
for (const sentence of sentences) {
|
||||
lines.push(sentence.raw.trim());
|
||||
}
|
||||
} else {
|
||||
lines.push(block.textContent!.trim());
|
||||
}
|
||||
}
|
||||
return lines.filter(line => line.length);
|
||||
}
|
||||
}
|
||||
|
||||
export function ElevenLabsReaderButton(props: { selector: string }) {
|
||||
const elevenLabsApiKey = useAppSelector(selectElevenLabsApiKey);
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const voice = useAppSelector(selectVoice);
|
||||
|
||||
const [status, setStatus] = useState<'idle' | 'init' | 'playing' | 'buffering'>('idle');
|
||||
// const [error, setError] = useState(false);
|
||||
const reader = useRef(new ElevenLabsReader());
|
||||
|
||||
useEffect(() => {
|
||||
const currentReader = reader.current;
|
||||
|
||||
currentReader.on('init', () => setStatus('init'));
|
||||
currentReader.on('playing', () => setStatus('playing'));
|
||||
currentReader.on('buffering', () => setStatus('buffering'));
|
||||
currentReader.on('error', () => {
|
||||
setStatus('idle');
|
||||
// setError(true);
|
||||
});
|
||||
currentReader.on('done', () => setStatus('idle'));
|
||||
|
||||
return () => {
|
||||
currentReader.removeAllListeners();
|
||||
currentReader.stop();
|
||||
};
|
||||
}, [props.selector]);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
if (status === 'idle') {
|
||||
if (!elevenLabsApiKey?.length) {
|
||||
dispatch(openElevenLabsApiKeyPanel());
|
||||
return;
|
||||
}
|
||||
|
||||
audioContext.resume();
|
||||
reader.current.play(document.querySelector(props.selector)!, voice, elevenLabsApiKey);
|
||||
} else {
|
||||
reader.current.stop();
|
||||
}
|
||||
}, [dispatch, status, props.selector, elevenLabsApiKey, voice]);
|
||||
|
||||
return (
|
||||
<Button variant="subtle" size="sm" compact onClickCapture={onClick} loading={status === 'init'}>
|
||||
{status !== 'init' && <i className="fa fa-headphones" />}
|
||||
{status === 'idle' && <span>
|
||||
<FormattedMessage defaultMessage="Play" />
|
||||
</span>}
|
||||
{status === 'buffering' && <span>
|
||||
<FormattedMessage defaultMessage="Loading audio..." />
|
||||
</span>}
|
||||
{status !== 'idle' && status !== 'buffering' && <span>
|
||||
<FormattedMessage defaultMessage="Stop" />
|
||||
</span>}
|
||||
</Button>
|
||||
);
|
||||
}
|
58
app/src/types.ts
Normal file
58
app/src/types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { MessageTree } from "./message-tree";
|
||||
|
||||
export interface Parameters {
|
||||
temperature: number;
|
||||
apiKey?: string;
|
||||
initialSystemPrompt?: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
chatID: string;
|
||||
parentID?: string;
|
||||
timestamp: number;
|
||||
role: string;
|
||||
content: string;
|
||||
parameters?: Parameters;
|
||||
done?: boolean;
|
||||
}
|
||||
|
||||
export interface UserSubmittedMessage {
|
||||
chatID: string;
|
||||
parentID?: string;
|
||||
content: string;
|
||||
requestedParameters: Parameters;
|
||||
}
|
||||
|
||||
export interface OpenAIMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function getOpenAIMessageFromMessage(message: Message): OpenAIMessage {
|
||||
return {
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
};
|
||||
}
|
||||
|
||||
export interface Chat {
|
||||
id: string;
|
||||
messages: MessageTree;
|
||||
title?: string | null;
|
||||
created: 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;
|
||||
}
|
76
app/src/use-chat.ts
Normal file
76
app/src/use-chat.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { backend } from "./backend";
|
||||
import { ChatManager } from "./chat-manager";
|
||||
import { Chat, Message } from './types';
|
||||
|
||||
export interface UseChatResult {
|
||||
chat: Chat | null | undefined;
|
||||
chatLoadedAt: number;
|
||||
messages: Message[];
|
||||
messagesToDisplay: Message[];
|
||||
leaf: Message | null | undefined;
|
||||
}
|
||||
|
||||
export function useChat(chatManager: ChatManager, id: string | undefined | null, share = false): UseChatResult {
|
||||
const [chat, setChat] = useState<Chat | null | undefined>(null);
|
||||
const [_, setVersion] = useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
|
||||
// used to prevent auto-scroll when chat is first opened
|
||||
const [chatLoadedAt, setLoadedAt] = useState(0);
|
||||
|
||||
const update = useCallback(async () => {
|
||||
if (id) {
|
||||
if (!share) {
|
||||
const c = chatManager.get(id);
|
||||
if (c) {
|
||||
setChat(c);
|
||||
setVersion(v => v + 1);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const c = await backend.current?.getSharedChat(id);
|
||||
if (c) {
|
||||
setChat(c);
|
||||
setVersion(v => v + 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
setChat(null);
|
||||
}, [id, share, chatManager]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
update();
|
||||
chatManager.on(id, update);
|
||||
setChat(chatManager.get(id));
|
||||
setLoadedAt(Date.now());
|
||||
} else {
|
||||
setChat(null);
|
||||
setLoadedAt(0);
|
||||
}
|
||||
return () => {
|
||||
if (id) {
|
||||
chatManager.off(id, update);
|
||||
}
|
||||
};
|
||||
}, [id, update, chatManager]);
|
||||
|
||||
const leaf = chat?.messages.mostRecentLeaf();
|
||||
|
||||
let messages: Message[] = [];
|
||||
let messagesToDisplay: Message[] = [];
|
||||
|
||||
if (leaf) {
|
||||
messages = (chat?.messages.getMessageChainTo(leaf?.id) || []);
|
||||
messagesToDisplay = messages.filter(m => ['user', 'assistant'].includes(m.role)) || [];
|
||||
}
|
||||
|
||||
return {
|
||||
chat,
|
||||
chatLoadedAt,
|
||||
messages,
|
||||
messagesToDisplay,
|
||||
leaf,
|
||||
};
|
||||
}
|
61
app/src/utils.ts
Normal file
61
app/src/utils.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import * as hashes from 'jshashes';
|
||||
|
||||
const hasher = new hashes.MD5();
|
||||
|
||||
const hashCache = new Map<string, string>();
|
||||
|
||||
export async function md5(data: string): Promise<string> {
|
||||
if (!hashCache.has(data)) {
|
||||
const hashHex = hasher.hex(data);
|
||||
hashCache.set(data, hashHex);
|
||||
}
|
||||
return hashCache.get(data)!;
|
||||
}
|
||||
|
||||
export function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function share(text: string) {
|
||||
if (navigator.share) {
|
||||
await navigator.share({
|
||||
text,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function ellipsize(text: string, maxLength: number) {
|
||||
if (text.length > maxLength) {
|
||||
return text.substring(0, maxLength) + '...';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function cloneArrayBuffer(buffer) {
|
||||
const newBuffer = new ArrayBuffer(buffer.byteLength);
|
||||
new Uint8Array(newBuffer).set(new Uint8Array(buffer));
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
1
app/src/values.ts
Normal file
1
app/src/values.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const APP_NAME = "Chat with GPT";
|
27
app/tsconfig.json
Normal file
27
app/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitAny": false,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
Reference in New Issue
Block a user