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 + + + + + + + + + +
+ + + 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 ( + + ) +} + +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 +
+