diff --git a/craco.config.js b/craco.config.js
new file mode 100644
index 00000000..bb79ffc6
--- /dev/null
+++ b/craco.config.js
@@ -0,0 +1,5 @@
+const cracoWasm = require("craco-wasm");
+
+module.exports = {
+ plugins: [cracoWasm()]
+}
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..7fd15364
--- /dev/null
+++ b/package.json
@@ -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"
+ ]
+ }
+}
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 00000000..a11777cc
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 00000000..25f70599
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Chat with GPT | Unofficial ChatGPT app
+
+
+
+
+
+
+
+
+ You need to enable JavaScript to run this app.
+
+
+
+
diff --git a/public/logo192.png b/public/logo192.png
new file mode 100644
index 00000000..fc44b0a3
Binary files /dev/null and b/public/logo192.png differ
diff --git a/public/logo512.png b/public/logo512.png
new file mode 100644
index 00000000..a4e47a65
Binary files /dev/null and b/public/logo512.png differ
diff --git a/public/manifest.json b/public/manifest.json
new file mode 100644
index 00000000..7806318c
--- /dev/null
+++ b/public/manifest.json
@@ -0,0 +1,8 @@
+{
+ "short_name": "Chat with GPT",
+ "name": "Chat with GPT",
+ "start_url": ".",
+ "display": "standalone",
+ "theme_color": "#000000",
+ "background_color": "#ffffff"
+}
diff --git a/public/robots.txt b/public/robots.txt
new file mode 100644
index 00000000..e9e57dc4
--- /dev/null
+++ b/public/robots.txt
@@ -0,0 +1,3 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
+Disallow:
diff --git a/src/backend.ts b/src/backend.ts
new file mode 100644
index 00000000..d126cba6
--- /dev/null
+++ b/src/backend.ts
@@ -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 {
+ return null;
+ }
+
+ async getSharedChat(id: string): Promise {
+ return null;
+ }
+}
+
+export function getBackend() {
+ return backend;
+}
\ No newline at end of file
diff --git a/src/chat-manager.ts b/src/chat-manager.ts
new file mode 100644
index 00000000..1ab7a199
--- /dev/null
+++ b/src/chat-manager.ts
@@ -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();
+ 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 {
+ 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) {
+ }
+
+ 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();
diff --git a/src/components/header.tsx b/src/components/header.tsx
new file mode 100644
index 00000000..d116d679
--- /dev/null
+++ b/src/components/header.tsx
@@ -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 (
+
+ {props.icon && }
+ {props.children &&
+ {props.children}
+ }
+
+ )
+}
+
+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
+
+ {props.title ? `${props.title} - ` : ''}{APP_NAME} - Unofficial ChatGPT app
+
+ {props.title && {props.title} }
+ {!props.title && (
+
+ {APP_NAME}
+ An unofficial ChatGPT app
+
+ )}
+
+
+
+ {backend && !props.share && props.canShare && typeof navigator.share !== 'undefined' &&
+ Share
+ }
+ {backend && !context.authenticated && (
+ backend?.signIn()}>Sign in to sync
+ )}
+
+ New Chat
+
+ ;
+}
\ No newline at end of file
diff --git a/src/components/input.tsx b/src/components/input.tsx
new file mode 100644
index 00000000..8ed0d555
--- /dev/null
+++ b/src/components/input.tsx
@@ -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;
+
+function PaperPlaneSubmitButton(props: { onSubmit: any, disabled?: boolean }) {
+ return (
+ props.onSubmit()}>
+
+
+ );
+}
+
+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) => {
+ setMessage(e.target.value);
+ }, []);
+
+ const onSubmit = useCallback(async () => {
+ if (await props.onSubmit(message)) {
+ setMessage('');
+ }
+ }, [message, props.onSubmit]);
+
+ const onKeyDown = useCallback((e: React.KeyboardEvent) => {
+ if (e.key === 'Enter'&& e.shiftKey === false && !props.disabled) {
+ e.preventDefault();
+ onSubmit();
+ }
+ }, [onSubmit, props.disabled]);
+
+ const rightSection = useMemo(() => {
+ return (
+
+ );
+ }, [onSubmit, props.disabled]);
+
+ const openSystemPromptPanel = useCallback(() => context.settings.open('options', 'system-prompt'), []);
+ const openTemperaturePanel = useCallback(() => context.settings.open('options', 'temperature'), []);
+
+ return
+
+
+
+
+ Customize system prompt
+
+
+ Temperature: {props.parameters.temperature.toFixed(1)}
+
+
+
+ ;
+}
\ No newline at end of file
diff --git a/src/components/message.tsx b/src/components/message.tsx
new file mode 100644
index 00000000..15b79699
--- /dev/null
+++ b/src/components/message.tsx
@@ -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 (
+
+ );
+}
+
+export default function MessageComponent(props: { message: Message, last: boolean, share?: boolean }) {
+ if (props.message.role === 'system') {
+ return null;
+ }
+
+ return
+
+
+
+
+ {getRoleName(props.message.role, props.share)}:
+
+ {props.message.role === 'assistant' && props.last && !props.message.done && }
+
+ {props.message.done &&
}
+
+
+ {({ copy, copied }) => (
+
+
+ {copied ? 'Copied' : 'Copy'}
+
+ )}
+
+ {typeof navigator.share !== 'undefined' && (
+
share(props.message.content)}>
+
+ Share
+
+ )}
+
+
+
+
+ {({ copy, copied }) => (
+
+
+ {copied ? 'Copied' : 'Copy'}
+
+ )}
+
+
+
+ ) : (
+
+ {children}
+
+ )
+ }
+ }}>{props.message.content}
+
+
+ {props.last && }
+
+}
\ No newline at end of file
diff --git a/src/components/page.tsx b/src/components/page.tsx
new file mode 100644
index 00000000..70b1d51d
--- /dev/null
+++ b/src/components/page.tsx
@@ -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 (
+
+ {props.loading && }
+ {!props.loading && <>
+ Hello, how can I help you today?
+ {!context.apiKeys.openai && (
+ context.settings.open('user', 'openai-api-key')}>
+ Connect your OpenAI account to get started
+
+ )}
+ >}
+
+ );
+}
+
+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(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
+
+
+ ;
+}
\ No newline at end of file
diff --git a/src/components/settings-screen.tsx b/src/components/settings-screen.tsx
new file mode 100644
index 00000000..100a7999
--- /dev/null
+++ b/src/components/settings-screen.tsx
@@ -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(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 (
+
+
+
+ Options
+ User
+ Speech
+
+
+
+
+
+
+
+ Your OpenAI API Key
+ {
+ setParameters({ ...parameters, apiKey: event.currentTarget.value });
+ context.apiKeys.setOpenAIApiKey(event.currentTarget.value);
+ }} />
+ Find your API key here. Your API key is stored only on this device and never transmitted to anyone except OpenAI.
+ OpenAI API key usage is billed at a pay-as-you-go rate, separate from your ChatGPT subscription.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Temperature ({parameters.temperature.toFixed(1)})
+ setParameters({ ...parameters, temperature: value })} step={0.1} min={0} max={1} precision={3} />
+ 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.
+
+
+
+
+
+
+
+
+
+
+
+ Your ElevenLabs Text-to-Speech API Key (optional)
+ context.apiKeys.setElevenLabsApiKey(event.currentTarget.value)} />
+ Give ChatGPT a realisic human voice by connecting your ElevenLabs account (preview the available voices below). Click here to sign up.
+ 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.
+
+
+
+
+ Voice
+ context.voice.setVoiceID(v!)}
+ data={voices.map(v => ({ label: v.name, value: v.voice_id }))} />
+
+ v.voice_id === context.voice.id)?.preview_url} type="audio/mpeg" />
+
+ document.getElementById('voice-preview')?.play()} variant='light' compact style={{ marginTop: '1rem' }}>
+
+ Preview voice
+
+
+
+
+
+
+
+
+ context.settings.close()}>
+ Save and Close
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/context.tsx b/src/context.tsx
new file mode 100644
index 00000000..f0f9991a
--- /dev/null
+++ b/src/context.tsx
@@ -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({} 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(
+ localStorage.getItem('openai-api-key') || ''
+ );
+ const [elevenLabsApiKey, setElevenLabsApiKey] = useState(
+ 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();
+ const [option, setOption] = useState();
+
+ const [voiceID, setVoiceID] = useState(localStorage.getItem('voice-id') || defaultElevenLabsVoiceID);
+
+ useEffect(() => {
+ localStorage.setItem('voice-id', voiceID);
+ }, [voiceID]);
+
+ const context = useMemo(() => ({
+ 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 {props.children} ;
+}
\ No newline at end of file
diff --git a/src/elevenlabs.tsx b/src/elevenlabs.tsx
new file mode 100644
index 00000000..68d6f74e
--- /dev/null
+++ b/src/elevenlabs.tsx
@@ -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();
+
+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 (
+
+ {status !== 'init' && }
+ {status === 'idle' && Play }
+ {status === 'buffering' && Loading audio... }
+ {status !== 'idle' && status !== 'buffering' && Stop }
+
+ );
+}
diff --git a/src/idb.ts b/src/idb.ts
new file mode 100644
index 00000000..ff3e7879
--- /dev/null
+++ b/src/idb.ts
@@ -0,0 +1,105 @@
+import * as idb from 'idb-keyval';
+
+let supported = true;
+const inMemoryCache = new Map();
+
+{
+ 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) {}
+ }
+}
\ No newline at end of file
diff --git a/src/index.scss b/src/index.scss
new file mode 100644
index 00000000..ca31f048
--- /dev/null
+++ b/src/index.scss
@@ -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;
+}
\ No newline at end of file
diff --git a/src/index.tsx b/src/index.tsx
new file mode 100644
index 00000000..eb09c28a
--- /dev/null
+++ b/src/index.tsx
@@ -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: ,
+ },
+ {
+ path: "/chat/:id",
+ element: ,
+ },
+ {
+ path: "/s/:id",
+ element: ,
+ },
+ {
+ path: "/s/:id/*",
+ element: ,
+ },
+]);
+
+const root = ReactDOM.createRoot(
+ document.getElementById('root') as HTMLElement
+);
+
+root.render(
+
+
+
+
+
+
+
+
+
+);
\ No newline at end of file
diff --git a/src/message-tree.ts b/src/message-tree.ts
new file mode 100644
index 00000000..8490081f
--- /dev/null
+++ b/src/message-tree.ts
@@ -0,0 +1,114 @@
+import { Message } from "./types";
+
+export interface Node extends Message {
+ parent: Node | null;
+ children: Set;
+}
+
+export function createNode(message: Message): Node {
+ return {
+ ...message,
+ parent: null,
+ children: new Set(),
+ };
+}
+
+export class MessageTree {
+ public nodes: Map = 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];
+ }
+}
\ No newline at end of file
diff --git a/src/openai.ts b/src/openai.ts
new file mode 100644
index 00000000..802371b6
--- /dev/null
+++ b/src/openai.ts
@@ -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 {
+ 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;
+}
\ No newline at end of file
diff --git a/src/parameters.ts b/src/parameters.ts
new file mode 100644
index 00000000..0d52273e
--- /dev/null
+++ b/src/parameters.ts
@@ -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);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts
new file mode 100644
index 00000000..6431bc5f
--- /dev/null
+++ b/src/react-app-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/src/spotlight.tsx b/src/spotlight.tsx
new file mode 100644
index 00000000..ffb66fd8
--- /dev/null
+++ b/src/spotlight.tsx
@@ -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: ,
+ actions: search,
+ filter: (query: string, items: any) => items,
+ }), [search]);
+
+ return props;
+}
\ No newline at end of file
diff --git a/src/sse.ts b/src/sse.ts
new file mode 100644
index 00000000..a422f95f
--- /dev/null
+++ b/src/sse.ts
@@ -0,0 +1,210 @@
+/**
+ * Copyright (C) 2016 Maxime Petazzoni .
+ * 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('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);
+ };
+};
\ No newline at end of file
diff --git a/src/titles.ts b/src/titles.ts
new file mode 100644
index 00000000..e29b3b86
--- /dev/null
+++ b/src/titles.ts
@@ -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 {
+ 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;
+}
\ No newline at end of file
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 00000000..7af76cbd
--- /dev/null
+++ b/src/types.ts
@@ -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;
+}
\ No newline at end of file
diff --git a/src/use-chat.ts b/src/use-chat.ts
new file mode 100644
index 00000000..60df115b
--- /dev/null
+++ b/src/use-chat.ts
@@ -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(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,
+ };
+}
\ No newline at end of file
diff --git a/src/utils.ts b/src/utils.ts
new file mode 100644
index 00000000..e80a6662
--- /dev/null
+++ b/src/utils.ts
@@ -0,0 +1,38 @@
+import * as hashes from 'jshashes';
+
+const hasher = new hashes.MD5();
+
+const hashCache = new Map();
+
+export async function md5(data: string): Promise {
+ 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;
+}
diff --git a/src/values.ts b/src/values.ts
new file mode 100644
index 00000000..c50a3f20
--- /dev/null
+++ b/src/values.ts
@@ -0,0 +1 @@
+export const APP_NAME = "Chat with GPT";
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 00000000..80e14924
--- /dev/null
+++ b/tsconfig.json
@@ -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"
+ ]
+}