This commit is contained in:
Cogent Apps
2023-03-06 05:30:58 -08:00
committed by GitHub
parent 512348c0db
commit 1726f5b0f2
32 changed files with 2784 additions and 0 deletions

33
src/backend.ts Normal file
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 Normal file
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();

121
src/components/header.tsx Normal file
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>;
}

113
src/components/input.tsx Normal file
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>;
}

267
src/components/message.tsx Normal file
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>
}

199
src/components/page.tsx Normal file
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 Normal file
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 Normal file
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 Normal file
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 Normal file
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 Normal file
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 Normal file
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 Normal file
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 Normal file
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 Normal file
View File

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

33
src/spotlight.tsx Normal file
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 Normal file
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 Normal file
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 Normal file
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 Normal file
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 Normal file
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 Normal file
View File

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