main
Cogent Apps 2023-03-06 05:30:58 -08:00 committed by GitHub
parent 512348c0db
commit 1726f5b0f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 2784 additions and 0 deletions

5
craco.config.js 100644
View File

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

76
package.json 100644
View File

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

BIN
public/favicon.ico 100644

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

50
public/index.html 100644
View File

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

BIN
public/logo192.png 100644

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/logo512.png 100644

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -0,0 +1,8 @@
{
"short_name": "Chat with GPT",
"name": "Chat with GPT",
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

33
src/backend.ts 100644
View File

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

261
src/chat-manager.ts 100644
View File

@ -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();

View File

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

View File

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

View File

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

View File

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

View File

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

103
src/context.tsx 100644
View File

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

318
src/elevenlabs.tsx 100644
View File

@ -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>
);
}

105
src/idb.ts 100644
View File

@ -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) {}
}
}

73
src/index.scss 100644
View File

@ -0,0 +1,73 @@
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
html,
body {
padding: 0;
margin: 0;
font-family: "Open Sans", sans-serif;
}
body {
overflow: hidden;
}
#root {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
font-size: 110%;
overflow: hidden;
}
.fa + span {
margin-left: 0.25rem;
}

46
src/index.tsx 100644
View File

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

114
src/message-tree.ts 100644
View File

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

131
src/openai.ts 100644
View File

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

33
src/parameters.ts 100644
View File

@ -0,0 +1,33 @@
import { Parameters } from "./types";
export const defaultParameters: Parameters = {
temperature: 0.5,
};
export function loadParameters(id: string | null | undefined = null): Parameters {
const apiKey = localStorage.getItem('openai-api-key') || undefined;
const key = id ? `parameters-${id}` : 'parameters';
try {
const raw = localStorage.getItem(key);
if (raw) {
const parameters = JSON.parse(raw) as Parameters;
parameters.apiKey = apiKey;
return parameters;
}
} catch (e) { }
return id ? loadParameters() : { ...defaultParameters, apiKey };
}
export function saveParameters(id: string, parameters: Parameters) {
if (parameters) {
const apiKey = parameters.apiKey;
delete parameters.apiKey;
localStorage.setItem(`parameters-${id}`, JSON.stringify(parameters));
localStorage.setItem('parameters', JSON.stringify(parameters));
if (apiKey) {
localStorage.setItem(`openai-api-key`, apiKey);
}
}
}

1
src/react-app-env.d.ts vendored 100644
View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

33
src/spotlight.tsx 100644
View File

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

210
src/sse.ts 100644
View File

@ -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);
};
};

61
src/titles.ts 100644
View File

@ -0,0 +1,61 @@
import { createChatCompletion } from "./openai";
import { OpenAIMessage, Chat } from "./types";
const systemPrompt = `
Please read the following exchange and write a short, concise title describing the topic.
`.trim();
const userPrompt = (user: string, assistant: string) => `
Message: ${user}
Response: ${assistant}
Title:
`.trim();
export async function createTitle(chat: Chat, apiKey: string | undefined | null, attempt = 0): Promise<string|null> {
if (!apiKey) {
return null;
}
const nodes = Array.from(chat.messages.nodes.values());
const firstUserMessage = nodes.find(m => m.role === 'user');
const firstAssistantMessage = nodes.find(m => m.role === 'assistant');
if (!firstUserMessage || !firstAssistantMessage) {
return null;
}
const messages: OpenAIMessage[] = [
{
role: 'system',
content: systemPrompt,
},
{
role: 'user',
content: userPrompt(firstUserMessage!.content, firstAssistantMessage!.content),
},
];
let title = await createChatCompletion(messages as any, { temperature: 0.5, apiKey });
if (!title?.length) {
if (firstUserMessage.content.trim().length > 2 && firstUserMessage.content.trim().length < 250) {
return firstUserMessage.content.trim();
}
if (attempt === 0) {
return createTitle(chat, apiKey, 1);
}
}
// remove periods at the end of the title
title = title.replace(/(\w)\.$/g, '$1');
if (title.length > 250) {
title = title.substring(0, 250) + '...';
}
return title;
}

45
src/types.ts 100644
View File

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

67
src/use-chat.ts 100644
View File

@ -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,
};
}

38
src/utils.ts 100644
View File

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

1
src/values.ts 100644
View File

@ -0,0 +1 @@
export const APP_NAME = "Chat with GPT";

27
tsconfig.json 100644
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"noImplicitAny": false,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}