v0.1.0
parent
512348c0db
commit
1726f5b0f2
|
@ -0,0 +1,5 @@
|
||||||
|
const cracoWasm = require("craco-wasm");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
plugins: [cracoWasm()]
|
||||||
|
}
|
|
@ -0,0 +1,76 @@
|
||||||
|
{
|
||||||
|
"name": "chat-with-gpt",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@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",
|
||||||
|
"@testing-library/jest-dom": "^5.16.5",
|
||||||
|
"@testing-library/react": "^13.4.0",
|
||||||
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"@types/jest": "^27.5.2",
|
||||||
|
"@types/natural": "^5.1.2",
|
||||||
|
"@types/node": "^16.18.13",
|
||||||
|
"@types/react": "^18.0.28",
|
||||||
|
"@types/react-dom": "^18.0.11",
|
||||||
|
"@types/react-helmet": "^6.1.6",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.6",
|
||||||
|
"@types/uuid": "^9.0.1",
|
||||||
|
"broadcast-channel": "^4.20.2",
|
||||||
|
"craco": "^0.0.3",
|
||||||
|
"craco-wasm": "^0.0.1",
|
||||||
|
"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",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-helmet": "^6.1.0",
|
||||||
|
"react-markdown": "^8.0.5",
|
||||||
|
"react-router-dom": "^6.8.2",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"react-syntax-highlighter": "^15.5.0",
|
||||||
|
"rehype-katex": "^6.0.2",
|
||||||
|
"remark-gfm": "^3.0.1",
|
||||||
|
"remark-math": "^5.1.1",
|
||||||
|
"sass": "^1.58.3",
|
||||||
|
"sentence-splitter": "^4.2.0",
|
||||||
|
"slugify": "^1.6.5",
|
||||||
|
"sort-by": "^1.2.0",
|
||||||
|
"typescript": "^4.9.5",
|
||||||
|
"uuid": "^9.0.0",
|
||||||
|
"web-vitals": "^2.1.4"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "craco start",
|
||||||
|
"build": "craco build",
|
||||||
|
"test": "craco test",
|
||||||
|
"eject": "craco eject"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 3.8 KiB |
|
@ -0,0 +1,50 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<!-- mobile app viewport -->
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Web site created using create-react-app"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
|
-->
|
||||||
|
<title>Chat with GPT | Unofficial ChatGPT app</title>
|
||||||
|
<link rel="stylesheet" media="all" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.2/css/all.min.css" />
|
||||||
|
<link rel="stylesheet" media="all" href="https://fonts.googleapis.com/css?family=Open+Sans:100,400,300,500,700,800" />
|
||||||
|
<link rel="stylesheet" media="all" href="https://fonts.googleapis.com/css?family=Fira+Code:100,400,300,500,700,800" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Work+Sans:300,400,500,600,700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css" integrity="sha384-Xi8rHCmBmhbuyyhbI88391ZKP2dmfnOl4rT9ZfRI7mLTdk1wblIUnrIq35nqwEvC" crossorigin="anonymous">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tailwindcss/typography@0.4.1/dist/typography.min.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
Binary file not shown.
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
After Width: | Height: | Size: 9.4 KiB |
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"short_name": "Chat with GPT",
|
||||||
|
"name": "Chat with GPT",
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
|
@ -0,0 +1,33 @@
|
||||||
|
import EventEmitter from 'events';
|
||||||
|
import { Chat } from './types';
|
||||||
|
|
||||||
|
export let backend: Backend | null = null;
|
||||||
|
|
||||||
|
export class Backend extends EventEmitter {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
register() {
|
||||||
|
backend = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isAuthenticated() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async signIn(options?: any) {
|
||||||
|
}
|
||||||
|
|
||||||
|
async shareChat(chat: Chat): Promise<string|null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSharedChat(id: string): Promise<Chat|null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackend() {
|
||||||
|
return backend;
|
||||||
|
}
|
|
@ -0,0 +1,261 @@
|
||||||
|
import { BroadcastChannel } from 'broadcast-channel';
|
||||||
|
import EventEmitter from 'events';
|
||||||
|
import MiniSearch, { SearchResult } from 'minisearch'
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { Chat, getOpenAIMessageFromMessage, Message, 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';
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.load();
|
||||||
|
|
||||||
|
this.on('update', () => {
|
||||||
|
this.changed = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
channel.onmessage = (message: {
|
||||||
|
type: 'chat-update',
|
||||||
|
data: Chat,
|
||||||
|
}) => {
|
||||||
|
const id = message.data.id;
|
||||||
|
this.chats.set(id, message.data);
|
||||||
|
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: 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reply: Message = {
|
||||||
|
id: uuidv4(),
|
||||||
|
parentID: newMessage.id,
|
||||||
|
chatID: chat.id,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
done: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
chat.messages.addMessage(newMessage);
|
||||||
|
chat.messages.addMessage(reply);
|
||||||
|
chat.updated = Date.now();
|
||||||
|
|
||||||
|
this.emit(chat.id);
|
||||||
|
this.emit('messages', [newMessage]);
|
||||||
|
channel.postMessage({ type: 'chat-update', data: chat });
|
||||||
|
|
||||||
|
const messages: Message[] = message.parentID
|
||||||
|
? chat.messages.getMessageChainTo(message.parentID)
|
||||||
|
: [];
|
||||||
|
messages.push(newMessage);
|
||||||
|
|
||||||
|
const response = await createStreamingChatCompletion(messages.map(getOpenAIMessageFromMessage),
|
||||||
|
message.requestedParameters);
|
||||||
|
|
||||||
|
response.on('error', () => {
|
||||||
|
if (!reply.content) {
|
||||||
|
reply.content += "\n\nI'm sorry, I'm having trouble connecting to OpenAI. Please make sure you've entered your OpenAI API key correctly and try again.";
|
||||||
|
reply.content = reply.content.trim();
|
||||||
|
reply.done = true;
|
||||||
|
chat.messages.updateMessage(reply);
|
||||||
|
chat.updated = Date.now();
|
||||||
|
this.emit(chat.id);
|
||||||
|
this.emit('messages', [reply]);
|
||||||
|
channel.postMessage({ type: 'chat-update', data: chat });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
response.on('data', (data: string) => {
|
||||||
|
reply.content = data;
|
||||||
|
chat.messages.updateMessage(reply);
|
||||||
|
this.emit(chat.id);
|
||||||
|
channel.postMessage({ type: 'chat-update', data: chat });
|
||||||
|
});
|
||||||
|
|
||||||
|
response.on('done', async () => {
|
||||||
|
reply.done = true;
|
||||||
|
chat.messages.updateMessage(reply);
|
||||||
|
chat.updated = Date.now();
|
||||||
|
this.emit(chat.id);
|
||||||
|
this.emit('messages', [reply]);
|
||||||
|
this.emit('update');
|
||||||
|
channel.postMessage({ type: 'chat-update', data: chat });
|
||||||
|
setTimeout(() => this.search.update(chat), 500);
|
||||||
|
|
||||||
|
if (!chat.title) {
|
||||||
|
chat.title = await createTitle(chat, message.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: 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
const chat = this.chats.get(chatID);
|
||||||
|
if (!chat) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let description = chat.messages?.first?.content || '';
|
||||||
|
description = ellipsize(description, 400);
|
||||||
|
if (!chat.title || !description) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push({
|
||||||
|
chatID,
|
||||||
|
title: chat.title,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new ChatManager();
|
|
@ -0,0 +1,121 @@
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import Helmet from 'react-helmet';
|
||||||
|
import { useSpotlight } from '@mantine/spotlight';
|
||||||
|
import { Button, ButtonProps, TextInput } from '@mantine/core';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { APP_NAME } from '../values';
|
||||||
|
import { useAppContext } from '../context';
|
||||||
|
import { backend } from '../backend';
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
min-height: 2.618rem;
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
@media (min-width: 40em) {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
i + span {
|
||||||
|
@media (max-width: 40em) {
|
||||||
|
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 default function Header(props: { title?: any, onShare?: () => void, share?: boolean, canShare?: boolean }) {
|
||||||
|
const context = useAppContext();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const spotlight = useSpotlight();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const onNewChat = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
navigate(`/`);
|
||||||
|
setLoading(false);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const openSettings = useCallback(() => {
|
||||||
|
context.settings.open(context.apiKeys.openai ? 'options' : 'user');
|
||||||
|
}, [context, context.apiKeys.openai]);
|
||||||
|
|
||||||
|
return <Container>
|
||||||
|
<Helmet>
|
||||||
|
<title>{props.title ? `${props.title} - ` : ''}{APP_NAME} - Unofficial ChatGPT app</title>
|
||||||
|
</Helmet>
|
||||||
|
{props.title && <h1>{props.title}</h1>}
|
||||||
|
{!props.title && (<h1>
|
||||||
|
<div>
|
||||||
|
<strong>{APP_NAME}</strong><br />
|
||||||
|
<span>An unofficial ChatGPT app</span>
|
||||||
|
</div>
|
||||||
|
</h1>)}
|
||||||
|
<div className="spacer" />
|
||||||
|
<HeaderButton icon="search" onClick={spotlight.openSpotlight} />
|
||||||
|
<HeaderButton icon="gear" onClick={openSettings} />
|
||||||
|
{backend && !props.share && props.canShare && typeof navigator.share !== 'undefined' && <HeaderButton icon="share" onClick={props.onShare}>
|
||||||
|
Share
|
||||||
|
</HeaderButton>}
|
||||||
|
{backend && !context.authenticated && (
|
||||||
|
<HeaderButton onClick={() => backend?.signIn()}>Sign in to sync</HeaderButton>
|
||||||
|
)}
|
||||||
|
<HeaderButton icon="plus" onClick={onNewChat} loading={loading} variant="light">
|
||||||
|
New Chat
|
||||||
|
</HeaderButton>
|
||||||
|
</Container>;
|
||||||
|
}
|
|
@ -0,0 +1,113 @@
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { Button, ActionIcon, Textarea } from '@mantine/core';
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { useAppContext } from '../context';
|
||||||
|
import { Parameters } from '../types';
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
background: #292933;
|
||||||
|
border-top: thin solid #393933;
|
||||||
|
padding: 1rem 1rem 0 1rem;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0rem;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
|
||||||
|
.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>;
|
||||||
|
|
||||||
|
function PaperPlaneSubmitButton(props: { onSubmit: any, disabled?: boolean }) {
|
||||||
|
return (
|
||||||
|
<ActionIcon size="xs"
|
||||||
|
disabled={props.disabled}
|
||||||
|
loading={props.disabled}
|
||||||
|
onClick={() => props.onSubmit()}>
|
||||||
|
<i className="fa fa-paper-plane" style={{ fontSize: '90%' }} />
|
||||||
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageInputProps {
|
||||||
|
disabled?: boolean;
|
||||||
|
parameters: Parameters;
|
||||||
|
onSubmit: OnSubmit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageInput(props: MessageInputProps) {
|
||||||
|
const context = useAppContext();
|
||||||
|
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
|
const onChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setMessage(e.target.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSubmit = useCallback(async () => {
|
||||||
|
if (await props.onSubmit(message)) {
|
||||||
|
setMessage('');
|
||||||
|
}
|
||||||
|
}, [message, props.onSubmit]);
|
||||||
|
|
||||||
|
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.4rem',
|
||||||
|
}}>
|
||||||
|
<PaperPlaneSubmitButton onSubmit={onSubmit} disabled={props.disabled} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [onSubmit, props.disabled]);
|
||||||
|
|
||||||
|
const openSystemPromptPanel = useCallback(() => context.settings.open('options', 'system-prompt'), []);
|
||||||
|
const openTemperaturePanel = useCallback(() => context.settings.open('options', 'temperature'), []);
|
||||||
|
|
||||||
|
return <Container>
|
||||||
|
<div className="inner">
|
||||||
|
<Textarea disabled={props.disabled}
|
||||||
|
autosize
|
||||||
|
minRows={3}
|
||||||
|
maxRows={12}
|
||||||
|
placeholder={"Enter a message here..."}
|
||||||
|
value={message}
|
||||||
|
onChange={onChange}
|
||||||
|
rightSection={rightSection}
|
||||||
|
onKeyDown={onKeyDown} />
|
||||||
|
<div>
|
||||||
|
<Button variant="subtle"
|
||||||
|
className="settings-button"
|
||||||
|
size="xs"
|
||||||
|
compact
|
||||||
|
onClick={openSystemPromptPanel}>
|
||||||
|
<span>Customize system prompt</span>
|
||||||
|
</Button>
|
||||||
|
<Button variant="subtle"
|
||||||
|
className="settings-button"
|
||||||
|
size="xs"
|
||||||
|
compact
|
||||||
|
onClick={openTemperaturePanel}>
|
||||||
|
<span>Temperature: {props.parameters.temperature.toFixed(1)}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>;
|
||||||
|
}
|
|
@ -0,0 +1,267 @@
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import { Button, CopyButton, Loader } from '@mantine/core';
|
||||||
|
|
||||||
|
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 { Message } from "../types";
|
||||||
|
import { share } from '../utils';
|
||||||
|
import { ElevenLabsReaderButton } from '../elevenlabs';
|
||||||
|
|
||||||
|
// hide for everyone but screen readers
|
||||||
|
const SROnly = styled.span`
|
||||||
|
position: fixed;
|
||||||
|
left: -9999px;
|
||||||
|
top: -9999px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
&.by-user {
|
||||||
|
}
|
||||||
|
|
||||||
|
&.by-assistant {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.by-assistant + &.by-assistant, &.by-user + &.by-user {
|
||||||
|
border-top: 0.2rem dotted 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);
|
||||||
|
`;
|
||||||
|
|
||||||
|
function getRoleName(role: string, share = false) {
|
||||||
|
switch (role) {
|
||||||
|
case 'user':
|
||||||
|
return !share ? 'You' : 'User';
|
||||||
|
case 'assistant':
|
||||||
|
return 'ChatGPT';
|
||||||
|
case 'system':
|
||||||
|
return 'System';
|
||||||
|
default:
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function InlineLoader() {
|
||||||
|
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 }) {
|
||||||
|
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 ? 'Copied' : '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>Share</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={"prose dark:prose-invert content content-" + props.message.id}>
|
||||||
|
<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 ? 'Copied' : '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.message.content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{props.last && <EndOfChatMarker />}
|
||||||
|
</Container>
|
||||||
|
}
|
|
@ -0,0 +1,199 @@
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import slugify from 'slugify';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { Button, Drawer, Loader } from '@mantine/core';
|
||||||
|
import { SpotlightProvider } from '@mantine/spotlight';
|
||||||
|
|
||||||
|
import { Parameters } from '../types';
|
||||||
|
import MessageInput from './input';
|
||||||
|
import Header from './header';
|
||||||
|
import SettingsScreen from './settings-screen';
|
||||||
|
|
||||||
|
import { useChatSpotlightProps } from '../spotlight';
|
||||||
|
import { useChat } from '../use-chat';
|
||||||
|
import Message from './message';
|
||||||
|
import { loadParameters, saveParameters } from '../parameters';
|
||||||
|
import { useAppContext } from '../context';
|
||||||
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
|
import { APP_NAME } from '../values';
|
||||||
|
import { backend } from '../backend';
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: #292933;
|
||||||
|
color: white;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Messages = styled.div`
|
||||||
|
max-height: 100%;
|
||||||
|
overflow-y: scroll;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const EmptyMessage = styled.div`
|
||||||
|
min-height: 70vh;
|
||||||
|
padding-bottom: 10vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
font-family: "Work Sans", sans-serif;
|
||||||
|
line-height: 1.7;
|
||||||
|
gap: 1rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
function Empty(props: { loading?: boolean }) {
|
||||||
|
const context = useAppContext();
|
||||||
|
return (
|
||||||
|
<EmptyMessage>
|
||||||
|
{props.loading && <Loader variant="dots" />}
|
||||||
|
{!props.loading && <>
|
||||||
|
<p>Hello, how can I help you today?</p>
|
||||||
|
{!context.apiKeys.openai && (
|
||||||
|
<Button size="xs"
|
||||||
|
variant="light"
|
||||||
|
compact
|
||||||
|
onClick={() => context.settings.open('user', 'openai-api-key')}>
|
||||||
|
Connect your OpenAI account to get started
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>}
|
||||||
|
</EmptyMessage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatPage(props: any) {
|
||||||
|
const { id } = useParams();
|
||||||
|
const context = useAppContext();
|
||||||
|
const spotlightProps = useChatSpotlightProps();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { chat, messages, chatLoadedAt, leaf } = useChat(id, props.share);
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
|
||||||
|
const [_parameters, setParameters] = useState<Parameters>(loadParameters(id));
|
||||||
|
const [parameters] = useDebouncedValue(_parameters, 2000);
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
saveParameters(id, parameters);
|
||||||
|
}
|
||||||
|
}, [parameters]);
|
||||||
|
|
||||||
|
const onNewMessage = useCallback(async (message?: string) => {
|
||||||
|
if (props.share) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message?.trim().length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.apiKeys.openai) {
|
||||||
|
context.settings.open('user', 'openai-api-key');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGenerating(true);
|
||||||
|
|
||||||
|
if (chat) {
|
||||||
|
await context.chat.sendMessage({
|
||||||
|
chatID: chat.id,
|
||||||
|
content: message.trim(),
|
||||||
|
requestedParameters: {
|
||||||
|
...parameters,
|
||||||
|
apiKey: context.apiKeys.openai,
|
||||||
|
},
|
||||||
|
parentID: leaf?.id,
|
||||||
|
});
|
||||||
|
} else if (props.landing) {
|
||||||
|
const id = await context.chat.createChat();
|
||||||
|
await context.chat.sendMessage({
|
||||||
|
chatID: id,
|
||||||
|
content: message.trim(),
|
||||||
|
requestedParameters: {
|
||||||
|
...parameters,
|
||||||
|
apiKey: context.apiKeys.openai,
|
||||||
|
},
|
||||||
|
parentID: leaf?.id,
|
||||||
|
});
|
||||||
|
navigate('/chat/' + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => setGenerating(false), 4000);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, [chat, context.apiKeys.openai, leaf, parameters, props.landing]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.share) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldScroll = (Date.now() - 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);
|
||||||
|
}
|
||||||
|
}, [chatLoadedAt, messages.length]);
|
||||||
|
|
||||||
|
const disabled = generating
|
||||||
|
|| messages[messages.length - 1]?.role === 'user'
|
||||||
|
|| (messages.length > 0 && !messages[messages.length - 1]?.done);
|
||||||
|
|
||||||
|
const shouldShowChat = id && chat && !!messages.length;
|
||||||
|
|
||||||
|
return <SpotlightProvider {...spotlightProps}>
|
||||||
|
<Container key={chat?.id}>
|
||||||
|
<Header share={props.share} canShare={messages.length > 1}
|
||||||
|
title={(id && messages.length) ? chat?.title : null}
|
||||||
|
onShare={async () => {
|
||||||
|
if (chat) {
|
||||||
|
const id = await backend?.shareChat(chat);
|
||||||
|
if (id) {
|
||||||
|
const slug = chat.title ? '/' + slugify(chat.title.toLocaleLowerCase()) : '';
|
||||||
|
const url = window.location.origin + '/s/' + id + slug;
|
||||||
|
navigator.share?.({
|
||||||
|
title: chat.title || undefined,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
<Messages id="messages">
|
||||||
|
{shouldShowChat && <div style={{ paddingBottom: '20rem' }}>
|
||||||
|
{messages.map((message) => (
|
||||||
|
<Message message={message}
|
||||||
|
share={props.share}
|
||||||
|
last={chat.messages.leafs.some(n => n.id === message.id)} />
|
||||||
|
))}
|
||||||
|
</div>}
|
||||||
|
{!shouldShowChat && <Empty loading={(!props.landing && !chat) || props.share} />}
|
||||||
|
</Messages>
|
||||||
|
|
||||||
|
{!props.share && <MessageInput disabled={disabled} onSubmit={onNewMessage} parameters={parameters} />}
|
||||||
|
|
||||||
|
<Drawer size="50rem"
|
||||||
|
position='right'
|
||||||
|
opened={!!context.settings.tab}
|
||||||
|
onClose={() => context.settings.close()}
|
||||||
|
withCloseButton={false}>
|
||||||
|
<SettingsScreen parameters={_parameters} setParameters={setParameters} />
|
||||||
|
</Drawer>
|
||||||
|
</Container>
|
||||||
|
</SpotlightProvider>;
|
||||||
|
}
|
|
@ -0,0 +1,242 @@
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { Button, Grid, Select, Slider, Tabs, Textarea, TextInput } from "@mantine/core";
|
||||||
|
import { useMediaQuery } from '@mantine/hooks';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { defaultSystemPrompt } from '../openai';
|
||||||
|
import { defaultVoiceList, getVoices } from '../elevenlabs';
|
||||||
|
import { useAppContext } from '../context';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 interface SettingsScreenProps {
|
||||||
|
parameters: any;
|
||||||
|
setParameters: (parameters: any) => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsScreen(props: SettingsScreenProps) {
|
||||||
|
const context = useAppContext();
|
||||||
|
const small = useMediaQuery('(max-width: 40em)');
|
||||||
|
const { parameters, setParameters } = props;
|
||||||
|
|
||||||
|
const [voices, setVoices] = useState<any[]>(defaultVoiceList);
|
||||||
|
useEffect(() => {
|
||||||
|
if (context.apiKeys.elevenlabs) {
|
||||||
|
getVoices().then(data => {
|
||||||
|
if (data?.voices?.length) {
|
||||||
|
setVoices(data.voices);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [context.apiKeys.elevenlabs]);
|
||||||
|
|
||||||
|
if (!context.settings.tab) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Tabs defaultValue={context.settings.tab} 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>
|
||||||
|
|
||||||
|
<Tabs.Panel value="user">
|
||||||
|
<Settings>
|
||||||
|
<Grid style={{ marginBottom: '1.618rem' }} gutter={24}>
|
||||||
|
<Grid.Col span={12}>
|
||||||
|
<section className={context.settings.option === 'openai-api-key' ? 'focused' : ''}>
|
||||||
|
<h3>Your OpenAI API Key</h3>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Paste your API key here"
|
||||||
|
value={context.apiKeys.openai || ''}
|
||||||
|
onChange={event => {
|
||||||
|
setParameters({ ...parameters, apiKey: event.currentTarget.value });
|
||||||
|
context.apiKeys.setOpenAIApiKey(event.currentTarget.value);
|
||||||
|
}} />
|
||||||
|
<p><a href="https://platform.openai.com/account/api-keys" target="_blank">Find your API key here.</a> Your API key is stored only on this device and never transmitted to anyone except OpenAI.</p>
|
||||||
|
<p>OpenAI API key usage is billed at a pay-as-you-go rate, separate from your ChatGPT subscription.</p>
|
||||||
|
</section>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Settings>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="options">
|
||||||
|
<Settings>
|
||||||
|
<Grid style={{ marginBottom: '1.618rem' }} gutter={24}>
|
||||||
|
<Grid.Col span={12}>
|
||||||
|
<section className={context.settings.option === 'system-prompt' ? 'focused' : ''}>
|
||||||
|
<h3>System Prompt</h3>
|
||||||
|
<Textarea
|
||||||
|
value={parameters.initialSystemPrompt || defaultSystemPrompt}
|
||||||
|
onChange={event => setParameters({ ...parameters, initialSystemPrompt: event.currentTarget.value })}
|
||||||
|
minRows={5}
|
||||||
|
maxRows={10}
|
||||||
|
autosize />
|
||||||
|
<p style={{ marginBottom: '0.7rem' }}>The System Prompt is shown to ChatGPT by the "System" before your first message. The <code style={{ whiteSpace: 'nowrap' }}>{'{{ datetime }}'}</code> tag is automatically replaced by the current date and time.</p>
|
||||||
|
{(parameters.initialSystemPrompt?.trim() !== defaultSystemPrompt) && <Button size="xs" compact variant="light" onClick={() => setParameters({ ...parameters, initialSystemPrompt: defaultSystemPrompt })}>
|
||||||
|
Reset to default
|
||||||
|
</Button>}
|
||||||
|
</section>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={12}>
|
||||||
|
<section className={context.settings.option === 'temperature' ? 'focused' : ''}>
|
||||||
|
<h3>Temperature ({parameters.temperature.toFixed(1)})</h3>
|
||||||
|
<Slider value={parameters.temperature} onChange={value => setParameters({ ...parameters, temperature: value })} step={0.1} min={0} max={1} precision={3} />
|
||||||
|
<p>The temperature parameter controls the randomness of the AI's responses. Lower values will make the AI more predictable, while higher values will make it more creative.</p>
|
||||||
|
</section>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Settings>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="speech">
|
||||||
|
<Settings>
|
||||||
|
<Grid style={{ marginBottom: '1.618rem' }} gutter={24}>
|
||||||
|
<Grid.Col span={12}>
|
||||||
|
<section className={context.settings.option === 'elevenlabs-api-key' ? 'focused' : ''}>
|
||||||
|
<h3>Your ElevenLabs Text-to-Speech API Key (optional)</h3>
|
||||||
|
<TextInput placeholder="Paste your API key here" value={context.apiKeys.elevenlabs || ''} onChange={event => context.apiKeys.setElevenLabsApiKey(event.currentTarget.value)} />
|
||||||
|
<p>Give ChatGPT a realisic human voice by connecting your ElevenLabs account (preview the available voices below). <a href="https://beta.elevenlabs.io" target="_blank">Click here to sign up.</a></p>
|
||||||
|
<p>You can find your API key on the Profile tab of the ElevenLabs website. Your API key is stored only on this device and never transmitted to anyone except ElevenLabs.</p>
|
||||||
|
</section>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={12}>
|
||||||
|
<section className={context.settings.option === 'elevenlabs-voice' ? 'focused' : ''}>
|
||||||
|
<h3>Voice</h3>
|
||||||
|
<Select
|
||||||
|
value={context.voice.id}
|
||||||
|
onChange={v => context.voice.setVoiceID(v!)}
|
||||||
|
data={voices.map(v => ({ label: v.name, value: v.voice_id }))} />
|
||||||
|
<audio controls style={{ display: 'none' }} id="voice-preview" key={context.voice.id}>
|
||||||
|
<source src={voices.find(v => v.voice_id === context.voice.id)?.preview_url} type="audio/mpeg" />
|
||||||
|
</audio>
|
||||||
|
<Button onClick={() => document.getElementById('voice-preview')?.play()} variant='light' compact style={{ marginTop: '1rem' }}>
|
||||||
|
<i className='fa fa-headphones' />
|
||||||
|
<span>Preview voice</span>
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Settings>
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
<div id="save">
|
||||||
|
<Button variant="light" fullWidth size="md" onClick={() => context.settings.close()}>
|
||||||
|
Save and Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,103 @@
|
||||||
|
import React, { useState, useRef, useMemo, useEffect, useCallback } from "react";
|
||||||
|
import { backend } from "./backend";
|
||||||
|
import ChatManagerInstance, { ChatManager } from "./chat-manager";
|
||||||
|
import { defaultElevenLabsVoiceID } from "./elevenlabs";
|
||||||
|
|
||||||
|
export interface Context {
|
||||||
|
authenticated: boolean;
|
||||||
|
chat: ChatManager;
|
||||||
|
apiKeys: {
|
||||||
|
openai: string | undefined | null;
|
||||||
|
setOpenAIApiKey: (apiKey: string | null) => void;
|
||||||
|
elevenlabs: string | undefined | null;
|
||||||
|
setElevenLabsApiKey: (apiKey: string | null) => void;
|
||||||
|
};
|
||||||
|
settings: {
|
||||||
|
tab: string | undefined | null;
|
||||||
|
option: string | undefined | null;
|
||||||
|
open: (tab: string, option?: string | undefined | null) => void;
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
voice: {
|
||||||
|
id: string;
|
||||||
|
setVoiceID: (id: string) => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppContext = React.createContext<Context>({} as any);
|
||||||
|
|
||||||
|
export function useCreateAppContext(): Context {
|
||||||
|
const chat = useRef(ChatManagerInstance);
|
||||||
|
const [authenticated, setAuthenticated] = useState(backend?.isAuthenticated || false);
|
||||||
|
|
||||||
|
const updateAuth = useCallback((authenticated: boolean) => setAuthenticated(authenticated), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
backend?.on('authenticated', updateAuth);
|
||||||
|
return () => {
|
||||||
|
backend?.off('authenticated', updateAuth)
|
||||||
|
};
|
||||||
|
}, [backend]);
|
||||||
|
|
||||||
|
const [openaiApiKey, setOpenAIApiKey] = useState<string | null>(
|
||||||
|
localStorage.getItem('openai-api-key') || ''
|
||||||
|
);
|
||||||
|
const [elevenLabsApiKey, setElevenLabsApiKey] = useState<string | null>(
|
||||||
|
localStorage.getItem('elevenlabs-api-key') || ''
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('openai-api-key', openaiApiKey || '');
|
||||||
|
}, [openaiApiKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('elevenlabs-api-key', elevenLabsApiKey || '');
|
||||||
|
}, [elevenLabsApiKey]);
|
||||||
|
|
||||||
|
const [settingsTab, setSettingsTab] = useState<string | null | undefined>();
|
||||||
|
const [option, setOption] = useState<string | null | undefined>();
|
||||||
|
|
||||||
|
const [voiceID, setVoiceID] = useState(localStorage.getItem('voice-id') || defaultElevenLabsVoiceID);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('voice-id', voiceID);
|
||||||
|
}, [voiceID]);
|
||||||
|
|
||||||
|
const context = useMemo<Context>(() => ({
|
||||||
|
authenticated,
|
||||||
|
chat: chat.current,
|
||||||
|
apiKeys: {
|
||||||
|
openai: openaiApiKey,
|
||||||
|
elevenlabs: elevenLabsApiKey,
|
||||||
|
setOpenAIApiKey,
|
||||||
|
setElevenLabsApiKey,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
tab: settingsTab,
|
||||||
|
option: option,
|
||||||
|
open: (tab: string, option?: string | undefined | null) => {
|
||||||
|
setSettingsTab(tab);
|
||||||
|
setOption(option);
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
setSettingsTab(null);
|
||||||
|
setOption(null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
voice: {
|
||||||
|
id: voiceID,
|
||||||
|
setVoiceID,
|
||||||
|
},
|
||||||
|
}), [chat, authenticated, openaiApiKey, elevenLabsApiKey, settingsTab, option, voiceID]);
|
||||||
|
|
||||||
|
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>;
|
||||||
|
}
|
|
@ -0,0 +1,318 @@
|
||||||
|
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 { useAppContext } from "./context";
|
||||||
|
|
||||||
|
const endpoint = 'https://api.elevenlabs.io';
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 context = useAppContext();
|
||||||
|
const [status, setStatus] = useState<'idle' | 'init' | 'playing' | 'buffering'>('idle');
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const reader = useRef(new ElevenLabsReader());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reader.current.on('init', () => setStatus('init'));
|
||||||
|
reader.current.on('playing', () => setStatus('playing'));
|
||||||
|
reader.current.on('buffering', () => setStatus('buffering'));
|
||||||
|
reader.current.on('error', () => {
|
||||||
|
setStatus('idle');
|
||||||
|
setError(true);
|
||||||
|
});
|
||||||
|
reader.current.on('done', () => setStatus('idle'));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
reader.current.removeAllListeners();
|
||||||
|
reader.current.stop();
|
||||||
|
};
|
||||||
|
}, [reader.current, props.selector]);
|
||||||
|
|
||||||
|
const onClick = useCallback(() => {
|
||||||
|
if (status === 'idle') {
|
||||||
|
if (!context.apiKeys.elevenlabs?.length) {
|
||||||
|
context.settings.open('speech', 'elevenlabs-api-key');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const voice = context.voice.id;
|
||||||
|
audioContext.resume();
|
||||||
|
reader.current.play(document.querySelector(props.selector)!, voice, context.apiKeys.elevenlabs);
|
||||||
|
} else {
|
||||||
|
reader.current.stop();
|
||||||
|
}
|
||||||
|
}, [status, props.selector, context.apiKeys.elevenlabs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="subtle" size="sm" compact onClickCapture={onClick} loading={status === 'init'}>
|
||||||
|
{status !== 'init' && <i className="fa fa-headphones" />}
|
||||||
|
{status === 'idle' && <span>Play</span>}
|
||||||
|
{status === 'buffering' && <span>Loading audio...</span>}
|
||||||
|
{status !== 'idle' && status !== 'buffering' && <span>Stop</span>}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,105 @@
|
||||||
|
import * as idb from 'idb-keyval';
|
||||||
|
|
||||||
|
let supported = true;
|
||||||
|
const inMemoryCache = new Map<string, any>();
|
||||||
|
|
||||||
|
{
|
||||||
|
var db = indexedDB.open('idb-test');
|
||||||
|
db.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) {}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import {
|
||||||
|
createBrowserRouter,
|
||||||
|
RouterProvider,
|
||||||
|
} from "react-router-dom";
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import { ModalsProvider } from '@mantine/modals';
|
||||||
|
import ChatPage from './components/page';
|
||||||
|
import { AppContextProvider } from './context';
|
||||||
|
import './index.scss';
|
||||||
|
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: <ChatPage landing={true} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/chat/:id",
|
||||||
|
element: <ChatPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/s/:id",
|
||||||
|
element: <ChatPage share={true} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/s/:id/*",
|
||||||
|
element: <ChatPage share={true} />,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(
|
||||||
|
document.getElementById('root') as HTMLElement
|
||||||
|
);
|
||||||
|
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<MantineProvider theme={{ colorScheme: "dark" }}>
|
||||||
|
<AppContextProvider>
|
||||||
|
<ModalsProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</ModalsProvider>
|
||||||
|
</AppContextProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
|
@ -0,0 +1,114 @@
|
||||||
|
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();
|
||||||
|
|
||||||
|
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 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 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];
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,131 @@
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="react-scripts" />
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAppContext } from "./context";
|
||||||
|
|
||||||
|
export function useChatSpotlightProps() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const context = useAppContext();
|
||||||
|
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
context.chat.on('update', () => setVersion(v => v + 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 : '')),
|
||||||
|
}))
|
||||||
|
}, [navigate, version]);
|
||||||
|
|
||||||
|
const props = useMemo(() => ({
|
||||||
|
shortcut: ['mod + P'],
|
||||||
|
overlayColor: '#000000',
|
||||||
|
searchPlaceholder: 'Search your chats',
|
||||||
|
searchIcon: <i className="fa fa-search" />,
|
||||||
|
actions: search,
|
||||||
|
filter: (query: string, items: any) => items,
|
||||||
|
}), [search]);
|
||||||
|
|
||||||
|
return props;
|
||||||
|
}
|
|
@ -0,0 +1,210 @@
|
||||||
|
/**
|
||||||
|
* 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.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.xhr.abort();
|
||||||
|
this.xhr = null;
|
||||||
|
this._setReadyState(this.CLOSED);
|
||||||
|
};
|
||||||
|
};
|
|
@ -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;
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
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;
|
||||||
|
}
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { backend } from "./backend";
|
||||||
|
import { useAppContext } from "./context";
|
||||||
|
import { Chat, Message } from './types';
|
||||||
|
|
||||||
|
export function useChat(id: string | undefined | null, share = false) {
|
||||||
|
const context = useAppContext();
|
||||||
|
const [chat, setChat] = useState<Chat | null | undefined>(null);
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
|
|
||||||
|
// 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 = context.chat.get(id);
|
||||||
|
if (c) {
|
||||||
|
setChat(c);
|
||||||
|
setVersion(v => v + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const c = await backend?.getSharedChat(id);
|
||||||
|
if (c) {
|
||||||
|
setChat(c);
|
||||||
|
setVersion(v => v + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setChat(null);
|
||||||
|
}, [id, share]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
update();
|
||||||
|
context.chat.on(id, update);
|
||||||
|
setChat(context.chat.get(id));
|
||||||
|
setLoadedAt(Date.now());
|
||||||
|
} else {
|
||||||
|
setChat(null);
|
||||||
|
setLoadedAt(0);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (id) {
|
||||||
|
context.chat.off(id, update);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [id, update]);
|
||||||
|
|
||||||
|
const leaf = chat?.messages.mostRecentLeaf();
|
||||||
|
|
||||||
|
let messages: Message[] = [];
|
||||||
|
|
||||||
|
if (leaf) {
|
||||||
|
messages = (chat?.messages.getMessageChainTo(leaf?.id) || [])
|
||||||
|
.filter(m => ['user', 'assistant'].includes(m.role)) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chat,
|
||||||
|
chatLoadedAt,
|
||||||
|
messages,
|
||||||
|
leaf,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
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;
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export const APP_NAME = "Chat with GPT";
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
Loading…
Reference in New Issue