From eb58d900b50c3a508b5e3910f542d31c75b1b5cc Mon Sep 17 00:00:00 2001 From: Cogent Apps Date: Sat, 15 Apr 2023 10:30:02 +0000 Subject: [PATCH] v0.2.3 --- app/package.json | 14 +- app/public/index.html | 1 + app/public/lang/en-us.json | 55 ++- app/src/backend.ts | 173 -------- app/src/chat-manager.ts | 395 ------------------ .../{auth/modals.tsx => auth-modals.tsx} | 6 +- app/src/components/header.tsx | 63 ++- app/src/components/input.tsx | 114 +++-- app/src/components/markdown.tsx | 2 +- app/src/components/message.tsx | 27 +- app/src/components/page.tsx | 20 +- app/src/components/pages/chat.tsx | 43 +- app/src/components/pages/landing.tsx | 14 +- app/src/components/quick-settings.tsx | 66 +++ app/src/components/settings/chat.tsx | 5 + app/src/components/settings/index.tsx | 24 +- app/src/components/settings/option.tsx | 13 + app/src/components/settings/options.tsx | 101 ----- app/src/components/settings/speech.tsx | 78 +--- app/src/components/settings/tab.tsx | 194 ++++++++- .../components/settings/ui-preferences.tsx | 5 + app/src/components/settings/user.tsx | 119 +++--- app/src/components/sidebar/index.tsx | 34 +- app/src/components/sidebar/recent-chats.tsx | 109 +++-- app/src/components/tts-button.tsx | 63 +++ app/src/components/tts-controls.tsx | 130 ++++++ app/src/context.tsx | 186 --------- app/src/core/backend.ts | 276 ++++++++++++ app/src/core/chat/chat-persistance.ts | 71 ++++ app/src/core/chat/create-reply.ts | 185 ++++++++ app/src/core/chat/message-tree.ts | 197 +++++++++ app/src/{ => core/chat}/openai.ts | 114 ++--- app/src/{ => core/chat}/parameters.ts | 0 app/src/{ => core/chat}/types.ts | 2 + app/src/{ => core/chat}/use-chat.ts | 4 +- app/src/core/chat/y-chat.ts | 307 ++++++++++++++ app/src/core/context.tsx | 256 ++++++++++++ app/src/core/index.ts | 254 +++++++++++ app/src/core/options/index.ts | 209 +++++++++ app/src/core/options/option-group.tsx | 20 + app/src/core/options/option.ts | 34 ++ app/src/core/options/render-props.ts | 41 ++ app/src/core/options/use-option.ts | 45 ++ app/src/core/plugins/command.ts | 10 + app/src/core/plugins/index.ts | 30 ++ app/src/core/plugins/metadata.ts | 15 + app/src/core/plugins/plugin-context.ts | 18 + app/src/core/plugins/plugin-description.ts | 9 + app/src/core/plugins/plugin-runner.ts | 24 ++ app/src/core/search.ts | 90 ++++ .../{ => core}/speech-recognition-types.ts | 0 app/src/{ => core}/tokenizer/bpe.ts | 0 .../tokenizer/chat-history-trimmer.ts | 2 +- app/src/{ => core}/tokenizer/cl100k_base.json | 0 app/src/{ => core}/tokenizer/index.ts | 3 +- app/src/core/tokenizer/worker.ts | 16 + app/src/core/tokenizer/wrapper.ts | 28 ++ app/src/core/tts/audio-file-player.ts | 286 +++++++++++++ app/src/core/tts/direct-tts-player.ts | 120 ++++++ app/src/core/tts/direct-tts-plugin.ts | 10 + app/src/core/tts/tts-plugin.ts | 16 + app/src/core/tts/types.ts | 48 +++ app/src/core/tts/use-tts.tsx | 162 +++++++ app/src/core/utils/async-loop.ts | 46 ++ .../utils/event-emitter-async-iterator.ts | 91 ++++ app/src/{ => core/utils}/idb.ts | 17 + app/src/core/utils/index.ts | 101 +++++ app/src/{ => core/utils}/sse.ts | 285 +++++++------ app/src/global-options/index.tsx | 43 ++ app/src/global-options/openai.tsx | 32 ++ app/src/global-options/parameters.tsx | 64 +++ app/src/global-options/tts-service.tsx | 34 ++ app/src/global-options/ui.tsx | 50 +++ app/src/global-options/whisper.tsx | 31 ++ app/src/index.tsx | 3 +- app/src/message-tree.ts | 132 ------ app/src/plugins/index.ts | 16 + app/src/plugins/system-prompt.tsx | 60 +++ app/src/plugins/titles.ts | 75 ++++ app/src/plugins/trimmer.ts | 106 +++++ app/src/spotlight.tsx | 26 +- app/src/store/api-keys.ts | 42 -- app/src/store/index.ts | 11 +- app/src/store/parameters.ts | 37 -- app/src/store/settings-ui.ts | 4 +- app/src/store/voices.ts | 23 - app/src/stub.js | 1 + app/src/titles.ts | 61 --- .../elevenlabs-defaults.ts} | 0 app/src/tts-plugins/elevenlabs.tsx | 234 +++++++++++ app/src/tts-plugins/web-speech.ts | 123 ++++++ app/src/tts/elevenlabs.tsx | 285 ------------- app/src/utils.ts | 61 --- server/generate-self-signed-certificate.sh | 13 + server/package.json | 11 +- server/src/auth0.ts | 36 +- server/src/config.ts | 140 +++++++ server/src/database/index.ts | 21 + server/src/database/knex.ts | 204 +++++++++ server/src/database/sqlite.ts | 202 --------- server/src/endpoints/base.ts | 10 + server/src/endpoints/completion/basic.ts | 20 - server/src/endpoints/completion/streaming.ts | 57 --- server/src/endpoints/messages.ts | 18 - .../elevenlabs/text-to-speech.ts | 28 ++ .../service-proxies/elevenlabs/voices.ts | 21 + .../endpoints/service-proxies/openai/basic.ts | 15 + .../endpoints/service-proxies/openai/index.ts | 22 + .../service-proxies/openai/streaming.ts | 51 +++ server/src/endpoints/session.ts | 10 +- server/src/endpoints/sync-legacy.ts | 46 ++ server/src/endpoints/sync.ts | 74 ++-- server/src/endpoints/title.ts | 13 - server/src/endpoints/whisper.ts | 8 - server/src/index.ts | 152 +++++-- server/src/object-store/sqlite.ts | 2 - server/src/passport.ts | 4 +- server/src/utils.ts | 4 +- 118 files changed, 5773 insertions(+), 2459 deletions(-) delete mode 100644 app/src/backend.ts delete mode 100644 app/src/chat-manager.ts rename app/src/components/{auth/modals.tsx => auth-modals.tsx} (96%) create mode 100644 app/src/components/quick-settings.tsx create mode 100644 app/src/components/settings/chat.tsx delete mode 100644 app/src/components/settings/options.tsx create mode 100644 app/src/components/settings/ui-preferences.tsx create mode 100644 app/src/components/tts-button.tsx create mode 100644 app/src/components/tts-controls.tsx delete mode 100644 app/src/context.tsx create mode 100644 app/src/core/backend.ts create mode 100644 app/src/core/chat/chat-persistance.ts create mode 100644 app/src/core/chat/create-reply.ts create mode 100644 app/src/core/chat/message-tree.ts rename app/src/{ => core/chat}/openai.ts (53%) rename app/src/{ => core/chat}/parameters.ts (100%) rename app/src/{ => core/chat}/types.ts (94%) rename app/src/{ => core/chat}/use-chat.ts (96%) create mode 100644 app/src/core/chat/y-chat.ts create mode 100644 app/src/core/context.tsx create mode 100644 app/src/core/index.ts create mode 100644 app/src/core/options/index.ts create mode 100644 app/src/core/options/option-group.tsx create mode 100644 app/src/core/options/option.ts create mode 100644 app/src/core/options/render-props.ts create mode 100644 app/src/core/options/use-option.ts create mode 100644 app/src/core/plugins/command.ts create mode 100644 app/src/core/plugins/index.ts create mode 100644 app/src/core/plugins/metadata.ts create mode 100644 app/src/core/plugins/plugin-context.ts create mode 100644 app/src/core/plugins/plugin-description.ts create mode 100644 app/src/core/plugins/plugin-runner.ts create mode 100644 app/src/core/search.ts rename app/src/{ => core}/speech-recognition-types.ts (100%) rename app/src/{ => core}/tokenizer/bpe.ts (100%) rename app/src/{ => core}/tokenizer/chat-history-trimmer.ts (99%) rename app/src/{ => core}/tokenizer/cl100k_base.json (100%) rename app/src/{ => core}/tokenizer/index.ts (96%) create mode 100644 app/src/core/tokenizer/worker.ts create mode 100644 app/src/core/tokenizer/wrapper.ts create mode 100644 app/src/core/tts/audio-file-player.ts create mode 100644 app/src/core/tts/direct-tts-player.ts create mode 100644 app/src/core/tts/direct-tts-plugin.ts create mode 100644 app/src/core/tts/tts-plugin.ts create mode 100644 app/src/core/tts/types.ts create mode 100644 app/src/core/tts/use-tts.tsx create mode 100644 app/src/core/utils/async-loop.ts create mode 100644 app/src/core/utils/event-emitter-async-iterator.ts rename app/src/{ => core/utils}/idb.ts (72%) create mode 100644 app/src/core/utils/index.ts rename app/src/{ => core/utils}/sse.ts (52%) create mode 100644 app/src/global-options/index.tsx create mode 100644 app/src/global-options/openai.tsx create mode 100644 app/src/global-options/parameters.tsx create mode 100644 app/src/global-options/tts-service.tsx create mode 100644 app/src/global-options/ui.tsx create mode 100644 app/src/global-options/whisper.tsx delete mode 100644 app/src/message-tree.ts create mode 100644 app/src/plugins/index.ts create mode 100644 app/src/plugins/system-prompt.tsx create mode 100644 app/src/plugins/titles.ts create mode 100644 app/src/plugins/trimmer.ts delete mode 100644 app/src/store/api-keys.ts delete mode 100644 app/src/store/parameters.ts delete mode 100644 app/src/store/voices.ts create mode 100644 app/src/stub.js delete mode 100644 app/src/titles.ts rename app/src/{tts/defaults.ts => tts-plugins/elevenlabs-defaults.ts} (100%) create mode 100644 app/src/tts-plugins/elevenlabs.tsx create mode 100644 app/src/tts-plugins/web-speech.ts delete mode 100644 app/src/tts/elevenlabs.tsx delete mode 100644 app/src/utils.ts create mode 100755 server/generate-self-signed-certificate.sh create mode 100644 server/src/config.ts create mode 100644 server/src/database/knex.ts delete mode 100644 server/src/database/sqlite.ts delete mode 100644 server/src/endpoints/completion/basic.ts delete mode 100644 server/src/endpoints/completion/streaming.ts delete mode 100644 server/src/endpoints/messages.ts create mode 100644 server/src/endpoints/service-proxies/elevenlabs/text-to-speech.ts create mode 100644 server/src/endpoints/service-proxies/elevenlabs/voices.ts create mode 100644 server/src/endpoints/service-proxies/openai/basic.ts create mode 100644 server/src/endpoints/service-proxies/openai/index.ts create mode 100644 server/src/endpoints/service-proxies/openai/streaming.ts create mode 100644 server/src/endpoints/sync-legacy.ts delete mode 100644 server/src/endpoints/title.ts delete mode 100644 server/src/endpoints/whisper.ts diff --git a/app/package.json b/app/package.json index f0714ee6..3392c79b 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "chat-with-gpt", - "version": "0.2.2", + "version": "0.2.3", "dependencies": { "@auth0/auth0-spa-js": "^2.0.4", "@chengsokdara/use-whisper": "^0.2.0", @@ -12,6 +12,7 @@ "@mantine/modals": "^5.10.5", "@mantine/notifications": "^5.10.5", "@mantine/spotlight": "^5.10.5", + "@msgpack/msgpack": "^3.0.0-beta2", "@reduxjs/toolkit": "^1.9.3", "@svgr/webpack": "^6.5.1", "broadcast-channel": "^4.20.2", @@ -20,9 +21,9 @@ "expiry-set": "^1.0.0", "idb-keyval": "^6.2.0", "jshashes": "^1.0.8", + "lib0": "^0.2.73", "localforage": "^1.10.0", "match-sorter": "^6.3.1", - "mic-recorder-to-mp3": "^2.2.2", "minisearch": "^6.0.1", "natural": "^6.2.0", "openai": "^3.2.1", @@ -42,15 +43,20 @@ "sentence-splitter": "^4.2.0", "slugify": "^1.6.5", "sort-by": "^0.0.2", + "url": "^0.11.0", "uuid": "^9.0.0", - "web-vitals": "^2.1.4" + "web-vitals": "^2.1.4", + "workerize-loader": "^2.0.2", + "y-indexeddb": "^9.0.9", + "y-protocols": "^1.0.5", + "yjs": "^13.5.50" }, "overrides": { "@svgr/webpack": "$@svgr/webpack" }, "scripts": { "start": "craco start", - "build": "GENERATE_SOURCEMAP=false craco build", + "build": "craco build", "test": "craco test", "eject": "craco eject", "extract": "formatjs extract 'src/**/*.ts*' --ignore='**/*.d.ts' --out-file public/lang/en-us.json --id-interpolation-pattern '[sha512:contenthash:base64:6]'" diff --git a/app/public/index.html b/app/public/index.html index 2765ee32..bcbbbba9 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -46,6 +46,7 @@ height: 90vh; } + diff --git a/app/public/lang/en-us.json b/app/public/lang/en-us.json index e738c7f1..3596ef85 100644 --- a/app/public/lang/en-us.json +++ b/app/public/lang/en-us.json @@ -2,6 +2,9 @@ "+G35mR": { "defaultMessage": "Open sidebar" }, + "+H2Qtw": { + "defaultMessage": "Show quick settings below message input" + }, "+LMWDJ": { "defaultMessage": "Chat History", "description": "Heading for the chat history screen" @@ -27,9 +30,6 @@ "3T9nRn": { "defaultMessage": "Your API key is stored only on this device and never transmitted to anyone except OpenAI." }, - "4I+enA": { - "defaultMessage": "GPT 4 (requires invite)" - }, "5sg7KC": { "defaultMessage": "Password" }, @@ -56,8 +56,8 @@ "defaultMessage": "Save changes", "description": "Label for a button that appears when the user is editing the text of one of their messages, to save the changes" }, - "CJwO9s": { - "defaultMessage": "GPT 3.5 Turbo (default)" + "E4+fv5": { + "defaultMessage": "Auto-scroll to the bottom of the page when opening a chat" }, "FEzBCd": { "defaultMessage": "Untitled", @@ -70,16 +70,15 @@ "HyS0qp": { "defaultMessage": "Close sidebar" }, - "JpZMMj": { - "defaultMessage": "Voice", - "description": "Heading for the setting that lets users choose an ElevenLabs text-to-speech voice, on the settings screen" - }, "KKa5Br": { "defaultMessage": "Give ChatGPT a realisic human voice by connecting your ElevenLabs account (preview the available voices below). Click here to sign up." }, "L5s+z7": { "defaultMessage": "OpenAI API key usage is billed at a pay-as-you-go rate, separate from your ChatGPT subscription." }, + "LHOuNA": { + "defaultMessage": "Auto-scroll while generating a response" + }, "MI5gZ+": { "defaultMessage": "Download SVG" }, @@ -110,6 +109,9 @@ "defaultMessage": "Sign in to sync", "description": "Label for sign in button, indicating the purpose of signing in is to sync your data between devices" }, + "T8gKkC": { + "defaultMessage": "Delete this chat" + }, "Tgo3vj": { "defaultMessage": "Edit", "description": "Label for the button the user can click to edit the text of one of their messages" @@ -117,10 +119,6 @@ "VL24Xt": { "defaultMessage": "Search your chats" }, - "WOuVxP": { - "defaultMessage": "Model", - "description": "Heading for the setting that lets users choose a model to interact with, on the settings screen" - }, "Xzm66E": { "defaultMessage": "Connect your OpenAI account to get started" }, @@ -128,26 +126,28 @@ "defaultMessage": "Or sign in to an existing account", "description": "Label for a button on the Create Account page that lets the user sign into their existing account instead" }, + "aR9WsJ": { + "defaultMessage": "UI Settings", + "description": "Heading for the setting that lets users customize various UI elements" + }, "bIacvz": { "defaultMessage": "Chat with GPT - Unofficial ChatGPT app", "description": "HTML title tag" }, - "cAtzqn": { - "defaultMessage": "System Prompt", - "description": "Heading for the setting that lets users customize the System Prompt, on the settings screen" - }, - "cmcjSh": { - "defaultMessage": "Preview voice", - "description": "Label for the button that plays a preview of the selected ElevenLabs text-to-speech voice" - }, "f/hGIY": { "defaultMessage": "Hello, how can I help you today?", "description": "A friendly message that appears at the start of new chat sessions" }, + "gNu/AE": { + "defaultMessage": "Show microphone button in message input" + }, "gzJlXS": { "defaultMessage": "Share", "description": "Label for a button which shares the text of a chat message using the user device's share functionality" }, + "h9+jXQ": { + "defaultMessage": "The System Prompt is an invisible message inserted at the start of the chat and can be used to give ChatGPT information about itself and general guidelines for how it should respond. The '{{ datetime }}' tag is automatically replaced by the current date and time (use this to give the AI access to the time)." + }, "hJZwTS": { "defaultMessage": "Email address" }, @@ -186,16 +186,9 @@ "defaultMessage": "User", "description": "Label that is shown above messages written by the user (as opposed to the AI) for publicly shared conversation (third person, formal)." }, - "sPtnbA": { - "defaultMessage": "The System Prompt is shown to ChatGPT by the "System" before your first message. The '{{ datetime }}' tag is automatically replaced by the current date and time." - }, "ss6kle": { "defaultMessage": "Reset to default" }, - "sskUPZ": { - "defaultMessage": "Your ElevenLabs Text-to-Speech API Key (optional)", - "description": "Heading for the ElevenLabs API key setting on the settings screen" - }, "tZdXp/": { "defaultMessage": "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." }, @@ -207,6 +200,12 @@ "defaultMessage": "Customize system prompt", "description": "Label for the button that opens a modal for customizing the 'system prompt', a message used to customize and influence how the AI responds." }, + "xXbJso": { + "defaultMessage": "Sign out" + }, + "xqpqZE": { + "defaultMessage": "Your Elevenlabs API Key" + }, "y1F8Hs": { "defaultMessage": "Your OpenAI API Key", "description": "Heading for the OpenAI API key setting on the settings screen" diff --git a/app/src/backend.ts b/app/src/backend.ts deleted file mode 100644 index 9903aa2c..00000000 --- a/app/src/backend.ts +++ /dev/null @@ -1,173 +0,0 @@ -import EventEmitter from 'events'; -import chatManager from './chat-manager'; -import { MessageTree } from './message-tree'; -import { Chat, Message } from './types'; -import { AsyncLoop } from './utils'; - -const endpoint = '/chatapi'; - -export let backend: { - current?: Backend | null -} = {}; - -export interface User { - email?: string; - name?: string; - avatar?: string; -} - -export class Backend extends EventEmitter { - public user: User | null = null; - - private sessionInterval = new AsyncLoop(() => this.getSession(), 1000 * 30); - private syncInterval = new AsyncLoop(() => this.sync(), 1000 * 60 * 2); - - public constructor() { - super(); - - backend.current = this; - - this.sessionInterval.start(); - this.syncInterval.start(); - - chatManager.on('messages', async (messages: Message[]) => { - if (!this.isAuthenticated) { - return; - } - await this.post(endpoint + '/messages', { messages }); - }); - - chatManager.on('title', async (id: string, title: string) => { - if (!this.isAuthenticated) { - return; - } - if (!title?.trim()) { - return; - } - await this.post(endpoint + '/title', { id, title }); - }); - } - - public async getSession() { - const wasAuthenticated = this.isAuthenticated; - const session = await this.get(endpoint + '/session'); - if (session?.authenticated) { - this.user = { - email: session.email, - name: session.name, - avatar: session.picture, - }; - } else { - this.user = null; - } - if (wasAuthenticated !== this.isAuthenticated) { - this.emit('authenticated', this.isAuthenticated); - } - } - - public async sync() { - if (!this.isAuthenticated) { - return; - } - - const response = await this.post(endpoint + '/sync', {}); - - for (const chatID of Object.keys(response)) { - try { - const chat = chatManager.chats.get(chatID) || { - id: chatID, - messages: new MessageTree(), - } as Chat; - - if (response[chatID].deleted) { - chatManager.deleteChat(chatID); - continue; - } - - chat.title = response[chatID].title || chat.title; - chat.messages.addMessages(response[chatID].messages); - chatManager.loadChat(chat); - } catch (e) { - console.error('error loading chat', e); - } - } - - chatManager.emit('update'); - } - - async signIn() { - window.location.href = endpoint + '/login'; - } - - get isAuthenticated() { - return this.user !== null; - } - - async logout() { - window.location.href = endpoint + '/logout'; - } - - async shareChat(chat: Chat): Promise { - try { - const { id } = await this.post(endpoint + '/share', { - ...chat, - messages: chat.messages.serialize(), - }); - if (typeof id === 'string') { - return id; - } - } catch (e) { - console.error(e); - } - return null; - } - - async getSharedChat(id: string): Promise { - const format = process.env.REACT_APP_SHARE_URL || (endpoint + '/share/:id'); - const url = format.replace(':id', id); - try { - const chat = await this.get(url); - if (chat?.messages?.length) { - chat.messages = new MessageTree(chat.messages); - return chat; - } - } catch (e) { - console.error(e); - } - return null; - } - - async deleteChat(id: string) { - if (!this.isAuthenticated) { - return; - } - - return this.post(endpoint + '/delete', { id }); - } - - async get(url: string) { - const response = await fetch(url); - if (!response.ok) { - throw new Error(response.statusText); - } - return response.json(); - } - - async post(url: string, data: any) { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), - }); - if (!response.ok) { - throw new Error(response.statusText); - } - return response.json(); - } -} - -if (process.env.REACT_APP_AUTH_PROVIDER) { - new Backend(); -} \ No newline at end of file diff --git a/app/src/chat-manager.ts b/app/src/chat-manager.ts deleted file mode 100644 index 6a8eaa56..00000000 --- a/app/src/chat-manager.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { BroadcastChannel } from 'broadcast-channel'; -import EventEmitter from 'events'; -import MiniSearch, { SearchResult } from 'minisearch' -import { v4 as uuidv4 } from 'uuid'; -import { Chat, deserializeChat, getOpenAIMessageFromMessage, Message, Parameters, serializeChat, 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; - private activeReplies = new Map(); - - constructor() { - super(); - this.load(); - - this.on('update', () => { - this.changed = true; - }); - - channel.onmessage = (message: { - type: 'chat-update' | 'chat-delete', - data: string, - }) => { - switch (message.type) { - case 'chat-update': - const chat = deserializeChat(message.data); - const id = chat.id; - this.chats.set(id, chat); - this.emit(id); - break; - case 'chat-delete': - this.deleteChat(message.data, false); - break; - } - }; - - (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: serializeChat(chat) }); - - return id; - } - - public async sendMessage(message: UserSubmittedMessage) { - const chat = this.chats.get(message.chatID); - - if (!chat || chat.deleted) { - 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, - }; - - chat.messages.addMessage(newMessage); - chat.updated = Date.now(); - - this.emit(chat.id); - this.emit('messages', [newMessage]); - channel.postMessage({ type: 'chat-update', data: serializeChat(chat) }); - - const messages: Message[] = message.parentID - ? chat.messages.getMessageChainTo(message.parentID) - : []; - messages.push(newMessage); - - await this.getReply(messages, message.requestedParameters); - } - - public async regenerate(message: Message, requestedParameters: Parameters) { - const chat = this.chats.get(message.chatID); - - if (!chat || chat.deleted) { - throw new Error('Chat not found'); - } - - const messages: Message[] = message.parentID - ? chat.messages.getMessageChainTo(message.parentID) - : []; - - await this.getReply(messages, requestedParameters); - } - - private async getReply(messages: Message[], requestedParameters: Parameters) { - const latestMessage = messages[messages.length - 1]; - const chat = this.chats.get(latestMessage.chatID); - - if (!chat || chat.deleted) { - throw new Error('Chat not found'); - } - - const reply: Message = { - id: uuidv4(), - parentID: latestMessage.id, - chatID: latestMessage.chatID, - timestamp: Date.now(), - role: 'assistant', - model: requestedParameters.model, - content: '', - done: false, - }; - this.activeReplies.set(reply.id, reply); - - chat.messages.addMessage(reply); - chat.updated = Date.now(); - - this.emit(chat.id); - channel.postMessage({ type: 'chat-update', data: serializeChat(chat) }); - - const messagesToSend = messages.map(getOpenAIMessageFromMessage) - - const { emitter, cancel } = await createStreamingChatCompletion(messagesToSend, requestedParameters); - - let lastChunkReceivedAt = Date.now(); - - const onError = (error?: string) => { - if (reply.done) { - return; - } - clearInterval(timer); - cancel(); - reply.content += `\n\nI'm sorry, I'm having trouble connecting to OpenAI (${error || 'no response from the API'}). Please make sure you've entered your OpenAI API key correctly and try again.`; - reply.content = reply.content.trim(); - reply.done = true; - this.activeReplies.delete(reply.id); - chat.messages.updateMessage(reply); - chat.updated = Date.now(); - this.emit(chat.id); - this.emit('messages', [reply]); - channel.postMessage({ type: 'chat-update', data: serializeChat(chat) }); - }; - - let timer = setInterval(() => { - const sinceLastChunk = Date.now() - lastChunkReceivedAt; - if (sinceLastChunk > 30000 && !reply.done) { - onError('no response from OpenAI in the last 30 seconds'); - } - }, 2000); - - emitter.on('error', (e: any) => { - if (!reply.content && !reply.done) { - lastChunkReceivedAt = Date.now(); - onError(e); - } - }); - - emitter.on('data', (data: string) => { - if (reply.done) { - return; - } - lastChunkReceivedAt = Date.now(); - reply.content = data; - chat.messages.updateMessage(reply); - this.emit(chat.id); - channel.postMessage({ type: 'chat-update', data: serializeChat(chat) }); - }); - - emitter.on('done', async () => { - if (reply.done) { - return; - } - clearInterval(timer); - lastChunkReceivedAt = Date.now(); - reply.done = true; - this.activeReplies.delete(reply.id); - 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: serializeChat(chat) }); - setTimeout(() => this.search.update(chat), 500); - - if (!chat.title) { - chat.title = await createTitle(chat, 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: serializeChat(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); - } - - public cancelReply(id: string) { - const reply = this.activeReplies.get(id); - if (reply) { - reply.done = true; - this.activeReplies.delete(reply.id); - - const chat = this.chats.get(reply.chatID); - const message = chat?.messages.get(id); - if (message) { - message.done = true; - this.emit(reply.chatID); - this.emit('messages', [reply]); - this.emit('update'); - channel.postMessage({ type: 'chat-update', data: serializeChat(chat!) }); - } - } else { - console.log('failed to find reply'); - } - } - - private async load() { - const serialized = await idb.get('chats'); - if (serialized) { - for (const chat of serialized) { - try { - if (chat.deleted) { - continue; - } - const messages = new MessageTree(); - for (const m of chat.messages) { - messages.addMessage(m); - } - chat.messages = messages; - this.loadChat(chat); - } catch (e) { - console.error(e); - } - } - this.emit('update'); - } - this.loaded = true; - } - - public loadChat(chat: Chat) { - if (!chat?.id) { - return; - } - - const existing = this.chats.get(chat.id); - - if (existing && existing.deleted) { - return; - } - - if (existing && existing.title && !chat.title) { - chat.title = existing.title; - } - - chat.created = chat.messages.first?.timestamp || 0; - chat.updated = chat.messages.mostRecentLeaf().timestamp; - - 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); - } - - public deleteChat(id: string, broadcast = true) { - this.chats.delete(id); - this.search.delete(id); - this.emit(id); - if (broadcast) { - channel.postMessage({ type: 'chat-delete', data: 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 delete(id: string) { - this.index.remove({ id }); - } - - 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; - let chat = this.chats.get(chatID); - if (!chat) { - continue; - } - - chat = { ...chat }; - - let description = chat.messages?.first?.content || ''; - description = ellipsize(description, 400); - - if (!chat.title) { - chat.title = ellipsize(description, 100); - } - - if (!chat.title || !description) { - continue; - } - - output.push({ - chatID, - title: chat.title, - description, - }); - } - return output; - } -} - -const chatManager = new ChatManager(); -export default chatManager; diff --git a/app/src/components/auth/modals.tsx b/app/src/components/auth-modals.tsx similarity index 96% rename from app/src/components/auth/modals.tsx rename to app/src/components/auth-modals.tsx index a93f4aee..d81af716 100644 --- a/app/src/components/auth/modals.tsx +++ b/app/src/components/auth-modals.tsx @@ -1,9 +1,9 @@ import styled from "@emotion/styled"; import { Button, Modal, PasswordInput, TextInput } from "@mantine/core"; -import { useCallback, useState } from "react"; +import { useCallback } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { useAppDispatch, useAppSelector } from "../../store"; -import { closeModals, openLoginModal, openSignupModal, selectModal } from "../../store/ui"; +import { useAppDispatch, useAppSelector } from "../store"; +import { closeModals, openLoginModal, openSignupModal, selectModal } from "../store/ui"; const Container = styled.form` * { diff --git a/app/src/components/header.tsx b/app/src/components/header.tsx index cacde2ea..27d7a785 100644 --- a/app/src/components/header.tsx +++ b/app/src/components/header.tsx @@ -5,14 +5,25 @@ import { useSpotlight } from '@mantine/spotlight'; import { Burger, Button, ButtonProps } from '@mantine/core'; import { useCallback, useMemo, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import { useAppContext } from '../context'; -import { backend } from '../backend'; +import { useAppContext } from '../core/context'; +import { backend } from '../core/backend'; import { MenuItem, secondaryMenu } from '../menus'; import { useAppDispatch, useAppSelector } from '../store'; -import { selectOpenAIApiKey } from '../store/api-keys'; import { setTab } from '../store/settings-ui'; import { selectSidebarOpen, toggleSidebar } from '../store/sidebar'; -import { openSignupModal } from '../store/ui'; +import { openLoginModal, openSignupModal } from '../store/ui'; +import { useOption } from '../core/options/use-option'; +import { useHotkeys } from '@mantine/hooks'; + +const Banner = styled.div` + background: rgba(224, 49, 49, 0.2); + color: white; + text-align: center; + font-family: "Work Sans", sans-serif; + font-size: 80%; + padding: 0.5rem; + cursor: pointer; +`; const HeaderContainer = styled.div` display: flex; @@ -61,6 +72,7 @@ const HeaderContainer = styled.div` h2 { margin: 0 0.5rem; font-size: 1rem; + white-space: nowrap; } .spacer { @@ -134,7 +146,7 @@ export default function Header(props: HeaderProps) { const navigate = useNavigate(); const spotlight = useSpotlight(); const [loading, setLoading] = useState(false); - const openAIApiKey = useAppSelector(selectOpenAIApiKey); + const [openAIApiKey] = useOption('openai', 'apiKey'); const dispatch = useAppDispatch(); const intl = useIntl(); @@ -149,13 +161,37 @@ export default function Header(props: HeaderProps) { setLoading(true); navigate(`/`); setLoading(false); + setTimeout(() => document.querySelector('#message-input')?.focus(), 100); }, [navigate]); const openSettings = useCallback(() => { - dispatch(setTab(openAIApiKey ? 'options' : 'user')); + dispatch(setTab(openAIApiKey ? 'chat' : 'user')); }, [openAIApiKey, dispatch]); - const header = useMemo(() => ( + const signIn = useCallback(() => { + if ((window as any).AUTH_PROVIDER !== 'local') { + backend.current?.signIn(); + } else { + dispatch(openLoginModal()); + } + }, [dispatch]) + + const signUp = useCallback(() => { + if ((window as any).AUTH_PROVIDER !== 'local') { + backend.current?.signIn(); + } else { + dispatch(openSignupModal()); + } + }, [dispatch]) + + useHotkeys([ + ['c', onNewChat], + ]); + + const header = useMemo(() => (<> + {context.sessionExpired && + You have been signed out. Click here to sign back in. + } @@ -172,15 +208,9 @@ export default function Header(props: HeaderProps) { <FormattedMessage defaultMessage="Share" description="Label for the button used to create a public share URL for a chat log" /> </HeaderButton>} {backend.current && !context.authenticated && ( - <HeaderButton onClick={() => { - if (process.env.REACT_APP_AUTH_PROVIDER !== 'local') { - backend.current?.signIn(); - } else { - dispatch(openSignupModal()); - } - }}> + <HeaderButton onClick={localStorage.getItem('registered') ? signIn : signUp}> <FormattedMessage defaultMessage="Sign in <h>to sync</h>" - description="Label for sign in button, indicating the purpose of signing in is to sync your data between devices" + description="Label for sign in button, which indicates that the purpose of signing in is to sync your data between devices. Less important text inside <h> tags is hidden on small screens." values={{ h: (chunks: any) => <span className="hide-on-mobile">{chunks}</span> }} /> @@ -190,7 +220,8 @@ export default function Header(props: HeaderProps) { <FormattedMessage defaultMessage="New Chat" description="Label for the button used to start a new chat session" /> </HeaderButton> </HeaderContainer> - ), [sidebarOpen, onBurgerClick, props.title, props.share, props.canShare, props.onShare, openSettings, onNewChat, loading, context.authenticated, context.isHome, context.isShare, spotlight.openSpotlight]); + </>), [sidebarOpen, onBurgerClick, props.title, props.share, props.canShare, props.onShare, openSettings, onNewChat, + loading, context.authenticated, context.sessionExpired, context.isHome, context.isShare, spotlight.openSpotlight, signIn, signUp]); return header; } diff --git a/app/src/components/input.tsx b/app/src/components/input.tsx index 71988581..0e175994 100644 --- a/app/src/components/input.tsx +++ b/app/src/components/input.tsx @@ -1,17 +1,17 @@ import styled from '@emotion/styled'; -import { Button, ActionIcon, Textarea, Loader, Popover, Checkbox, Center, Group } from '@mantine/core'; -import { useLocalStorage, useMediaQuery } from '@mantine/hooks'; +import { Button, ActionIcon, Textarea, Loader, Popover } from '@mantine/core'; +import { getHotkeyHandler, useHotkeys, useMediaQuery } from '@mantine/hooks'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; -import { useLocation } from 'react-router-dom'; -import { useAppContext } from '../context'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useAppContext } from '../core/context'; import { useAppDispatch, useAppSelector } from '../store'; import { selectMessage, setMessage } from '../store/message'; -import { selectTemperature } from '../store/parameters'; -import { openOpenAIApiKeyPanel, openSystemPromptPanel, openTemperaturePanel } from '../store/settings-ui'; -import { speechRecognition, supportsSpeechRecognition } from '../speech-recognition-types' +import { selectSettingsTab, openOpenAIApiKeyPanel } from '../store/settings-ui'; +import { speechRecognition, supportsSpeechRecognition } from '../core/speech-recognition-types' import { useWhisper } from '@chengsokdara/use-whisper'; -import { selectUseOpenAIWhisper, selectOpenAIApiKey } from '../store/api-keys'; +import QuickSettings from './quick-settings'; +import { useOption } from '../core/options/use-option'; const Container = styled.div` background: #292933; @@ -24,19 +24,8 @@ const Container = styled.div` text-align: right; } - .inner > .bottom { - display: flex; - justify-content: space-between; - } - - @media (max-width: 600px) { - .inner > .bottom { - flex-direction: column; - align-items: flex-start; - } - } - .settings-button { + margin: 0.5rem -0.4rem 0.5rem 1rem; font-size: 0.7rem; color: #999; } @@ -49,14 +38,12 @@ export interface MessageInputProps { } export default function MessageInput(props: MessageInputProps) { - const temperature = useAppSelector(selectTemperature); const message = useAppSelector(selectMessage); const [recording, setRecording] = useState(false); const [speechError, setSpeechError] = useState<string | null>(null); const hasVerticalSpace = useMediaQuery('(min-height: 1000px)'); - const useOpenAIWhisper = useAppSelector(selectUseOpenAIWhisper); - const openAIApiKey = useAppSelector(selectOpenAIApiKey); - const [isEnterToSend, setIsEnterToSend] = useLocalStorage({ key: 'isEnterToSend', defaultValue: false}) + const [useOpenAIWhisper] = useOption<boolean>('speech-recognition', 'use-whisper'); + const [openAIApiKey] = useOption<string>('openai', 'apiKey'); const [initialMessage, setInitialMessage] = useState(''); const { @@ -69,12 +56,16 @@ export default function MessageInput(props: MessageInputProps) { streaming: false, }); + const navigate = useNavigate(); const context = useAppContext(); const dispatch = useAppDispatch(); const intl = useIntl(); - const onCustomizeSystemPromptClick = useCallback(() => dispatch(openSystemPromptPanel()), [dispatch]); - const onTemperatureClick = useCallback(() => dispatch(openTemperaturePanel()), [dispatch]); + const tab = useAppSelector(selectSettingsTab); + + const [showMicrophoneButton] = useOption<boolean>('speech-recognition', 'show-microphone'); + const [submitOnEnter] = useOption<boolean>('input', 'submit-on-enter'); + const onChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => { dispatch(setMessage(e.target.value)); }, [dispatch]); @@ -84,10 +75,15 @@ export default function MessageInput(props: MessageInputProps) { const onSubmit = useCallback(async () => { setSpeechError(null); - if (await context.onNewMessage(message)) { + const id = await context.onNewMessage(message); + + if (id) { + if (!window.location.pathname.includes(id)) { + navigate('/chat/' + id); + } dispatch(setMessage('')); } - }, [context, message, dispatch]); + }, [context, message, dispatch, navigate]); const onSpeechError = useCallback((e: any) => { console.error('speech recognition error', e); @@ -118,7 +114,7 @@ export default function MessageInput(props: MessageInputProps) { } else if (result.state == 'denied') { denied = true; } - } catch (e) {} + } catch (e) { } if (!granted && !denied) { try { @@ -191,12 +187,13 @@ export default function MessageInput(props: MessageInputProps) { } }, [initialMessage, transcript, recording, transcribing, useOpenAIWhisper, dispatch]); - const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => { - if(e.key === 'Enter' && e.shiftKey === false && !props.disabled) { - e.preventDefault(); - onSubmit(); - } - }, [isEnterToSend, onSubmit, props.disabled]); + useHotkeys([ + ['n', () => document.querySelector<HTMLTextAreaElement>('#message-input')?.focus()] + ]); + + const blur = useCallback(() => { + document.querySelector<HTMLTextAreaElement>('#message-input')?.blur(); + }, []); const rightSection = useMemo(() => { return ( @@ -210,7 +207,7 @@ export default function MessageInput(props: MessageInputProps) { }}> {context.generating && (<> <Button variant="subtle" size="xs" compact onClick={() => { - context.chat.cancelReply(context.currentChat.leaf!.id); + context.chat.cancelReply(context.currentChat.chat?.id, context.currentChat.leaf!.id); }}> <FormattedMessage defaultMessage={"Cancel"} description="Label for the button that can be clicked while the AI is generating a response to cancel generation" /> </Button> @@ -218,7 +215,7 @@ export default function MessageInput(props: MessageInputProps) { </>)} {!context.generating && ( <> - <Popover width={200} position="bottom" withArrow shadow="md" opened={speechError !== null}> + {showMicrophoneButton && <Popover width={200} position="bottom" withArrow shadow="md" opened={speechError !== null}> <Popover.Target> <ActionIcon size="xl" onClick={onSpeechStart}> @@ -245,7 +242,7 @@ export default function MessageInput(props: MessageInputProps) { </Button> </div> </Popover.Dropdown> - </Popover> + </Popover>} <ActionIcon size="xl" onClick={onSubmit}> <i className="fa fa-paper-plane" style={{ fontSize: '90%' }} /> @@ -254,7 +251,7 @@ export default function MessageInput(props: MessageInputProps) { )} </div> ); - }, [recording, transcribing, onSubmit, onSpeechStart, props.disabled, context.generating, speechError, onHideSpeechError]); + }, [recording, transcribing, onSubmit, onSpeechStart, props.disabled, context.generating, speechError, onHideSpeechError, showMicrophoneButton]); const disabled = context.generating; @@ -263,9 +260,21 @@ export default function MessageInput(props: MessageInputProps) { return null; } + const hotkeyHandler = useMemo(() => { + const keys = [ + ['Escape', blur, { preventDefault: true }], + ]; + if (submitOnEnter) { + keys.unshift(['Enter', onSubmit, { preventDefault: true }]); + } + const handler = getHotkeyHandler(keys as any); + return handler; + }, [onSubmit, blur, submitOnEnter]); + return <Container> <div className="inner"> <Textarea disabled={props.disabled || disabled} + id="message-input" autosize minRows={(hasVerticalSpace || context.isHome) ? 3 : 2} maxRows={12} @@ -274,31 +283,8 @@ export default function MessageInput(props: MessageInputProps) { onChange={onChange} rightSection={rightSection} rightSectionWidth={context.generating ? 100 : 55} - onKeyDown={onKeyDown} /> - <div className="bottom"> - <Group my="sm" spacing="xs"> - <Button variant="subtle" - className="settings-button" - size="xs" - compact - onClick={onCustomizeSystemPromptClick}> - <span> - <FormattedMessage defaultMessage={"Customize system prompt"} description="Label for the button that opens a modal for customizing the 'system prompt', a message used to customize and influence how the AI responds." /> - </span> - </Button> - <Button variant="subtle" - className="settings-button" - size="xs" - compact - onClick={onTemperatureClick}> - <span> - <FormattedMessage defaultMessage="Temperature: {temperature, number, ::.0}" - description="Label for the button that opens a modal for setting the 'temperature' (randomness) of AI responses" - values={{ temperature }} /> - </span> - </Button> - </Group> - </div> + onKeyDown={hotkeyHandler} /> + <QuickSettings key={tab} /> </div> </Container>; } diff --git a/app/src/components/markdown.tsx b/app/src/components/markdown.tsx index d008e6f9..74ed7b03 100644 --- a/app/src/components/markdown.tsx +++ b/app/src/components/markdown.tsx @@ -75,7 +75,7 @@ export function Markdown(props: MarkdownProps) { rehypePlugins={[rehypeKatex]} components={{ ol({ start, children }) { - return <ol start={start ?? 1} style={{ counterReset: `list-item ${(start || 1) - 1}` }}> + return <ol start={start ?? 1} style={{ counterReset: `list-item ${(start || 1)}` }}> {children} </ol>; }, diff --git a/app/src/components/message.tsx b/app/src/components/message.tsx index de14c6f0..46507130 100644 --- a/app/src/components/message.tsx +++ b/app/src/components/message.tsx @@ -1,13 +1,15 @@ import styled from '@emotion/styled'; import { Button, CopyButton, Loader, Textarea } from '@mantine/core'; -import { Message } from "../types"; -import { share } from '../utils'; -import { ElevenLabsReaderButton } from '../tts/elevenlabs'; +import { Message } from "../core/chat/types"; +import { share } from '../core/utils'; +import { TTSButton } from './tts-button'; import { Markdown } from './markdown'; -import { useAppContext } from '../context'; +import { useAppContext } from '../core/context'; import { useCallback, useMemo, useState } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; +import { useAppSelector } from '../store'; +import { selectSettingsTab } from '../store/settings-ui'; // hide for everyone but screen readers const SROnly = styled.span` @@ -138,6 +140,10 @@ const Container = styled.div` .fa + span { margin-left: 0.2em; + + @media (max-width: 40em) { + display: none; + } } .mantine-Button-root { @@ -204,6 +210,8 @@ export default function MessageComponent(props: { message: Message, last: boolea const [content, setContent] = useState(''); const intl = useIntl(); + const tab = useAppSelector(selectSettingsTab); + const getRoleName = useCallback((role: string, share = false) => { switch (role) { case 'user': @@ -237,14 +245,17 @@ export default function MessageComponent(props: { message: Message, last: boolea </strong> {props.message.role === 'assistant' && props.last && !props.message.done && <InlineLoader />} </span> - {props.message.done && <ElevenLabsReaderButton selector={'.content-' + props.message.id} />} + <TTSButton id={props.message.id} + selector={'.content-' + props.message.id} + complete={!!props.message.done} + autoplay={props.last && context.chat.lastReplyID === 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" /> - {copied ? <FormattedMessage defaultMessage="Copied" description="Label for copy-to-clipboard button after a successful copy" /> - : <FormattedMessage defaultMessage="Copy" description="Label for copy-to-clipboard button" />} + {copied ? <FormattedMessage defaultMessage="Copied" description="Label for copy-to-clipboard button after a successful copy" /> + : <span><FormattedMessage defaultMessage="Copy" description="Label for copy-to-clipboard button" /></span>} </Button> )} </CopyButton> @@ -293,7 +304,7 @@ export default function MessageComponent(props: { message: Message, last: boolea {props.last && <EndOfChatMarker />} </Container> ) - }, [props.last, props.share, editing, content, context, props.message, props.message.content]); + }, [props.last, props.share, editing, content, context, props.message, props.message.content, tab]); return elem; } \ No newline at end of file diff --git a/app/src/components/page.tsx b/app/src/components/page.tsx index c0ec103f..a8844e5e 100644 --- a/app/src/components/page.tsx +++ b/app/src/components/page.tsx @@ -1,11 +1,12 @@ import styled from '@emotion/styled'; import { SpotlightProvider } from '@mantine/spotlight'; import { useChatSpotlightProps } from '../spotlight'; -import { LoginModal, CreateAccountModal } from './auth/modals'; +import { LoginModal, CreateAccountModal } from './auth-modals'; import Header, { HeaderProps, SubHeader } from './header'; import MessageInput from './input'; import SettingsDrawer from './settings'; import Sidebar from './sidebar'; +import AudioControls from './tts-controls'; const Container = styled.div` position: absolute; @@ -13,12 +14,13 @@ const Container = styled.div` left: 0; right: 0; bottom: 0; - background: #292933; - color: white; display: flex; flex-direction: row; overflow: hidden; + background: #292933; + color: white; + .sidebar { width: 0%; height: 100%; @@ -55,10 +57,14 @@ const Container = styled.div` `; const Main = styled.div` + flex-grow: 1; display: flex; flex-direction: column; - flex-grow: 1; - overflow: hidden; + overflow: scroll; + + @media (min-height: 30em) { + overflow: hidden; + } `; export function Page(props: { @@ -79,6 +85,7 @@ export function Page(props: { onShare={props.headerProps?.onShare} /> {props.showSubHeader && <SubHeader />} {props.children} + <AudioControls /> <MessageInput key={localStorage.getItem('openai-api-key')} /> <SettingsDrawer /> <LoginModal /> @@ -86,5 +93,4 @@ export function Page(props: { </Main> </Container> </SpotlightProvider>; -} - +} \ No newline at end of file diff --git a/app/src/components/pages/chat.tsx b/app/src/components/pages/chat.tsx index 6ae69f05..64001a25 100644 --- a/app/src/components/pages/chat.tsx +++ b/app/src/components/pages/chat.tsx @@ -1,20 +1,23 @@ -import React, { Suspense } from 'react'; +import React, { Suspense, useCallback } from 'react'; import styled from '@emotion/styled'; import slugify from 'slugify'; import { useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { Loader } from '@mantine/core'; -import { useAppContext } from '../../context'; -import { backend } from '../../backend'; +import { useAppContext } from '../../core/context'; +import { backend } from '../../core/backend'; import { Page } from '../page'; +import { useOption } from '../../core/options/use-option'; const Message = React.lazy(() => import(/* webpackPreload: true */ '../message')); const Messages = styled.div` - max-height: 100%; - flex-grow: 1; - overflow-y: scroll; + @media (min-height: 30em) { + max-height: 100%; + flex-grow: 1; + overflow-y: scroll; + } display: flex; flex-direction: column; `; @@ -29,20 +32,22 @@ const EmptyMessage = styled.div` font-family: "Work Sans", sans-serif; line-height: 1.7; gap: 1rem; + min-height: 10rem; `; export default function ChatPage(props: any) { const { id } = useParams(); const context = useAppContext(); - let firstLoad = true; + const [autoScrollWhenOpeningChat] = useOption('auto-scroll', 'auto-scroll-when-opening-chat') + const [autoScrollWhileGenerating] = useOption('auto-scroll', 'auto-scroll-while-generating'); + useEffect(() => { if (props.share || !context.currentChat.chatLoadedAt) { return; } - const shouldScroll = (Date.now() - context.currentChat.chatLoadedAt) > 5000 || firstLoad; - firstLoad = false; + const shouldScroll = autoScrollWhenOpeningChat || (Date.now() - context.currentChat.chatLoadedAt) > 5000; if (!shouldScroll) { return; @@ -56,9 +61,23 @@ export default function ChatPage(props: any) { const offset = Math.max(0, latest.offsetTop - 100); setTimeout(() => { container?.scrollTo({ top: offset, behavior: 'smooth' }); - }, 500); + }, 100); } - }, [context.currentChat?.chatLoadedAt, context.currentChat?.messagesToDisplay.length, props.share]); + }, [context.currentChat?.chatLoadedAt, context.currentChat?.messagesToDisplay.length, props.share, autoScrollWhenOpeningChat]); + + const autoScroll = useCallback(() => { + if (context.generating && autoScrollWhileGenerating) { + const container = document.querySelector('#messages') as HTMLElement; + container?.scrollTo({ top: 999999, behavior: 'smooth' }); + container?.parentElement?.scrollTo({ top: 999999, behavior: 'smooth' }); + } + }, [context.generating, autoScrollWhileGenerating]); + useEffect(() => { + const timer = setInterval(() => autoScroll(), 1000); + return () => { + clearInterval(timer); + }; + }, [autoScroll]); const messagesToDisplay = context.currentChat.messagesToDisplay; @@ -94,7 +113,7 @@ export default function ChatPage(props: any) { {shouldShowChat && ( <div style={{ paddingBottom: '4.5rem' }}> {messagesToDisplay.map((message) => ( - <Message key={message.id} + <Message key={id + ":" + message.id} message={message} share={props.share} last={context.currentChat.chat!.messages.leafs.some(n => n.id === message.id)} /> diff --git a/app/src/components/pages/landing.tsx b/app/src/components/pages/landing.tsx index c119e821..fd49d04b 100644 --- a/app/src/components/pages/landing.tsx +++ b/app/src/components/pages/landing.tsx @@ -2,10 +2,11 @@ import styled from '@emotion/styled'; import { Button } from '@mantine/core'; import { useCallback } from 'react'; import { FormattedMessage } from 'react-intl'; -import { useAppDispatch, useAppSelector } from '../../store'; -import { selectOpenAIApiKey } from '../../store/api-keys'; +import { useAppDispatch } from '../../store'; import { openOpenAIApiKeyPanel } from '../../store/settings-ui'; import { Page } from '../page'; +import { useOption } from '../../core/options/use-option'; +import { isProxySupported } from '../../core/chat/openai'; const Container = styled.div` flex-grow: 1; @@ -20,7 +21,7 @@ const Container = styled.div` `; export default function LandingPage(props: any) { - const openAIApiKey = useAppSelector(selectOpenAIApiKey); + const [openAIApiKey] = useOption<string>('openai', 'apiKey'); const dispatch = useAppDispatch(); const onConnectButtonClick = useCallback(() => dispatch(openOpenAIApiKeyPanel()), [dispatch]); @@ -30,16 +31,11 @@ export default function LandingPage(props: any) { <FormattedMessage defaultMessage={'Hello, how can I help you today?'} description="A friendly message that appears at the start of new chat sessions" /> </p> - {!openAIApiKey && ( + {!openAIApiKey && !isProxySupported() && ( <Button size="xs" variant="light" compact onClick={onConnectButtonClick}> <FormattedMessage defaultMessage={'Connect your OpenAI account to get started'} /> </Button> )} - <p> - <Button size="xs" variant="light" component="a" href="https://www.chatwithgpt.ai" target="_blank"> - Try the new beta app<i style={{ marginLeft: '0.5rem' }} className="fa fa-arrow-up-right-from-square" /> - </Button> - </p> </Container> </Page>; } diff --git a/app/src/components/quick-settings.tsx b/app/src/components/quick-settings.tsx new file mode 100644 index 00000000..7060e34e --- /dev/null +++ b/app/src/components/quick-settings.tsx @@ -0,0 +1,66 @@ +import styled from '@emotion/styled'; +import { useAppContext } from '../core/context'; +import { Option } from '../core/options/option'; +import { useOption } from '../core/options/use-option'; +import { Button } from '@mantine/core'; +import { useAppDispatch, useAppSelector } from '../store'; +import { useCallback } from 'react'; +import { setTabAndOption } from '../store/settings-ui'; + +const Container = styled.div` + margin: 0.5rem -0.5rem; + + display: flex; + flex-wrap: wrap; + text-align: left; + + justify-content: center; + + @media (min-width: 40em) { + justify-content: flex-end; + } + + .mantine-Button-root { + font-size: 0.7rem; + color: #999; + } +`; + +export function QuickSettingsButton(props: { groupID: string, option: Option }) { + const context = useAppContext(); + const dispatch = useAppDispatch(); + + const [value] = useOption(props.groupID, props.option.id, context.id || undefined); + + const onClick = useCallback(() => { + dispatch(setTabAndOption({ tab: props.option.displayOnSettingsScreen, option: props.option.id })); + }, [props.groupID, props.option.id, dispatch]); + + const labelBuilder = props.option.displayInQuickSettings?.label; + let label = props.option.id; + + if (labelBuilder) { + label = typeof labelBuilder === 'string' ? labelBuilder : labelBuilder(value, context.chat.options, context); + } + + return ( + <Button variant="subtle" size="xs" compact onClick={onClick}> + <span> + {label} + </span> + </Button> + ) +} + +export default function QuickSettings(props: any) { + const context = useAppContext(); + const options = context.chat.getQuickSettings(); + + if (!options.length) { + return <div style={{ height: '1rem' }} />; + } + + return <Container> + {options.map(o => <QuickSettingsButton groupID={o.groupID} option={o.option} key={o.groupID + "." + o.option.id} />)} + </Container>; +} \ No newline at end of file diff --git a/app/src/components/settings/chat.tsx b/app/src/components/settings/chat.tsx new file mode 100644 index 00000000..43edfefa --- /dev/null +++ b/app/src/components/settings/chat.tsx @@ -0,0 +1,5 @@ +import SettingsTab from "./tab"; + +export default function ChatOptionsTab(props: any) { + return <SettingsTab name="chat" /> +} \ No newline at end of file diff --git a/app/src/components/settings/index.tsx b/app/src/components/settings/index.tsx index bda8c753..f9205119 100644 --- a/app/src/components/settings/index.tsx +++ b/app/src/components/settings/index.tsx @@ -1,13 +1,14 @@ import styled from '@emotion/styled'; import { Button, Drawer, Tabs } from "@mantine/core"; import { useMediaQuery } from '@mantine/hooks'; -import { useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; import UserOptionsTab from './user'; -import GenerationOptionsTab from './options'; +import ChatOptionsTab from './chat'; import { useAppDispatch, useAppSelector } from '../../store'; -import { closeSettingsUI, selectSettingsTab, setTab } from '../../store/settings-ui'; +import { closeSettingsUI, selectSettingsOption, selectSettingsTab, setTab } from '../../store/settings-ui'; import SpeechOptionsTab from './speech'; import { FormattedMessage } from 'react-intl'; +import UIPreferencesTab from './ui-preferences'; const Container = styled.div` padding: .4rem 1rem 1rem 1rem; @@ -76,12 +77,19 @@ export interface SettingsDrawerProps { export default function SettingsDrawer(props: SettingsDrawerProps) { const tab = useAppSelector(selectSettingsTab); + const option = useAppSelector(selectSettingsOption); const small = useMediaQuery('(max-width: 40em)'); const dispatch = useAppDispatch(); const close = useCallback(() => dispatch(closeSettingsUI()), [dispatch]); const onTabChange = useCallback((tab: string) => dispatch(setTab(tab)), [dispatch]); + useEffect(() => { + setTimeout(() => { + document.querySelector('.focused')?.scrollIntoView(); + }, 1000); + }, [tab, option]); + return ( <Drawer size="50rem" position='right' @@ -93,13 +101,15 @@ export default function SettingsDrawer(props: SettingsDrawerProps) { <Container> <Tabs value={tab} onTabChange={onTabChange} style={{ margin: '0rem' }}> <Tabs.List grow={small}> - <Tabs.Tab value="options">Options</Tabs.Tab> - <Tabs.Tab value="user">User</Tabs.Tab> + <Tabs.Tab value="chat">Chat</Tabs.Tab> <Tabs.Tab value="speech">Speech</Tabs.Tab> + <Tabs.Tab value="ui">UI</Tabs.Tab> + <Tabs.Tab value="user">User</Tabs.Tab> </Tabs.List> - <UserOptionsTab /> - <GenerationOptionsTab /> + <ChatOptionsTab /> <SpeechOptionsTab /> + <UIPreferencesTab /> + <UserOptionsTab /> </Tabs> <div id="save"> <Button variant="light" fullWidth size="md" onClick={close}> diff --git a/app/src/components/settings/option.tsx b/app/src/components/settings/option.tsx index dc0ab70e..0bf34add 100644 --- a/app/src/components/settings/option.tsx +++ b/app/src/components/settings/option.tsx @@ -1,12 +1,25 @@ export default function SettingsOption(props: { focused?: boolean; heading?: string; + description?: any; children?: any; span?: number; + collapsed?: boolean; }) { + if (!props.heading || props.collapsed) { + return props.children; + } + return ( <section className={props.focused ? 'focused' : ''}> {props.heading && <h3>{props.heading}</h3>} + {props.description && <div style={{ + fontSize: "90%", + opacity: 0.9, + marginTop: '-0.5rem', + }}> + {props.description} + </div>} {props.children} </section> ); diff --git a/app/src/components/settings/options.tsx b/app/src/components/settings/options.tsx deleted file mode 100644 index 8a56159d..00000000 --- a/app/src/components/settings/options.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import SettingsTab from "./tab"; -import SettingsOption from "./option"; -import { Button, Select, Slider, Textarea } from "@mantine/core"; -import { useCallback, useMemo } from "react"; -import { defaultSystemPrompt, defaultModel } from "../../openai"; -import { useAppDispatch, useAppSelector } from "../../store"; -import { resetModel, setModel, selectModel, resetSystemPrompt, selectSystemPrompt, selectTemperature, setSystemPrompt, setTemperature } from "../../store/parameters"; -import { selectSettingsOption } from "../../store/settings-ui"; -import { FormattedMessage, useIntl } from "react-intl"; - -export default function GenerationOptionsTab(props: any) { - const intl = useIntl(); - - const option = useAppSelector(selectSettingsOption); - const initialSystemPrompt = useAppSelector(selectSystemPrompt); - const model = useAppSelector(selectModel); - const temperature = useAppSelector(selectTemperature); - - const dispatch = useAppDispatch(); - const onSystemPromptChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => dispatch(setSystemPrompt(event.target.value)), [dispatch]); - const onModelChange = useCallback((value: string) => dispatch(setModel(value)), [dispatch]); - const onResetSystemPrompt = useCallback(() => dispatch(resetSystemPrompt()), [dispatch]); - const onResetModel = useCallback(() => dispatch(resetModel()), [dispatch]); - const onTemperatureChange = useCallback((value: number) => dispatch(setTemperature(value)), [dispatch]); - - const resettableSystemPromopt = initialSystemPrompt - && (initialSystemPrompt?.trim() !== defaultSystemPrompt.trim()); - - const resettableModel = model - && (model?.trim() !== defaultModel.trim()); - - const systemPromptOption = useMemo(() => ( - <SettingsOption heading={intl.formatMessage({ defaultMessage: "System Prompt", description: "Heading for the setting that lets users customize the System Prompt, on the settings screen" })} - focused={option === 'system-prompt'}> - <Textarea - value={initialSystemPrompt || defaultSystemPrompt} - onChange={onSystemPromptChange} - minRows={5} - maxRows={10} - autosize /> - <p style={{ marginBottom: '0.7rem' }}> - <FormattedMessage defaultMessage="The System Prompt is shown to ChatGPT by the "System" before your first message. The <code>'{{ datetime }}'</code> tag is automatically replaced by the current date and time." - values={{ code: chunk => <code style={{ whiteSpace: 'nowrap' }}>{chunk}</code> }} /> - </p> - {resettableSystemPromopt && <Button size="xs" compact variant="light" onClick={onResetSystemPrompt}> - <FormattedMessage defaultMessage="Reset to default" /> - </Button>} - </SettingsOption> - ), [option, initialSystemPrompt, resettableSystemPromopt, onSystemPromptChange, onResetSystemPrompt]); - - const modelOption = useMemo(() => ( - <SettingsOption heading={intl.formatMessage({ defaultMessage: "Model", description: "Heading for the setting that lets users choose a model to interact with, on the settings screen" })} - focused={option === 'model'}> - <Select - value={model || defaultModel} - data={[ - { - label: intl.formatMessage({ defaultMessage: "GPT 3.5 Turbo (default)" }), - value: "gpt-3.5-turbo", - }, - { - label: intl.formatMessage({ defaultMessage: "GPT 4 (requires invite)" }), - value: "gpt-4", - }, - ]} - onChange={onModelChange} /> - {model === 'gpt-4' && ( - <p style={{ marginBottom: '0.7rem' }}> - <FormattedMessage defaultMessage="Note: GPT-4 will only work if your OpenAI account has been granted access to the new model. <a>Request access here.</a>" - values={{ a: chunk => <a href="https://openai.com/waitlist/gpt-4-api" target="_blank" rel="noreferer">{chunk}</a> }} /> - </p> - )} - {resettableModel && <Button size="xs" compact variant="light" onClick={onResetModel}> - <FormattedMessage defaultMessage="Reset to default" /> - </Button>} - </SettingsOption> - ), [option, model, resettableModel, onModelChange, onResetModel]); - - const temperatureOption = useMemo(() => ( - <SettingsOption heading={intl.formatMessage({ - defaultMessage: "Temperature: {temperature, number, ::.0}", - description: "Label for the button that opens a modal for setting the 'temperature' (randomness) of AI responses", - }, { temperature })} - focused={option === 'temperature'}> - <Slider value={temperature} onChange={onTemperatureChange} step={0.1} min={0} max={1} precision={3} /> - <p> - <FormattedMessage defaultMessage="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> - </SettingsOption> - ), [temperature, option, onTemperatureChange]); - - const elem = useMemo(() => ( - <SettingsTab name="options"> - {systemPromptOption} - {modelOption} - {temperatureOption} - </SettingsTab> - ), [systemPromptOption, modelOption, temperatureOption]); - - return elem; -} \ No newline at end of file diff --git a/app/src/components/settings/speech.tsx b/app/src/components/settings/speech.tsx index eec9d222..f325d3a6 100644 --- a/app/src/components/settings/speech.tsx +++ b/app/src/components/settings/speech.tsx @@ -1,81 +1,5 @@ import SettingsTab from "./tab"; -import SettingsOption from "./option"; -import { Button, Select, TextInput } from "@mantine/core"; -import { useAppDispatch, useAppSelector } from "../../store"; -import { selectElevenLabsApiKey, setElevenLabsApiKey } from "../../store/api-keys"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { selectVoice, setVoice } from "../../store/voices"; -import { getVoices } from "../../tts/elevenlabs"; -import { selectSettingsOption } from "../../store/settings-ui"; -import { defaultVoiceList } from "../../tts/defaults"; -import { FormattedMessage, useIntl } from "react-intl"; export default function SpeechOptionsTab() { - const intl = useIntl(); - - const option = useAppSelector(selectSettingsOption); - const elevenLabsApiKey = useAppSelector(selectElevenLabsApiKey); - const voice = useAppSelector(selectVoice); - - const dispatch = useAppDispatch(); - const onElevenLabsApiKeyChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => dispatch(setElevenLabsApiKey(event.target.value)), [dispatch]); - const onVoiceChange = useCallback((value: string) => dispatch(setVoice(value)), [dispatch]); - - const [voices, setVoices] = useState<any[]>(defaultVoiceList); - useEffect(() => { - if (elevenLabsApiKey) { - getVoices().then(data => { - if (data?.voices?.length) { - setVoices(data.voices); - } - }); - } - }, [elevenLabsApiKey]); - - const apiKeyOption = useMemo(() => ( - <SettingsOption heading={intl.formatMessage({ defaultMessage: 'Your ElevenLabs Text-to-Speech API Key (optional)', description: "Heading for the ElevenLabs API key setting on the settings screen" })} - focused={option === 'elevenlabs-api-key'}> - <TextInput placeholder={intl.formatMessage({ defaultMessage: "Paste your API key here" })} - value={elevenLabsApiKey || ''} onChange={onElevenLabsApiKeyChange} /> - <p> - <FormattedMessage defaultMessage="Give ChatGPT a realisic human voice by connecting your ElevenLabs account (preview the available voices below). <a>Click here to sign up.</a>" - values={{ - a: (chunks: any) => <a href="https://beta.elevenlabs.io" target="_blank" rel="noreferrer">{chunks}</a> - }} /> - </p> - <p> - <FormattedMessage defaultMessage="You can find your API key by clicking your avatar or initials in the top right of the ElevenLabs website, then clicking Profile. Your API key is stored only on this device and never transmitted to anyone except ElevenLabs." /> - </p> - </SettingsOption> - ), [option, elevenLabsApiKey, onElevenLabsApiKeyChange]); - - const voiceOption = useMemo(() => ( - <SettingsOption heading={intl.formatMessage({ defaultMessage: 'Voice', description: 'Heading for the setting that lets users choose an ElevenLabs text-to-speech voice, on the settings screen' })} - focused={option === 'elevenlabs-voice'}> - <Select - value={voice} - onChange={onVoiceChange} - data={[ - ...voices.map(v => ({ label: v.name, value: v.voice_id })), - ]} /> - <audio controls style={{ display: 'none' }} id="voice-preview" key={voice}> - <source src={voices.find(v => v.voice_id === voice)?.preview_url} type="audio/mpeg" /> - </audio> - <Button onClick={() => (document.getElementById('voice-preview') as HTMLMediaElement)?.play()} variant='light' compact style={{ marginTop: '1rem' }}> - <i className='fa fa-headphones' /> - <span> - <FormattedMessage defaultMessage="Preview voice" description="Label for the button that plays a preview of the selected ElevenLabs text-to-speech voice" /> - </span> - </Button> - </SettingsOption> - ), [option, voice, voices, onVoiceChange]); - - const elem = useMemo(() => ( - <SettingsTab name="speech"> - {apiKeyOption} - {voices.length > 0 && voiceOption} - </SettingsTab> - ), [apiKeyOption, voiceOption, voices.length]); - - return elem; + return <SettingsTab name="speech" /> } \ No newline at end of file diff --git a/app/src/components/settings/tab.tsx b/app/src/components/settings/tab.tsx index a8980302..95c3ccb5 100644 --- a/app/src/components/settings/tab.tsx +++ b/app/src/components/settings/tab.tsx @@ -1,5 +1,15 @@ import styled from "@emotion/styled"; -import { Tabs } from "@mantine/core"; +import { Button, NumberInput, PasswordInput, Select, Slider, Switch, Tabs, Text, TextInput, Textarea } from "@mantine/core"; +import { Option } from "../../core/options/option"; +import SettingsOption from "./option"; +import { selectSettingsOption } from "../../store/settings-ui"; +import { useAppSelector } from "../../store"; +import { FormattedMessage } from "react-intl"; +import { useOption } from "../../core/options/use-option"; +import { Context, useAppContext } from "../../core/context"; +import { pluginMetadata as pluginMetadata } from "../../core/plugins/metadata"; +import { globalOptions } from "../../global-options"; +import { useEffect } from "react"; const Settings = styled.div` font-family: "Work Sans", sans-serif; @@ -9,6 +19,11 @@ const Settings = styled.div` margin-bottom: .618rem; padding: 0.618rem; + section { + padding-left: 0; + padding-right: 0; + } + h3 { font-size: 1rem; font-weight: bold; @@ -29,6 +44,13 @@ const Settings = styled.div` code { font-family: "Fira Code", monospace; } + + .mantine-NumberInput-root, .slider-wrapper { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + } } .focused { @@ -50,14 +72,184 @@ const Settings = styled.div` } `; +const OptionWrapper = styled.div` + & { + margin-top: 1rem; + } + + * { + font-family: "Work Sans", sans-serif; + color: white; + font-size: 1rem; + } +`; + +export function PluginOptionWidget(props: { pluginID: string, option: Option, chatID?: string | null | undefined, context: Context }) { + const requestedOption = useAppSelector(selectSettingsOption); + + const option = props.option; + + const [_value, setValue, renderProps] = useOption(props.pluginID, option.id, props.chatID || undefined); + + const value = _value ?? option.defaultValue; + + if (option.defaultValue && (typeof value === 'undefined' || value === null)) { + console.warn(`expected option value for ${props.pluginID}.${option.id}, got:`, _value); + } + + if (renderProps.hidden) { + return null; + } + + let component: any; + + switch (renderProps.type) { + case "textarea": + component = ( + <Textarea label={!option.displayAsSeparateSection ? renderProps.label : null} + placeholder={renderProps.placeholder} + disabled={renderProps.disabled} + value={value || ''} + onChange={e => setValue(e.target.value)} + minRows={5} /> + ); + break; + case "select": + component = ( + <Select label={!option.displayAsSeparateSection ? renderProps.label : null} + placeholder={renderProps.placeholder} + disabled={renderProps.disabled} + value={value || ''} + onChange={value => setValue(value)} + data={renderProps.options ?? []} + /> + ); + break; + case "slider": + component = ( + <div className="slider-wrapper"> + {!option.displayAsSeparateSection && <Text size='sm' weight={500}>{renderProps.label}:</Text>} + <Slider label={value.toString()} + disabled={renderProps.disabled} + value={value} + onChange={v => setValue(v)} + min={renderProps.min} + max={renderProps.max} + step={renderProps.step} + style={{ + minWidth: '10rem', + flexGrow: 1, + }} /> + </div> + ); + break; + case "number": + component = ( + <NumberInput label={!option.displayAsSeparateSection ? (renderProps.label + ':') : null} + disabled={renderProps.disabled} + value={value ?? undefined} + onChange={v => setValue(v)} + min={renderProps.min} + max={renderProps.max} + step={renderProps.step} /> + ); + break; + case "checkbox": + component = ( + <Switch label={!option.displayAsSeparateSection ? renderProps.label : null} + disabled={renderProps.disabled} + checked={value} + onChange={e => setValue(e.target.checked)} /> + ); + break; + case "password": + component = ( + <PasswordInput label={!option.displayAsSeparateSection ? renderProps.label : null} + placeholder={renderProps.placeholder} + disabled={renderProps.disabled} + value={value} + onChange={e => setValue(e.target.value)} /> + ); + break; + case "text": + default: + component = ( + <TextInput label={!option.displayAsSeparateSection ? renderProps.label : null} + placeholder={renderProps.placeholder} + disabled={renderProps.disabled} + value={value} + onChange={e => setValue(e.target.value)} /> + ); + break; + } + + const focused = !!requestedOption && option.id === requestedOption; + + const elem = <OptionWrapper className={(focused && !option.displayAsSeparateSection) ? 'focused' : ''}> + {component} + {typeof renderProps.description?.props === 'undefined' && <p style={{ marginBottom: '0.7rem' }}>{renderProps.description}</p>} + {typeof renderProps.description?.props !== 'undefined' && renderProps.description} + </OptionWrapper>; + + if (option.displayAsSeparateSection) { + return <SettingsOption heading={renderProps.label} focused={focused}> + {elem} + {option.resettable && <div style={{ + display: 'flex', + gap: '1rem', + marginTop: '1rem', + }}> + <Button size="xs" compact variant="light" onClick={() => setValue(option.defaultValue)}> + <FormattedMessage defaultMessage="Reset to default" /> + </Button> + </div>} + </SettingsOption>; + } + + return elem; +} + export default function SettingsTab(props: { name: string; children?: any; }) { + const context = useAppContext(); + + const optionSets = [...globalOptions, ...pluginMetadata] + .map(metadata => ({ + id: metadata.id, + name: metadata.name, + description: metadata.description, + options: metadata.options.filter(o => o.displayOnSettingsScreen === props.name), + resettable: metadata.options.filter(o => o.displayOnSettingsScreen === props.name && o.resettable && !o.displayAsSeparateSection).length > 0, + collapsed: metadata.options.filter(o => o.displayOnSettingsScreen === props.name && o.displayAsSeparateSection).length > 0, + hidden: typeof metadata.hidden === 'function' ? metadata.hidden(context.chat.options) : metadata.hidden, + })) + .filter(({ options, hidden }) => options.length && !hidden); + return ( <Tabs.Panel value={props.name}> <Settings> {props.children} + {optionSets.map(({ name, id, description, options, resettable, collapsed }) => <> + <SettingsOption heading={name} description={description} collapsed={collapsed} key={id}> + {options.map(o => <PluginOptionWidget + pluginID={id} + option={o} + chatID={context.id} + context={context} + key={id + "." + o.id} />)} + {resettable && <div style={{ + display: 'flex', + gap: '1rem', + marginTop: '1rem', + }}> + <Button size="xs" compact variant="light" onClick={() => context.chat.resetPluginOptions(id, context.id)}> + <FormattedMessage defaultMessage="Reset to default" /> + </Button> + </div>} + </SettingsOption> + </>)} </Settings> </Tabs.Panel> ); diff --git a/app/src/components/settings/ui-preferences.tsx b/app/src/components/settings/ui-preferences.tsx new file mode 100644 index 00000000..7b56ce75 --- /dev/null +++ b/app/src/components/settings/ui-preferences.tsx @@ -0,0 +1,5 @@ +import SettingsTab from "./tab"; + +export default function UIPreferencesTab(props: any) { + return <SettingsTab name="ui" /> +} \ No newline at end of file diff --git a/app/src/components/settings/user.tsx b/app/src/components/settings/user.tsx index 7532f43e..69bdf13d 100644 --- a/app/src/components/settings/user.tsx +++ b/app/src/components/settings/user.tsx @@ -1,34 +1,23 @@ -import SettingsTab from "./tab"; +import { Button, FileButton } from "@mantine/core"; +import { importChat } from "../../core/chat/chat-persistance"; +import { Chat, serializeChat } from "../../core/chat/types"; +import { useAppContext } from "../../core/context"; import SettingsOption from "./option"; -import { Button, Checkbox, TextInput } from "@mantine/core"; -import { useCallback, useMemo } from "react"; -import { useAppDispatch, useAppSelector } from "../../store"; -import { selectOpenAIApiKey, setOpenAIApiKeyFromEvent, selectUseOpenAIWhisper, setUseOpenAIWhisperFromEvent } from "../../store/api-keys"; -import { selectSettingsOption } from "../../store/settings-ui"; -import { FormattedMessage, useIntl } from "react-intl"; -import { supportsSpeechRecognition } from "../../speech-recognition-types"; -import { useAppContext } from "../../context"; -import { serializeChat } from "../../types"; +import SettingsTab from "./tab"; +import { useState, useCallback } from "react"; export default function UserOptionsTab(props: any) { - const option = useAppSelector(selectSettingsOption); - const openaiApiKey = useAppSelector(selectOpenAIApiKey); - const useOpenAIWhisper = useAppSelector(selectUseOpenAIWhisper); - const intl = useIntl() - - const dispatch = useAppDispatch(); - const onOpenAIApiKeyChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => dispatch(setOpenAIApiKeyFromEvent(event)), [dispatch]); - const onUseOpenAIWhisperChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => dispatch(setUseOpenAIWhisperFromEvent(event)), [dispatch]); - const context = useAppContext(); + + const doc = context.chat.doc; const getData = useCallback(async () => { - const chats = Array.from(context.chat.chats.values()); - return chats.map(chat => ({ - ...chat, - messages: chat.messages.serialize(), - })); + const chats = context.chat.all() as Chat[]; + return chats.map(chat => serializeChat(chat)); }, [context.chat]); + const [importedChats, setImportedChats] = useState<number | null>(null); + const [errorMessage, setErrorMessage] = useState<string | null>(null); + const handleExport = useCallback(async () => { const data = await getData(); const json = JSON.stringify(data); @@ -40,42 +29,62 @@ export default function UserOptionsTab(props: any) { link.click(); }, [getData]); - const elem = useMemo(() => ( + const handleImport = useCallback( + async (file: File) => { + try { + const reader = new FileReader(); + reader.onload = (e) => { + const json = e.target?.result as string; + const data = JSON.parse(json) as Chat[]; + if (data.length > 0) { + context.chat.doc.transact(() => { + for (const chat of data) { + try { + importChat(doc, chat); + } catch (e) { + console.error(e); + } + } + }); + setImportedChats(data.length); + setErrorMessage(null); + } else { + setErrorMessage("The imported file does not contain any chat data."); + } + }; + reader.readAsText(file); + } catch (error) { + setErrorMessage("Failed to import chat data."); + } + }, + [doc] + ); + + const successMessage = importedChats ? ( + <div style={{ color: 'green' }}> + <i className="fa fa-check-circle"></i> + <span style={{ marginLeft: '0.5em' }}>Imported {importedChats} chat(s)</span> + </div> + ) : null; + + const errorMessageElement = errorMessage ? ( + <div style={{ color: 'red' }}>{errorMessage}</div> + ) : null; + + return ( <SettingsTab name="user"> - <SettingsOption heading="Export"> + <SettingsOption heading="Import and Export"> <div> <Button variant="light" onClick={handleExport} style={{ marginRight: '1rem', }}>Export</Button> + <FileButton onChange={handleImport} accept=".json"> + {(props) => <Button variant="light" {...props}>Import</Button>} + </FileButton> </div> - </SettingsOption> - <SettingsOption heading={intl.formatMessage({ defaultMessage: "Your OpenAI API Key", description: "Heading for the OpenAI API key setting on the settings screen" })} - focused={option === 'openai-api-key'}> - <TextInput - placeholder={intl.formatMessage({ defaultMessage: "Paste your API key here" })} - value={openaiApiKey || ''} - onChange={onOpenAIApiKeyChange} /> - <p> - <a href="https://platform.openai.com/account/api-keys" target="_blank" rel="noreferrer"> - <FormattedMessage defaultMessage="Find your API key here." description="Label for the link that takes the user to the page on the OpenAI website where they can find their API key." /> - </a> - </p> - - {supportsSpeechRecognition && <Checkbox - style={{ marginTop: '1rem' }} - id="use-openai-whisper-api" checked={useOpenAIWhisper!} onChange={onUseOpenAIWhisperChange} - label="Use the OpenAI Whisper API for speech recognition." - />} - - <p> - <FormattedMessage defaultMessage="Your API key is stored only on this device and never transmitted to anyone except OpenAI." /> - </p> - <p> - <FormattedMessage defaultMessage="OpenAI API key usage is billed at a pay-as-you-go rate, separate from your ChatGPT subscription." /> - </p> + {successMessage} + {errorMessageElement} </SettingsOption> </SettingsTab> - ), [option, openaiApiKey, useOpenAIWhisper, onOpenAIApiKeyChange]); - - return elem; -} \ No newline at end of file + ); +} diff --git a/app/src/components/sidebar/index.tsx b/app/src/components/sidebar/index.tsx index 7ffcf293..99e9c903 100644 --- a/app/src/components/sidebar/index.tsx +++ b/app/src/components/sidebar/index.tsx @@ -1,10 +1,10 @@ import styled from '@emotion/styled'; import { ActionIcon, Avatar, Burger, Button, Menu } from '@mantine/core'; import { useElementSize } from '@mantine/hooks'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; -import { backend } from '../../backend'; -import { useAppContext } from '../../context'; +import { backend } from '../../core/backend'; +import { useAppContext } from '../../core/context'; import { useAppDispatch, useAppSelector } from '../../store'; import { setTab } from '../../store/settings-ui'; import { selectSidebarOpen, toggleSidebar } from '../../store/sidebar'; @@ -109,6 +109,18 @@ export default function Sidebar(props: { const onBurgerClick = useCallback(() => dispatch(toggleSidebar()), [dispatch]); const { ref, width } = useElementSize(); + const [version, setVersion] = useState(0); + const update = useCallback(() => { + setVersion(v => v + 1); + }, []); + + useEffect(() => { + context.chat.on('update', update); + return () => { + context.chat.off('update', update); + }; + }, []); + const burgerLabel = sidebarOpen ? intl.formatMessage({ defaultMessage: "Close sidebar" }) : intl.formatMessage({ defaultMessage: "Open sidebar" }); @@ -122,14 +134,14 @@ export default function Sidebar(props: { <div className="sidebar-content"> <RecentChats /> </div> - {backend.current && backend.current.isAuthenticated && ( + {context.authenticated && ( <Menu width={width - 20}> <Menu.Target> <div className="sidebar-footer"> - <Avatar size="lg" src={backend.current!.user!.avatar} /> + <Avatar size="lg" src={context.user!.avatar} /> <div className="user-info"> - <strong>{backend.current!.user!.name || backend.current!.user!.email}</strong> - {!!backend.current!.user!.name && <span>{backend.current.user!.email}</span>} + <strong>{context.user!.name || context.user!.email}</strong> + {!!context.user!.name && <span>{context.user!.email}</span>} </div> <div className="spacer" /> @@ -144,17 +156,17 @@ export default function Sidebar(props: { }} icon={<i className="fas fa-gear" />}> <FormattedMessage defaultMessage={"User settings"} description="Menu item that opens the user settings screen" /> </Menu.Item> - {/* + <Menu.Divider /> <Menu.Item color="red" onClick={() => backend.current?.logout()} icon={<i className="fas fa-sign-out-alt" />}> <FormattedMessage defaultMessage={"Sign out"} /> - </Menu.Item> - */} + </Menu.Item> + </Menu.Dropdown> </Menu> )} </Container> - ), [sidebarOpen, width, ref, burgerLabel, onBurgerClick, dispatch, context.chat.chats.size]); + ), [sidebarOpen, width, ref, burgerLabel, onBurgerClick, dispatch, version]); return elem; } \ No newline at end of file diff --git a/app/src/components/sidebar/recent-chats.tsx b/app/src/components/sidebar/recent-chats.tsx index 31274308..d1fa33d9 100644 --- a/app/src/components/sidebar/recent-chats.tsx +++ b/app/src/components/sidebar/recent-chats.tsx @@ -1,13 +1,13 @@ import styled from '@emotion/styled'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { FormattedMessage } from 'react-intl'; import { Link, useNavigate } from 'react-router-dom'; -import { useAppContext } from '../../context'; +import { useAppContext } from '../../core/context'; import { useAppDispatch } from '../../store'; import { toggleSidebar } from '../../store/sidebar'; -import { ActionIcon, Menu } from '@mantine/core'; +import { ActionIcon, Button, Loader, Menu, TextInput, Textarea } from '@mantine/core'; import { useModals } from '@mantine/modals'; -import { backend } from '../../backend'; +import { backend } from '../../core/backend'; const Container = styled.div` margin: calc(1.618rem - 1rem); @@ -56,9 +56,16 @@ const ChatListItemLink = styled(Link)` .mantine-ActionIcon-root { position: absolute; - right: 0.5rem; + right: 0.0rem; top: 50%; - margin-top: -14px; + margin-top: -22px; + opacity: 0; + } + + &:hover { + .mantine-ActionIcon-root { + opacity: 1; + } } `; @@ -68,7 +75,10 @@ function ChatListItem(props: { chat: any, onClick: any, selected: boolean }) { const modals = useModals(); const navigate = useNavigate(); - const onDelete = useCallback(() => { + const onDelete = useCallback((e?: React.MouseEvent) => { + e?.preventDefault(); + e?.stopPropagation(); + modals.openConfirmModal({ title: "Are you sure you want to delete this chat?", children: <p style={{ lineHeight: 1.7 }}>The chat "{c.title}" will be permanently deleted. This cannot be undone.</p>, @@ -93,33 +103,79 @@ function ChatListItem(props: { chat: any, onClick: any, selected: boolean }) { confirm: "Try again", cancel: "Cancel", }, - onConfirm: onDelete, + onConfirm: () => onDelete(), }); } }, }); }, [c.chatID, c.title]); + const onRename = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Display a modal with a TextInput + modals.openModal({ + title: "Rename chat", + children: <div> + <Textarea + id="chat-title" + defaultValue={c.title} + maxLength={500} + autosize + required /> + <Button + fullWidth + variant="light" + style={{ marginTop: '1rem' }} + onClick={() => { + const title = document.querySelector<HTMLInputElement>('#chat-title')?.value?.trim(); + const ychat = context.chat.doc.getYChat(c.chatID); + if (ychat && title && title !== ychat?.title) { + ychat.title = title; + } + modals.closeAll(); + }} + > + Save changes + </Button> + </div>, + }); + }, [c.chatID, c.title]); + + const [menuOpen, setMenuOpen] = useState(false); + + const toggleMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setMenuOpen(open => !open); + }, []); + return ( <ChatListItemLink to={'/chat/' + c.chatID} onClick={props.onClick} data-chat-id={c.chatID} className={props.selected ? 'selected' : ''}> <strong>{c.title || <FormattedMessage defaultMessage={"Untitled"} description="default title for untitled chat sessions" />}</strong> - {props.selected && ( - <Menu> - <Menu.Target> - <ActionIcon> - <i className="fas fa-ellipsis" /> - </ActionIcon> - </Menu.Target> - <Menu.Dropdown> - <Menu.Item onClick={onDelete} color="red" icon={<i className="fa fa-trash" />}> - <FormattedMessage defaultMessage={"Delete this chat"} /> - </Menu.Item> - </Menu.Dropdown> - </Menu> - )} + <Menu opened={menuOpen} + closeOnClickOutside={true} + closeOnEscape={true} + onClose={() => setMenuOpen(false)}> + <Menu.Target> + <ActionIcon size="xl" onClick={toggleMenu}> + <i className="fas fa-ellipsis" /> + </ActionIcon> + </Menu.Target> + <Menu.Dropdown> + <Menu.Item onClick={onRename} icon={<i className="fa fa-edit" />}> + <FormattedMessage defaultMessage={"Rename this chat"} /> + </Menu.Item> + <Menu.Divider /> + <Menu.Item onClick={onDelete} color="red" icon={<i className="fa fa-trash" />}> + <FormattedMessage defaultMessage={"Delete this chat"} /> + </Menu.Item> + </Menu.Dropdown> + </Menu> </ChatListItemLink> ); } @@ -129,7 +185,7 @@ export default function RecentChats(props: any) { const dispatch = useAppDispatch(); const currentChatID = context.currentChat.chat?.id; - const recentChats = context.chat.search.query(''); + const recentChats = context.chat.searchChats(''); const onClick = useCallback((e: React.MouseEvent) => { if (e.currentTarget.closest('button')) { @@ -152,6 +208,8 @@ export default function RecentChats(props: any) { } }, [currentChatID]); + const synced = !backend.current || backend.current?.isSynced(); + return ( <Container> {recentChats.length > 0 && <ChatList> @@ -159,7 +217,10 @@ export default function RecentChats(props: any) { <ChatListItem key={c.chatID} chat={c} onClick={onClick} selected={c.chatID === currentChatID} /> ))} </ChatList>} - {recentChats.length === 0 && <Empty> + {recentChats.length === 0 && !synced && <Empty> + <Loader size="sm" variant="dots" /> + </Empty>} + {recentChats.length === 0 && synced && <Empty> <FormattedMessage defaultMessage={"No chats yet."} description="Message shown on the Chat History screen for new users who haven't started their first chat session" /> </Empty>} </Container> diff --git a/app/src/components/tts-button.tsx b/app/src/components/tts-button.tsx new file mode 100644 index 00000000..6a70ec3d --- /dev/null +++ b/app/src/components/tts-button.tsx @@ -0,0 +1,63 @@ +import { Button } from "@mantine/core"; +import { useCallback, useEffect, useState } from "react"; +import { FormattedMessage } from "react-intl"; +import { useTTS } from "../core/tts/use-tts"; +import { useAppDispatch } from "../store"; +import { setTabAndOption } from "../store/settings-ui"; + +const autoplayed = new Set<string>(); + +export function TTSButton(props: { id: string, selector: string, complete: boolean, autoplay?: boolean }) { + const dispatch = useAppDispatch(); + const { key, state, voice, autoplayEnabled, play, pause, cancel, setSourceElement, setComplete } = useTTS(); + const [clicked, setClicked] = useState(false); + + const onClick = useCallback(() => { + setClicked(true); + + if (!voice) { + dispatch(setTabAndOption({ tab: 'speech', option: 'service' })); + return; + } + + if (!state || key !== props.id) { + setSourceElement(props.id, document.querySelector(props.selector)!); + play(); + } else { + cancel(); + } + setComplete(props.complete); + }, [state, key, props.id, props.selector, props.complete, voice]); // + + useEffect(() => { + if (key === props.id) { + setComplete(props.complete); + } + }, [key, props.id, props.complete]); + + useEffect(() => { + if (autoplayEnabled && props.autoplay && key !== props.id && voice && !clicked && !autoplayed.has(props.id) && document.visibilityState === 'visible') { + autoplayed.add(props.id); + setSourceElement(props.id, document.querySelector(props.selector)!); + play(); + } + }, [clicked, key, voice, autoplayEnabled, props.id, props.selector, props.complete, props.autoplay]); + + let active = state && key === props.id; + + return (<> + <Button variant="subtle" size="sm" compact onClickCapture={onClick} loading={active && state?.buffering}> + {!active && <i className="fa fa-headphones" />} + {!active && <span> + <FormattedMessage defaultMessage="Play" description="Label for the button that starts text-to-speech playback" /> + </span>} + {active && state?.buffering && <span> + <FormattedMessage defaultMessage="Loading audio..." description="Message indicating that text-to-speech audio is buffering" /> + </span>} + {active && !state?.buffering && <span> + <FormattedMessage defaultMessage="Stop" description="Label for the button that stops text-to-speech playback" /> + </span>} + </Button> + {JSON.stringify(state)} + </>); +} diff --git a/app/src/components/tts-controls.tsx b/app/src/components/tts-controls.tsx new file mode 100644 index 00000000..23bd3d45 --- /dev/null +++ b/app/src/components/tts-controls.tsx @@ -0,0 +1,130 @@ +import styled from '@emotion/styled'; +import { ActionIcon, Button } from '@mantine/core'; +import { useCallback, useEffect } from 'react'; +import { useTTS } from '../core/tts/use-tts'; +import { useAppContext } from '../core/context'; +import { APP_NAME } from '../values'; +import { useHotkeys } from '@mantine/hooks'; + +const Container = styled.div` + background: #292933; + border-top: thin solid #393933; + padding: 1rem; + // padding-bottom: 0.6rem; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + text-align: center; + + p { + font-family: "Work Sans", sans-serif; + font-size: 80%; + margin-bottom: 1rem; + } + + .buttons { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + + // .mantine-ActionIcon-root:disabled { + // background: transparent; + // border-color: transparent; + // } + } +`; + +export default function AudioControls(props: any) { + const context = useAppContext(); + const { state, play, pause, cancel } = useTTS(); + + const handlePlayPause = useCallback(() => { + if (state?.playing) { + pause(); + } else { + play(); + } + }, [state, pause, play]); + + const handlePrevious = useCallback(() => { + if (!state) { + return; + } + play(state.index - 1); + }, [state, play]); + + const handleNext = useCallback(() => { + if (!state) { + return; + } + play(state.index + 1); + }, [state, play]); + + const handleJumpToStart = useCallback(() => { + play(0); + }, [play]); + + const handleJumpToEnd = useCallback(() => { + if (!state) { + return; + } + play(state.length - 1); + }, [state, play]); + + useEffect(() => { + if ('mediaSession' in navigator) { + navigator.mediaSession.metadata = new MediaMetadata({ + title: context.currentChat.chat?.title || APP_NAME, + artist: APP_NAME, + }); + + navigator.mediaSession.setActionHandler('play', handlePlayPause); + navigator.mediaSession.setActionHandler('pause', handlePlayPause); + navigator.mediaSession.setActionHandler('previoustrack', handlePrevious); + navigator.mediaSession.setActionHandler('nexttrack', handleNext); + } + }, [context.currentChat.chat?.title, handlePlayPause, handlePrevious, handleNext]); + + useEffect(() => { + if ('mediaSession' in navigator) { + navigator.mediaSession.playbackState = state?.playing ? 'playing' : 'paused'; + } + }, [state?.playing]); + + useHotkeys([ + ['Space', handlePlayPause], + ]); + + if (!state) { + return null; + } + + return ( + <Container> + <div className="buttons"> + <ActionIcon onClick={handleJumpToStart} variant='light' color='blue'> + <i className="fa fa-fast-backward" /> + </ActionIcon> + <ActionIcon onClick={handlePrevious} variant='light' color='blue' disabled={state?.index === 0}> + <i className="fa fa-step-backward" /> + </ActionIcon> + <ActionIcon onClick={handlePlayPause} variant='light' color='blue'> + <i className={state?.playing ? 'fa fa-pause' : 'fa fa-play'} /> + </ActionIcon> + <ActionIcon onClick={handleNext} variant='light' color='blue' disabled={!state || (state.index === state.length - 1)}> + <i className="fa fa-step-forward" /> + </ActionIcon> + <ActionIcon onClick={handleJumpToEnd} variant='light' color='blue'> + <i className="fa fa-fast-forward" /> + </ActionIcon> + <ActionIcon onClick={cancel} variant='light' color='blue'> + <i className="fa fa-close" /> + </ActionIcon> + </div> + </Container> + ); +} \ No newline at end of file diff --git a/app/src/context.tsx b/app/src/context.tsx deleted file mode 100644 index 50009b9a..00000000 --- a/app/src/context.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import React, { useState, useRef, useMemo, useEffect, useCallback } from "react"; -import { useLocation, useNavigate, useParams } from "react-router-dom"; -import { backend } from "./backend"; -import ChatManagerInstance, { ChatManager } from "./chat-manager"; -import store, { useAppDispatch } from "./store"; -import { openOpenAIApiKeyPanel } from "./store/settings-ui"; -import { Message } from "./types"; -import { useChat, UseChatResult } from "./use-chat"; - -export interface Context { - authenticated: boolean; - chat: ChatManager; - id: string | undefined | null; - currentChat: UseChatResult; - isHome: boolean; - isShare: boolean; - generating: boolean; - onNewMessage: (message?: string) => Promise<boolean>; - regenerateMessage: (message: Message) => Promise<boolean>; - editMessage: (message: Message, content: string) => Promise<boolean>; -} - -const AppContext = React.createContext<Context>({} as any); - -export function useCreateAppContext(): Context { - const { id } = useParams(); - const dispatch = useAppDispatch(); - - const pathname = useLocation().pathname; - const isHome = pathname === '/'; - const isShare = pathname.startsWith('/s/'); - const navigate = useNavigate(); - - const chatManager = useRef(ChatManagerInstance); - const currentChat = useChat(chatManager.current, id, isShare); - const [authenticated, setAuthenticated] = useState(backend.current?.isAuthenticated || false); - - const updateAuth = useCallback((authenticated: boolean) => setAuthenticated(authenticated), []); - - useEffect(() => { - backend.current?.on('authenticated', updateAuth); - return () => { - backend.current?.off('authenticated', updateAuth) - }; - }, [updateAuth]); - - const onNewMessage = useCallback(async (message?: string) => { - if (isShare) { - return false; - } - - if (!message?.trim().length) { - return false; - } - - const openaiApiKey = store.getState().apiKeys.openAIApiKey; - - if (!openaiApiKey) { - dispatch(openOpenAIApiKeyPanel()); - return false; - } - - const parameters = store.getState().parameters; - - if (id) { - await chatManager.current.sendMessage({ - chatID: id, - content: message.trim(), - requestedParameters: { - ...parameters, - apiKey: openaiApiKey, - }, - parentID: currentChat.leaf?.id, - }); - } else { - const id = await chatManager.current.createChat(); - await chatManager.current.sendMessage({ - chatID: id, - content: message.trim(), - requestedParameters: { - ...parameters, - apiKey: openaiApiKey, - }, - parentID: currentChat.leaf?.id, - }); - navigate('/chat/' + id); - } - - return true; - }, [dispatch, chatManager, id, currentChat.leaf, navigate, isShare]); - - const regenerateMessage = useCallback(async (message: Message) => { - if (isShare) { - return false; - } - - const openaiApiKey = store.getState().apiKeys.openAIApiKey; - - if (!openaiApiKey) { - dispatch(openOpenAIApiKeyPanel()); - return false; - } - - const parameters = store.getState().parameters; - - await chatManager.current.regenerate(message, { - ...parameters, - apiKey: openaiApiKey, - }); - - return true; - }, [dispatch, chatManager, isShare]); - - const editMessage = useCallback(async (message: Message, content: string) => { - if (isShare) { - return false; - } - - if (!content?.trim().length) { - return false; - } - - const openaiApiKey = store.getState().apiKeys.openAIApiKey; - - if (!openaiApiKey) { - dispatch(openOpenAIApiKeyPanel()); - return false; - } - - const parameters = store.getState().parameters; - - if (id) { - await chatManager.current.sendMessage({ - chatID: id, - content: content.trim(), - requestedParameters: { - ...parameters, - apiKey: openaiApiKey, - }, - parentID: message.parentID, - }); - } else { - const id = await chatManager.current.createChat(); - await chatManager.current.sendMessage({ - chatID: id, - content: content.trim(), - requestedParameters: { - ...parameters, - apiKey: openaiApiKey, - }, - parentID: message.parentID, - }); - navigate('/chat/' + id); - } - - return true; - }, [dispatch, chatManager, id, isShare, navigate]); - - const generating = currentChat?.messagesToDisplay?.length > 0 - ? !currentChat.messagesToDisplay[currentChat.messagesToDisplay.length - 1].done - : false; - - const context = useMemo<Context>(() => ({ - authenticated, - id, - chat: chatManager.current, - currentChat, - isHome, - isShare, - generating, - onNewMessage, - regenerateMessage, - editMessage, - }), [chatManager, authenticated, generating, onNewMessage, regenerateMessage, editMessage, currentChat, id, isShare]); - - 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>; -} \ No newline at end of file diff --git a/app/src/core/backend.ts b/app/src/core/backend.ts new file mode 100644 index 00000000..14932a22 --- /dev/null +++ b/app/src/core/backend.ts @@ -0,0 +1,276 @@ +import EventEmitter from 'events'; +import * as Y from 'yjs'; +import { encode, decode } from '@msgpack/msgpack'; +import { MessageTree } from './chat/message-tree'; +import { Chat } from './chat/types'; +import { AsyncLoop } from "./utils/async-loop"; +import { ChatManager } from '.'; +import { getRateLimitResetTimeFromResponse } from './utils'; +import { importChat } from './chat/chat-persistance'; + +const endpoint = '/chatapi'; + +export let backend: { + current?: Backend | null +} = {}; + +export interface User { + id: string; + email?: string; + name?: string; + avatar?: string; + services?: string[]; +} + +export class Backend extends EventEmitter { + public user: User | null = null; + private checkedSession = false; + + private sessionInterval = new AsyncLoop(() => this.getSession(), 1000 * 30); + private syncInterval = new AsyncLoop(() => this.sync(), 1000 * 5); + + private pendingYUpdate: Uint8Array | null = null; + private lastFullSyncAt = 0; + private legacySync = false; + private rateLimitedUntil = 0; + + public constructor(private context: ChatManager) { + super(); + + if ((window as any).AUTH_PROVIDER) { + backend.current = this; + + this.sessionInterval.start(); + this.syncInterval.start(); + } + } + + public isSynced() { + return (this.checkedSession && !this.isAuthenticated) || this.lastFullSyncAt > 0; + } + + public async getSession() { + if (Date.now() < this.rateLimitedUntil) { + console.log(`Waiting another ${this.rateLimitedUntil - Date.now()}ms to check session due to rate limiting.`); + return; + } + + const wasAuthenticated = this.isAuthenticated; + const session = await this.get(endpoint + '/session'); + + if (session?.authProvider) { + (window as any).AUTH_PROVIDER = session.authProvider; + } + + if (session?.authenticated) { + this.user = { + id: session.userID, + email: session.email, + name: session.name, + avatar: session.picture, + services: session.services, + }; + } else { + this.user = null; + } + + this.checkedSession = true; + + if (wasAuthenticated !== this.isAuthenticated) { + this.emit('authenticated', this.isAuthenticated); + this.lastFullSyncAt = 0; + } + } + + public async sync() { + if (!this.isAuthenticated) { + return; + } + + if (Date.now() < this.rateLimitedUntil) { + console.log(`Waiting another ${this.rateLimitedUntil - Date.now()}ms before syncing due to rate limiting.`); + return; + } + + const encoding = await import('lib0/encoding'); + const decoding = await import('lib0/decoding'); + const syncProtocol = await import('y-protocols/sync'); + + const sinceLastFullSync = Date.now() - this.lastFullSyncAt; + + const pendingYUpdate = this.pendingYUpdate; + if (pendingYUpdate && pendingYUpdate.length > 4) { + this.pendingYUpdate = null; + + const encoder = encoding.createEncoder(); + syncProtocol.writeUpdate(encoder, pendingYUpdate); + + const response = await fetch(endpoint + '/y-sync', { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream' + }, + body: encoding.toUint8Array(encoder), + }); + + if (response.status === 429) { + this.rateLimitedUntil = getRateLimitResetTimeFromResponse(response); + } + } else if (sinceLastFullSync > 1000 * 60 * 1) { + this.lastFullSyncAt = Date.now(); + + const encoder = encoding.createEncoder(); + syncProtocol.writeSyncStep1(encoder, this.context.doc.root); + + const queue: Uint8Array[] = [ + encoding.toUint8Array(encoder), + ]; + + for (let i = 0; i < 4; i++) { + if (!queue.length) { + break; + } + + const buffer = queue.shift()!; + + const response = await fetch(endpoint + '/y-sync', { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream' + }, + body: buffer, + }); + + if (!response.ok) { + this.rateLimitedUntil = getRateLimitResetTimeFromResponse(response); + throw new Error(response.statusText); + } + + const responseBuffer = await response.arrayBuffer(); + const responseChunks = decode(responseBuffer) as Uint8Array[]; + + for (const chunk of responseChunks) { + if (!chunk.byteLength) { + continue; + } + + const encoder = encoding.createEncoder(); + const decoder = decoding.createDecoder(chunk); + + const messageType = decoding.readVarUint(decoder); + decoder.pos = 0; + + syncProtocol.readSyncMessage(decoder, encoder, this.context.doc.root, 'sync'); + + if (encoding.length(encoder)) { + queue.push(encoding.toUint8Array(encoder)); + } + } + } + + this.context.emit('update'); + } + + if (!this.legacySync) { + this.legacySync = true; + + const chats = await this.get(endpoint + '/legacy-sync'); + + this.context.doc.transact(() => { + for (const chat of chats) { + try { + importChat(this.context.doc, chat); + } catch (e) { + console.error(e); + } + } + }); + } + } + + public receiveYUpdate(update: Uint8Array) { + if (!this.pendingYUpdate) { + this.pendingYUpdate = update; + } else { + this.pendingYUpdate = Y.mergeUpdates([this.pendingYUpdate, update]); + } + } + + async signIn() { + window.location.href = endpoint + '/login'; + } + + get isAuthenticated() { + return this.user !== null; + } + + async logout() { + window.location.href = endpoint + '/logout'; + } + + async shareChat(chat: Chat): Promise<string | null> { + try { + const { id } = await this.post(endpoint + '/share', { + ...chat, + messages: chat.messages.serialize(), + }); + if (typeof id === 'string') { + return id; + } + } catch (e) { + console.error(e); + } + return null; + } + + async getSharedChat(id: string): Promise<Chat | null> { + const format = process.env.REACT_APP_SHARE_URL || (endpoint + '/share/:id'); + const url = format.replace(':id', id); + try { + const chat = await this.get(url); + if (chat?.messages?.length) { + chat.messages = new MessageTree(chat.messages); + return chat; + } + } catch (e) { + console.error(e); + } + return null; + } + + async deleteChat(id: string) { + if (!this.isAuthenticated) { + return; + } + + return this.post(endpoint + '/delete', { id }); + } + + async get(url: string) { + const response = await fetch(url); + if (response.status === 429) { + this.rateLimitedUntil = getRateLimitResetTimeFromResponse(response); + } + if (!response.ok) { + throw new Error(response.statusText); + } + return response.json(); + } + + async post(url: string, data: any) { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + if (response.status === 429) { + this.rateLimitedUntil = getRateLimitResetTimeFromResponse(response); + } + if (!response.ok) { + throw new Error(response.statusText); + } + return response.json(); + } +} \ No newline at end of file diff --git a/app/src/core/chat/chat-persistance.ts b/app/src/core/chat/chat-persistance.ts new file mode 100644 index 00000000..e6727621 --- /dev/null +++ b/app/src/core/chat/chat-persistance.ts @@ -0,0 +1,71 @@ +import * as idb from '../utils/idb'; +import * as Y from 'yjs'; +import { MessageTree } from './message-tree'; +import { Chat } from './types'; +import { YChatDoc } from './y-chat'; + +export async function loadFromPreviousVersion(doc: YChatDoc) { + const serialized = await idb.get('chats'); + if (serialized) { + for (const chat of serialized) { + try { + if (chat.deleted) { + continue; + } + if (doc.has(chat.id)) { + continue; + } + const messages = new MessageTree(); + for (const m of chat.messages) { + messages.addMessage(m); + } + chat.messages = messages; + importChat(doc, chat); + } catch (e) { + console.error(e); + } + } + } +} + +export function importChat(doc: YChatDoc, chat: Chat) { + const ychat = doc.getYChat(chat.id, true); + + if (ychat.deleted) { + return; + } + + if (chat.metadata) { + for (const key of Object.keys(chat.metadata)) { + if (!ychat.importedMetadata.has(key)) { + ychat.importedMetadata.set(key, chat.metadata[key]); + } + } + } else if (chat.title) { + if (!ychat.importedMetadata.has('title')) { + ychat.importedMetadata.set('title', chat.title); + } + } + + if (chat.pluginOptions) { + for (const key of Object.keys(chat.pluginOptions)) { + const [pluginID, option] = key.split('.', 2); + if (!ychat.pluginOptions.has(key)) { + ychat.setOption(pluginID, option, chat.pluginOptions[key]); + } + } + } + + const messages = chat.messages instanceof MessageTree ? chat.messages.serialize() : chat.messages; + + for (const message of messages) { + if (ychat.messages.has(message.id)) { + continue; + } + ychat.messages.set(message.id, message); + ychat.content.set(message.id, new Y.Text(message.content || '')); + if (message.done) { + ychat.done.set(message.id, message.done); + } + } +} \ No newline at end of file diff --git a/app/src/core/chat/create-reply.ts b/app/src/core/chat/create-reply.ts new file mode 100644 index 00000000..112b3c0e --- /dev/null +++ b/app/src/core/chat/create-reply.ts @@ -0,0 +1,185 @@ +import EventEmitter from "events"; +import { createChatCompletion, createStreamingChatCompletion } from "./openai"; +import { PluginContext } from "../plugins/plugin-context"; +import { pluginRunner } from "../plugins/plugin-runner"; +import { Chat, Message, OpenAIMessage, Parameters, getOpenAIMessageFromMessage } from "./types"; +import { EventEmitterAsyncIterator } from "../utils/event-emitter-async-iterator"; +import { YChat } from "./y-chat"; +import { OptionsManager } from "../options"; + +export class ReplyRequest extends EventEmitter { + private mutatedMessages: OpenAIMessage[]; + private mutatedParameters: Parameters; + private lastChunkReceivedAt: number = 0; + private timer: any; + private done: boolean = false; + private content = ''; + private cancelSSE: any; + + constructor(private chat: Chat, + private yChat: YChat, + private messages: Message[], + private replyID: string, + private requestedParameters: Parameters, + private pluginOptions: OptionsManager) { + super(); + this.mutatedMessages = [...messages]; + this.mutatedMessages = messages.map(m => getOpenAIMessageFromMessage(m)); + this.mutatedParameters = { ...requestedParameters }; + delete this.mutatedParameters.apiKey; + } + + pluginContext = (pluginID: string) => ({ + getOptions: () => { + return this.pluginOptions.getAllOptions(pluginID, this.chat.id); + }, + + getCurrentChat: () => { + return this.chat; + }, + + createChatCompletion: async (messages: OpenAIMessage[], _parameters: Parameters) => { + return await createChatCompletion(messages, { + ..._parameters, + apiKey: this.requestedParameters.apiKey, + }); + }, + + setChatTitle: async (title: string) => { + this.yChat.title = title; + }, + } as PluginContext); + + private scheduleTimeout() { + this.lastChunkReceivedAt = Date.now(); + + clearInterval(this.timer); + + this.timer = setInterval(() => { + const sinceLastChunk = Date.now() - this.lastChunkReceivedAt; + if (sinceLastChunk > 30000 && !this.done) { + this.onError('no response from OpenAI in the last 30 seconds'); + } + }, 2000); + } + + public async execute() { + try { + this.scheduleTimeout(); + + await pluginRunner("preprocess-model-input", this.pluginContext, async plugin => { + const output = await plugin.preprocessModelInput(this.mutatedMessages, this.mutatedParameters); + this.mutatedMessages = output.messages; + this.mutatedParameters = output.parameters; + this.lastChunkReceivedAt = Date.now(); + }); + + const { emitter, cancel } = await createStreamingChatCompletion(this.mutatedMessages, { + ...this.mutatedParameters, + apiKey: this.requestedParameters.apiKey, + }); + this.cancelSSE = cancel; + + const eventIterator = new EventEmitterAsyncIterator<string>(emitter, ["data", "done", "error"]); + + for await (const event of eventIterator) { + const { eventName, value } = event; + + switch (eventName) { + case 'data': + await this.onData(value); + break; + + case 'done': + await this.onDone(); + break; + + case 'error': + if (!this.content || !this.done) { + await this.onError(value); + } + break; + } + } + } catch (e: any) { + console.error(e); + this.onError(e.message); + } + } + + public async onData(value: any) { + if (this.done) { + return; + } + + this.lastChunkReceivedAt = Date.now(); + + this.content = value; + + await pluginRunner("postprocess-model-output", this.pluginContext, async plugin => { + const output = await plugin.postprocessModelOutput({ + role: 'assistant', + content: this.content, + }, this.mutatedMessages, this.mutatedParameters, false); + + this.content = output.content; + }); + + this.yChat.setPendingMessageContent(this.replyID, this.content); + } + + public async onDone() { + if (this.done) { + return; + } + clearInterval(this.timer); + this.lastChunkReceivedAt = Date.now(); + this.done = true; + this.emit('done'); + + this.yChat.onMessageDone(this.replyID); + + await pluginRunner("postprocess-model-output", this.pluginContext, async plugin => { + const output = await plugin.postprocessModelOutput({ + role: 'assistant', + content: this.content, + }, this.mutatedMessages, this.mutatedParameters, true); + + this.content = output.content; + }); + + this.yChat.setMessageContent(this.replyID, this.content); + } + + public async onError(error: string) { + if (this.done) { + return; + } + this.done = true; + this.emit('done'); + clearInterval(this.timer); + this.cancelSSE?.(); + + this.content += `\n\nI'm sorry, I'm having trouble connecting to OpenAI (${error || 'no response from the API'}). Please make sure you've entered your OpenAI API key correctly and try again.`; + this.content = this.content.trim(); + + this.yChat.setMessageContent(this.replyID, this.content); + this.yChat.onMessageDone(this.replyID); + } + + public onCancel() { + clearInterval(this.timer); + this.done = true; + this.yChat.onMessageDone(this.replyID); + this.cancelSSE?.(); + this.emit('done'); + } + + // private setMessageContent(content: string) { + // const text = this.yChat.content.get(this.replyID); + // if (text && text.toString() !== content) { + // text?.delete(0, text.length); + // text?.insert(0, content); + // } + // } +} \ No newline at end of file diff --git a/app/src/core/chat/message-tree.ts b/app/src/core/chat/message-tree.ts new file mode 100644 index 00000000..e05686a1 --- /dev/null +++ b/app/src/core/chat/message-tree.ts @@ -0,0 +1,197 @@ +import { Message } from "./types"; + +/** + * MessageNode interface that extends the Message type and includes parent and replies properties. + * This allows creating a tree structure from messages. + */ +export interface MessageNode extends Message { + parent: MessageNode | null; + replies: Set<MessageNode>; +} + +/** + * Function to create a new MessageNode from a given message. + * + * @param {Message} message - The message to be converted to a MessageNode. + * @returns {MessageNode} - The newly created MessageNode. + */ +export function createMessageNode(message: Message): MessageNode { + return { + ...message, + parent: null, + replies: new Set(), + }; +} + +/** + * MessageTree class for representing and managing a tree structure of messages. + * The tree is made up of MessageNode objects, which extend the `Message` type and can have parent and replies relationships. + * The purpose of the tree structure is to represent a hierarchy of messages, where one message might have multiple + * replies, and each reply has a parent message that it is replying to. + */ + +export class MessageTree { + public messageNodes: Map<string, MessageNode> = new Map(); // TODO make private + + constructor(messages: (Message | MessageNode)[] = []) { + this.addMessages(messages); + } + + /** + * Getter method for retrieving root messages (messages without a parent) in the tree. + * @returns {MessageNode[]} - An array of root messages. + */ + public get roots(): MessageNode[] { + return Array.from(this.messageNodes.values()) + .filter((messageNode) => messageNode.parent === null); + } + + /** + * Getter method for retrieving leaf messages (messages without any replies) in the tree. + * @returns {MessageNode[]} - An array of leaf messages. + */ + public get leafs(): MessageNode[] { + return Array.from(this.messageNodes.values()) + .filter((messageNode) => messageNode.replies.size === 0); + } + + /** + * Getter method for retrieving the first message in the most recent message chain. + * @returns {MessageNode | null} - The first message in the most recent message chain, or null if the tree is empty. + */ + public get first(): MessageNode | null { + const leaf = this.mostRecentLeaf(); + let first: MessageNode | null = leaf; + while (first?.parent) { + first = first.parent; + } + return first; + } + + /** + * Method to get a message node from the tree by its ID. + * @param {string} id - The ID of the message node to retrieve. + * @returns {MessageNode | null} - The message node with the given ID, or null if it does not exist in the tree. + */ + public get(id: string): MessageNode | null { + return this.messageNodes.get(id) || null; + } + + /** + * Method to add a message to the tree. If a message with the same ID already exists in the tree, this method does nothing. + * @param {Message} message - The message to add to the tree. + */ + public addMessage(inputMessage: Message, content: string | undefined = '', done: boolean | undefined = false): void { + const message = { + ...inputMessage, + content: content || inputMessage.content || '', + done: typeof done === 'boolean' ? done : inputMessage.done, + }; + + if (this.messageNodes.get(message.id)?.content) { + return; + } + + const messageNode = createMessageNode(message); + + this.messageNodes.set(messageNode.id, messageNode); + + if (messageNode.parentID) { + let parent = this.messageNodes.get(messageNode.parentID); + + if (!parent) { + parent = createMessageNode({ + id: messageNode.parentID, + } as Message); + + this.messageNodes.set(parent.id, parent); + } + + parent.replies.add(messageNode); + messageNode.parent = parent; + } + + for (const other of Array.from(this.messageNodes.values())) { + if (other.parentID === messageNode.id) { + messageNode.replies.add(other); + other.parent = messageNode; + } + } + } + + /** + * Method to add multiple messages to the tree. + * @param {Message[]} messages - An array of messages to add to the tree. + */ + public addMessages(messages: Message[]): void { + for (const message of messages) { + try { + this.addMessage(message); + } catch (e) { + console.error(`Error adding message with id: ${message.id}`, e); + } + } + } + + /** + * Method to update the content, timestamp, and done status of an existing message in the tree. + * @param {Message} message - The updated message. + */ + public updateMessage(message: Message): void { + const messageNode = this.messageNodes.get(message.id); + + if (!messageNode) { + return; + } + + messageNode.content = message.content; + messageNode.timestamp = message.timestamp; + messageNode.done = message.done; + } + + /** + * Method to get the message chain leading to a specific message by its ID. + * @param {string} messageID - The ID of the target message. + * @returns {MessageNode[]} - An array of message nodes in the chain leading to the target message. + */ + public getMessageChainTo(messageID: string): MessageNode[] { + const message = this.messageNodes.get(messageID); + + if (!message) { + return []; + } + + const chain = [message]; + + let current = message; + + while (current.parent) { + chain.unshift(current.parent); + current = current.parent; + } + + return chain; + } + + /** + * Method to serialize the message tree into an array of message nodes, excluding parent and replies properties. + * @returns {Omit<MessageNode, 'parent' | 'replies'>[]} - An array of serialized message nodes. + */ + public serialize(): Omit<MessageNode, 'parent' | 'replies'>[] { + return Array.from(this.messageNodes.values()) + .map((messageNode) => { + const n: any = { ...messageNode }; + delete n.parent; + delete n.replies; + return n; + }); + } + + /** + * Method to get the most recent leaf message in the message tree. + * @returns {MessageNode | null} - The most recent leaf message, or null if the tree is empty. + */ + public mostRecentLeaf(): MessageNode | null { + return this.leafs.sort((a, b) => b.timestamp - a.timestamp)[0] || null; + } +} \ No newline at end of file diff --git a/app/src/openai.ts b/app/src/core/chat/openai.ts similarity index 53% rename from app/src/openai.ts rename to app/src/core/chat/openai.ts index d3f56ff7..8b63def7 100644 --- a/app/src/openai.ts +++ b/app/src/core/chat/openai.ts @@ -1,16 +1,23 @@ import EventEmitter from "events"; import { Configuration, OpenAIApi } from "openai"; -import SSE from "./sse"; +import SSE from "../utils/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(); +import { backend } from "../backend"; export const defaultModel = 'gpt-3.5-turbo'; +export function isProxySupported() { + return !!backend.current?.user?.services?.includes('openai'); +} + +function shouldUseProxy(apiKey: string | undefined | null) { + return !apiKey && isProxySupported(); +} + +function getEndpoint(proxied = false) { + return proxied ? '/chatapi/proxies/openai' : 'https://api.openai.com'; +} + export interface OpenAIResponseChunk { id?: string; done: boolean; @@ -44,83 +51,56 @@ function parseResponseChunk(buffer: any): OpenAIResponseChunk { } export async function createChatCompletion(messages: OpenAIMessage[], parameters: Parameters): Promise<string> { - if (!parameters.apiKey) { + const proxied = shouldUseProxy(parameters.apiKey); + const endpoint = getEndpoint(proxied); + + if (!proxied && !parameters.apiKey) { throw new Error('No API key provided'); } - const configuration = new Configuration({ - apiKey: parameters.apiKey, + const response = await fetch(endpoint + '/v1/chat/completions', { + method: "POST", + headers: { + 'Accept': 'application/json, text/plain, */*', + 'Authorization': !proxied ? `Bearer ${parameters.apiKey}` : '', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + "model": parameters.model, + "messages": messages, + "temperature": parameters.temperature, + }), }); - const openai = new OpenAIApi(configuration); + const data = await response.json(); - const response = await openai.createChatCompletion({ - model: parameters.model, - temperature: parameters.temperature, - messages: messages as any, - }); - - return response.data.choices[0].message?.content?.trim() || ''; + return 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(); - let messagesToSend = [...messages].filter(m => m.role !== 'app'); + const proxied = shouldUseProxy(parameters.apiKey); + const endpoint = getEndpoint(proxied); - 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); - } + if (!proxied && !parameters.apiKey) { + throw new Error('No API key provided'); } - messagesToSend.unshift({ - role: 'system', - content: (parameters.initialSystemPrompt || defaultSystemPrompt).replace('{{ datetime }}', new Date().toLocaleString()), - }); - - messagesToSend = await selectMessagesToSendSafely(messagesToSend, 2048); - - const eventSource = new SSE('https://api.openai.com/v1/chat/completions', { + const eventSource = new SSE(endpoint + '/v1/chat/completions', { method: "POST", headers: { 'Accept': 'application/json, text/plain, */*', - 'Authorization': `Bearer ${parameters.apiKey}`, + 'Authorization': !proxied ? `Bearer ${parameters.apiKey}` : '', 'Content-Type': 'application/json', }, payload: JSON.stringify({ "model": parameters.model, - "messages": messagesToSend, - "temperature": parameters.temperature, - "stream": true, - }), - }) as SSE; - - // TODO: enable (optional) server-side completion - /* - const eventSource = new SSE('/chatapi/completion/streaming', { - method: "POST", - headers: { - 'Accept': 'application/json, text/plain, *\/*', - 'Authorization': `Bearer ${(backend.current as any).token}`, - 'Content-Type': 'application/json', - }, - payload: JSON.stringify({ - "model": "gpt-3.5-turbo", - "messages": messagesToSend, + "messages": messages, "temperature": parameters.temperature, "stream": true, }), }) as SSE; - */ let contents = ''; @@ -135,7 +115,6 @@ export async function createStreamingChatCompletion(messages: OpenAIMessage[], p }); eventSource.addEventListener('message', async (event: any) => { - if (event.data === '[DONE]') { emitter.emit('done'); return; @@ -160,14 +139,7 @@ export async function createStreamingChatCompletion(messages: OpenAIMessage[], p }; } -async function selectMessagesToSendSafely(messages: OpenAIMessage[], maxTokens: number) { - const { ChatHistoryTrimmer } = await import(/* webpackPreload: true */ './tokenizer/chat-history-trimmer'); - const compressor = new ChatHistoryTrimmer(messages, { - maxTokens, - preserveFirstUserMessage: true, - preserveSystemPrompt: true, - }); - return compressor.process(); -} - -setTimeout(() => selectMessagesToSendSafely([], 2048), 2000); \ No newline at end of file +export const maxTokensByModel = { + "chatgpt-3.5-turbo": 2048, + "gpt-4": 8096, +} \ No newline at end of file diff --git a/app/src/parameters.ts b/app/src/core/chat/parameters.ts similarity index 100% rename from app/src/parameters.ts rename to app/src/core/chat/parameters.ts diff --git a/app/src/types.ts b/app/src/core/chat/types.ts similarity index 94% rename from app/src/types.ts rename to app/src/core/chat/types.ts index 8d86da81..cf43e1ec 100644 --- a/app/src/types.ts +++ b/app/src/core/chat/types.ts @@ -41,6 +41,8 @@ export function getOpenAIMessageFromMessage(message: Message): OpenAIMessage { export interface Chat { id: string; messages: MessageTree; + metadata?: Record<string, any>; + pluginOptions?: Record<string, any>; title?: string | null; created: number; updated: number; diff --git a/app/src/use-chat.ts b/app/src/core/chat/use-chat.ts similarity index 96% rename from app/src/use-chat.ts rename to app/src/core/chat/use-chat.ts index 637a5b25..54f43fd4 100644 --- a/app/src/use-chat.ts +++ b/app/src/core/chat/use-chat.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react"; -import { backend } from "./backend"; -import { ChatManager } from "./chat-manager"; +import { backend } from "../backend"; +import { ChatManager } from ".."; import { Chat, Message } from './types'; export interface UseChatResult { diff --git a/app/src/core/chat/y-chat.ts b/app/src/core/chat/y-chat.ts new file mode 100644 index 00000000..9653b765 --- /dev/null +++ b/app/src/core/chat/y-chat.ts @@ -0,0 +1,307 @@ +import * as Y from 'yjs'; +import { Chat, Message } from './types'; +import EventEmitter from 'events'; +import { v4 as uuidv4 } from 'uuid'; +import { MessageTree } from './message-tree'; + +const METADATA_KEY = 'metadata'; +const IMPORTED_METADATA_KEY = 'imported-metadata'; +const PLUGIN_OPTIONS_KEY = 'plugin-options'; +const MESSAGES_KEY = 'messages'; +const CONTENT_KEY = 'messages:content'; +const DONE_KEY = 'messages:done'; + +export class YChat { + private callback: any; + private pendingContent = new Map<string, string>(); + private prefix = 'chat.' + this.id + '.'; + + public static from(root: Y.Doc, id: string) { + // const id = data.get('metadata').get('id') as string; + return new YChat(id, root); + } + + constructor(public readonly id: string, public root: Y.Doc) { + this.purgeDeletedValues(); + } + + public observeDeep(callback: any) { + this.callback = callback; + this.metadata?.observeDeep(callback); + this.pluginOptions?.observeDeep(callback); + this.messages?.observeDeep(callback); + this.content?.observeDeep(callback); + this.done?.observeDeep(callback); + } + + public get deleted(): boolean { + return this.metadata.get('deleted') || false; + } + + public get metadata(): Y.Map<any> { + return this.root.getMap<any>(this.prefix + METADATA_KEY); + } + + public get importedMetadata(): Y.Map<any> { + return this.root.getMap<any>(this.prefix + IMPORTED_METADATA_KEY); + } + + public get pluginOptions(): Y.Map<any> { + return this.root.getMap<any>(this.prefix + PLUGIN_OPTIONS_KEY); + } + + public get messages(): Y.Map<Message> { + return this.root.getMap<Message>(this.prefix + MESSAGES_KEY); + } + + public get content(): Y.Map<Y.Text> { + return this.root.getMap<Y.Text>(this.prefix + CONTENT_KEY); + } + + public get done(): Y.Map<boolean> { + return this.root.getMap<boolean>(this.prefix + DONE_KEY); + } + + public get title() { + return (this.metadata.get('title') as string) || (this.importedMetadata.get('title') as string) || null; + } + + public set title(value: string | null) { + if (value) { + this.metadata.set('title', value); + } + } + + public setPendingMessageContent(messageID: string, value: string) { + this.pendingContent.set(messageID, value); + this.callback?.(); + } + + public setMessageContent(messageID: string, value: string) { + this.pendingContent.delete(messageID); + this.content.set(messageID, new Y.Text(value)); + } + + public getMessageContent(messageID: string) { + return this.pendingContent.get(messageID) || this.content.get(messageID)?.toString() || ""; + } + + public onMessageDone(messageID: string) { + this.done.set(messageID, true); + } + + public getOption(pluginID: string, optionID: string): any { + const key = pluginID + "." + optionID; + return this.pluginOptions?.get(key) || null; + } + + public setOption(pluginID: string, optionID: string, value: any) { + const key = pluginID + "." + optionID; + return this.pluginOptions.set(key, value); + } + + public hasOption(pluginID: string, optionID: string) { + const key = pluginID + "." + optionID; + return this.pluginOptions.has(key); + } + + public delete() { + if (!this.deleted) { + this.metadata.clear(); + this.pluginOptions.clear(); + this.messages.clear(); + this.content.clear(); + this.done.clear(); + } else { + this.purgeDeletedValues(); + } + } + + private purgeDeletedValues() { + if (this.deleted) { + if (this.metadata.size > 1) { + for (const key of Array.from(this.metadata.keys())) { + if (key !== 'deleted') { + this.metadata.delete(key); + } + } + } + if (this.pluginOptions.size > 0) { + this.pluginOptions.clear(); + } + if (this.messages.size > 0) { + this.messages.clear(); + } + if (this.content.size > 0) { + this.content.clear(); + } + if (this.done.size > 0) { + this.done.clear(); + } + } + } +} + +export class YChatDoc extends EventEmitter { + public root = new Y.Doc(); + // public chats = this.root.getMap<Y.Map<any>>('chats'); + // public deletedChatIDs = this.root.getArray<string>('deletedChatIDs'); + public deletedChatIDsSet = new Set<string>(); + public options = this.root.getMap<Y.Map<any>>('options'); + private yChats = new Map<string, YChat>(); + + private observed = new Set<string>(); + + constructor() { + super(); + + this.root.whenLoaded.then(() => { + const chatIDs = Array.from(this.root.getMap('chats').keys()); + for (const id of chatIDs) { + this.observeChat(id); + } + }); + } + + private observeChat(id: string, yChat = this.getYChat(id)) { + if (!this.observed.has(id)) { + yChat?.observeDeep(() => this.emit('update', id)); + this.observed.add(id); + } + } + + // public set(id: string, chat: YChat) { + // this.chats.set(id, chat.data); + + // if (!this.observed.has(id)) { + // this.getYChat(id)?.observeDeep(() => this.emit('update', id)); + // this.observed.add(id); + // } + // } + + public get chatIDMap() { + return this.root.getMap('chatIDs'); + } + + public getYChat(id: string, expectContent = false) { + let yChat = this.yChats.get(id); + + if (!yChat) { + yChat = YChat.from(this.root, id); + this.yChats.set(id, yChat); + } + + if (expectContent && !this.chatIDMap.has(id)) { + this.chatIDMap.set(id, true); + } + + this.observeChat(id, yChat); + + return yChat; + } + + public delete(id: string) { + this.getYChat(id)?.delete(); + } + + public has(id: string) { + return this.chatIDMap.has(id) && !YChat.from(this.root, id).deleted; + } + + public getChatIDs() { + return Array.from(this.chatIDMap.keys()); + } + + public getAllYChats() { + return this.getChatIDs().map(id => this.getYChat(id)!); + } + + public transact(cb: () => void) { + return this.root.transact(cb); + } + + public addMessage(message: Message) { + const chat = this.getYChat(message.chatID, true); + + if (!chat) { + throw new Error('Chat not found'); + } + + this.transact(() => { + chat.messages.set(message.id, { + ...message, + content: '', + }); + chat.content.set(message.id, new Y.Text(message.content || '')); + if (message.done) { + chat.done.set(message.id, message.done); + } + }); + } + + public createYChat(id = uuidv4()) { + // return new YChat(id, this.root); + // this.set(id, chat); + return id; + } + + public getMessageTree(chatID: string): MessageTree { + const tree = new MessageTree(); + const chat = this.getYChat(chatID); + + chat?.messages?.forEach(m => { + try { + const content = chat.getMessageContent(m.id); + const done = chat.done.get(m.id) || false; + tree.addMessage(m, content, done); + } catch (e) { + console.warn(`failed to load message ${m.id}`, e); + } + }); + + return tree; + } + + public getMessagesPrecedingMessage(chatID: string, messageID: string) { + const tree = this.getMessageTree(chatID); + const message = tree.get(messageID); + + if (!message) { + throw new Error("message not found: " + messageID); + } + + const messages: Message[] = message.parentID + ? tree.getMessageChainTo(message.parentID) + : []; + + return messages; + } + + public getChat(id: string): Chat { + const chat = this.getYChat(id); + const tree = this.getMessageTree(id); + return { + id, + messages: tree, + title: chat.title, + metadata: { + ...chat.importedMetadata.toJSON(), + ...chat.metadata.toJSON(), + }, + pluginOptions: chat?.pluginOptions?.toJSON() || {}, + deleted: !chat.deleted, + created: tree.first?.timestamp || 0, + updated: tree.mostRecentLeaf()?.timestamp || 0, + } + } + + public getOption(pluginID: string, optionID: string): any { + const key = pluginID + "." + optionID; + return this.options.get(key); + } + + public setOption(pluginID: string, optionID: string, value: any) { + const key = pluginID + "." + optionID; + return this.options.set(key, value); + } +} \ No newline at end of file diff --git a/app/src/core/context.tsx b/app/src/core/context.tsx new file mode 100644 index 00000000..3b337546 --- /dev/null +++ b/app/src/core/context.tsx @@ -0,0 +1,256 @@ +import React, { useState, useRef, useMemo, useEffect, useCallback } from "react"; +import { v4 as uuidv4 } from 'uuid'; +import { IntlShape, useIntl } from "react-intl"; +import { Backend, User } from "./backend"; +import { ChatManager } from "./"; +import { useAppDispatch } from "../store"; +import { openOpenAIApiKeyPanel } from "../store/settings-ui"; +import { Message, Parameters } from "./chat/types"; +import { useChat, UseChatResult } from "./chat/use-chat"; +import { TTSContextProvider } from "./tts/use-tts"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; +import { isProxySupported } from "./chat/openai"; +import { audioContext, resetAudioContext } from "./tts/audio-file-player"; + +export interface Context { + authenticated: boolean; + sessionExpired: boolean; + chat: ChatManager; + user: User | null; + intl: IntlShape; + id: string | undefined | null; + currentChat: UseChatResult; + isHome: boolean; + isShare: boolean; + generating: boolean; + onNewMessage: (message?: string) => Promise<string | false>; + regenerateMessage: (message: Message) => Promise<boolean>; + editMessage: (message: Message, content: string) => Promise<boolean>; +} + +const AppContext = React.createContext<Context>({} as any); + +const chatManager = new ChatManager(); +const backend = new Backend(chatManager); + +let intl: IntlShape; + +export function useCreateAppContext(): Context { + const { id: _id } = useParams(); + const [nextID, setNextID] = useState(uuidv4()); + const id = _id ?? nextID; + + const dispatch = useAppDispatch(); + + intl = useIntl(); + + const { pathname } = useLocation(); + const isHome = pathname === '/'; + const isShare = pathname.startsWith('/s/'); + + const currentChat = useChat(chatManager, id, isShare); + const [authenticated, setAuthenticated] = useState(backend?.isAuthenticated || false); + const [wasAuthenticated, setWasAuthenticated] = useState(backend?.isAuthenticated || false); + + useEffect(() => { + chatManager.on('y-update', update => backend?.receiveYUpdate(update)) + }, []); + + const updateAuth = useCallback((authenticated: boolean) => { + setAuthenticated(authenticated); + if (authenticated && backend.user) { + chatManager.login(backend.user.email || backend.user.id); + } + if (authenticated) { + setWasAuthenticated(true); + localStorage.setItem('registered', 'true'); + } + }, []); + + useEffect(() => { + updateAuth(backend?.isAuthenticated || false); + backend?.on('authenticated', updateAuth); + return () => { + backend?.off('authenticated', updateAuth) + }; + }, [updateAuth]); + + const onNewMessage = useCallback(async (message?: string) => { + resetAudioContext(); + + if (isShare) { + return false; + } + + if (!message?.trim().length) { + return false; + } + + // const openaiApiKey = store.getState().apiKeys.openAIApiKey; + const openaiApiKey = chatManager.options.getOption<string>('openai', 'apiKey'); + + if (!openaiApiKey && !isProxySupported()) { + dispatch(openOpenAIApiKeyPanel()); + return false; + } + + const parameters: Parameters = { + model: chatManager.options.getOption<string>('parameters', 'model', id), + temperature: chatManager.options.getOption<number>('parameters', 'temperature', id), + }; + + if (id === nextID) { + setNextID(uuidv4()); + + const autoPlay = chatManager.options.getOption<boolean>('tts', 'autoplay'); + + if (autoPlay) { + const ttsService = chatManager.options.getOption<string>('tts', 'service'); + if (ttsService === 'web-speech') { + const utterance = new SpeechSynthesisUtterance('Generating'); + utterance.volume = 0; + speechSynthesis.speak(utterance); + } + } + } + + // if (chatManager.has(id)) { + // chatManager.sendMessage({ + // chatID: id, + // content: message.trim(), + // requestedParameters: { + // ...parameters, + // apiKey: openaiApiKey, + // }, + // parentID: currentChat.leaf?.id, + // }); + // } else { + // await chatManager.createChat(id); + + chatManager.sendMessage({ + chatID: id, + content: message.trim(), + requestedParameters: { + ...parameters, + apiKey: openaiApiKey, + }, + parentID: currentChat.leaf?.id, + }); + // } + + return id; + }, [dispatch, id, currentChat.leaf, isShare]); + + const regenerateMessage = useCallback(async (message: Message) => { + resetAudioContext(); + + if (isShare) { + return false; + } + + // const openaiApiKey = store.getState().apiKeys.openAIApiKey; + const openaiApiKey = chatManager.options.getOption<string>('openai', 'apiKey'); + + if (!openaiApiKey && !isProxySupported()) { + dispatch(openOpenAIApiKeyPanel()); + return false; + } + + const parameters: Parameters = { + model: chatManager.options.getOption<string>('parameters', 'model', id), + temperature: chatManager.options.getOption<number>('parameters', 'temperature', id), + }; + + await chatManager.regenerate(message, { + ...parameters, + apiKey: openaiApiKey, + }); + + return true; + }, [dispatch, isShare]); + + const editMessage = useCallback(async (message: Message, content: string) => { + resetAudioContext(); + + if (isShare) { + return false; + } + + if (!content?.trim().length) { + return false; + } + + // const openaiApiKey = store.getState().apiKeys.openAIApiKey; + const openaiApiKey = chatManager.options.getOption<string>('openai', 'apiKey'); + + if (!openaiApiKey && !isProxySupported()) { + dispatch(openOpenAIApiKeyPanel()); + return false; + } + + const parameters: Parameters = { + model: chatManager.options.getOption<string>('parameters', 'model', id), + temperature: chatManager.options.getOption<number>('parameters', 'temperature', id), + }; + + if (id && chatManager.has(id)) { + await chatManager.sendMessage({ + chatID: id, + content: content.trim(), + requestedParameters: { + ...parameters, + apiKey: openaiApiKey, + }, + parentID: message.parentID, + }); + } else { + const id = await chatManager.createChat(); + await chatManager.sendMessage({ + chatID: id, + content: content.trim(), + requestedParameters: { + ...parameters, + apiKey: openaiApiKey, + }, + parentID: message.parentID, + }); + } + + return true; + }, [dispatch, id, isShare]); + + const generating = currentChat?.messagesToDisplay?.length > 0 + ? !currentChat.messagesToDisplay[currentChat.messagesToDisplay.length - 1].done + : false; + + const context = useMemo<Context>(() => ({ + authenticated, + sessionExpired: !authenticated && wasAuthenticated, + id, + user: backend.user, + intl, + chat: chatManager, + currentChat, + isHome, + isShare, + generating, + onNewMessage, + regenerateMessage, + editMessage, + }), [authenticated, wasAuthenticated, generating, onNewMessage, regenerateMessage, editMessage, currentChat, id, isHome, isShare, intl]); + + return context; +} + +export function useAppContext() { + return React.useContext(AppContext); +} + +export function AppContextProvider(props: { children: React.ReactNode }) { + const context = useCreateAppContext(); + return <AppContext.Provider value={context}> + <TTSContextProvider> + {props.children} + </TTSContextProvider> + </AppContext.Provider>; +} \ No newline at end of file diff --git a/app/src/core/index.ts b/app/src/core/index.ts new file mode 100644 index 00000000..f47a2d5c --- /dev/null +++ b/app/src/core/index.ts @@ -0,0 +1,254 @@ +import { BroadcastChannel } from 'broadcast-channel'; +import EventEmitter from 'events'; +import { v4 as uuidv4 } from 'uuid'; +import { Chat, Message, Parameters, UserSubmittedMessage } from './chat/types'; +import * as Y from 'yjs'; +import { IndexeddbPersistence } from 'y-indexeddb'; +import { YChatDoc } from './chat/y-chat'; +import { loadFromPreviousVersion as loadSavedChatsFromPreviousVersion } from './chat/chat-persistance'; +import { Search } from './search'; +import { ReplyRequest } from './chat/create-reply'; +import { OptionsManager } from './options'; +import { Option } from './options/option'; +import { pluginMetadata } from './plugins/metadata'; +import { pluginRunner } from "./plugins/plugin-runner"; +import { createBasicPluginContext } from './plugins/plugin-context'; + +export const channel = new BroadcastChannel('chats'); + +export class ChatManager extends EventEmitter { + public doc!: YChatDoc; + private provider!: IndexeddbPersistence; + private search!: Search; + public options!: OptionsManager; + private username: string | null = "anonymous"; + + private activeReplies = new Map<string, ReplyRequest>(); + private changedIDs = new Set<string>(); + public lastReplyID: string | null = null; + + constructor() { + super(); + + this.setMaxListeners(1000); + + console.log('initializing chat manager'); + + this.doc = this.attachYDoc('anonymous'); + + loadSavedChatsFromPreviousVersion(this.doc) + .then(() => this.emit('update')); + + setInterval(() => this.emitChanges()); + + channel.onmessage = message => { + if (message.type === 'y-update') { + this.applyYUpdate(message.data); + } + }; + + (window as any).chat = this; + } + + public login(username: string) { + if (username && this.username !== username) { + this.username = username; + this.attachYDoc(username); + } + } + + private attachYDoc(username: string) { + console.log('attaching y-doc for ' + username); + + // detach current doc + const doc = this.doc as YChatDoc | undefined; + const provider = this.provider as IndexeddbPersistence | undefined; + doc?.removeAllListeners(); + + const pluginOptionsManager = this.options as OptionsManager | undefined; + pluginOptionsManager?.destroy(); + + // attach new doc + this.doc = new YChatDoc(); + this.doc.on('update', chatID => this.changedIDs.add(chatID)); + this.doc.root.on('update', (update, origin) => { + if (!(origin instanceof IndexeddbPersistence) && origin !== 'sync') { + this.emit('y-update', update); + channel.postMessage({ type: 'y-update', data: update }); + } else { + console.log("IDB/sync update"); + } + }); + this.search = new Search(this); + + // connect new doc to persistance, scoped to the current username + this.provider = new IndexeddbPersistence('chats:' + username, this.doc.root); + this.provider.whenSynced.then(() => { + this.doc.getChatIDs().map(id => this.emit(id)); + this.emit('update'); + }); + + this.options = new OptionsManager(this.doc, pluginMetadata); + this.options.on('update', (...args) => this.emit('plugin-options-update', ...args)); + + pluginRunner( + 'init', + pluginID => createBasicPluginContext(pluginID, this.options), + plugin => plugin.initialize(), + ); + + if (username !== 'anonymous') { + // import chats from the anonymous doc after signing in + provider?.whenSynced.then(() => { + if (doc) { + Y.applyUpdate(this.doc.root, Y.encodeStateAsUpdate(doc.root)); + setTimeout(() => provider.clearData(), 10 * 1000); + } + }); + } + + return this.doc; + } + + public applyYUpdate(update: Uint8Array) { + Y.applyUpdate(this.doc.root, update); + } + + private emitChanges() { + const ids = Array.from(this.changedIDs); + this.changedIDs.clear(); + + for (const id of ids) { + this.emit(id); + this.search.update(id); + } + + if (ids.length) { + this.emit('update'); + } + } + + public async sendMessage(userSubmittedMessage: UserSubmittedMessage) { + const chat = this.doc.getYChat(userSubmittedMessage.chatID); + + if (!chat) { + throw new Error('Chat not found'); + } + + const message: Message = { + id: uuidv4(), + parentID: userSubmittedMessage.parentID, + chatID: userSubmittedMessage.chatID, + timestamp: Date.now(), + role: 'user', + content: userSubmittedMessage.content, + done: true, + }; + + this.doc.addMessage(message); + + const messages: Message[] = this.doc.getMessagesPrecedingMessage(message.chatID, message.id); + messages.push(message); + + await this.getReply(messages, userSubmittedMessage.requestedParameters); + } + + public async regenerate(message: Message, requestedParameters: Parameters) { + const messages = this.doc.getMessagesPrecedingMessage(message.chatID, message.id); + await this.getReply(messages, requestedParameters); + } + + private async getReply(messages: Message[], requestedParameters: Parameters) { + const latestMessage = messages[messages.length - 1]; + const chatID = latestMessage.chatID; + const parentID = latestMessage.id; + const chat = this.doc.getYChat(latestMessage.chatID); + + if (!chat) { + throw new Error('Chat not found'); + } + + const message: Message = { + id: uuidv4(), + parentID, + chatID, + timestamp: Date.now(), + role: 'assistant', + model: requestedParameters.model, + content: '', + }; + + this.lastReplyID = message.id; + + this.doc.addMessage(message); + + const request = new ReplyRequest(this.get(chatID), chat, messages, message.id, requestedParameters, this.options); + request.on('done', () => this.activeReplies.delete(message.id)); + request.execute(); + + this.activeReplies.set(message.id, request); + } + + public cancelReply(chatID: string | undefined, id: string) { + this.activeReplies.get(id)?.onCancel(); + this.activeReplies.delete(id); + } + + public async createChat(id?: string): Promise<string> { + return this.doc.createYChat(id); + } + + public get(id: string): Chat { + return this.doc.getChat(id); + } + + public has(id: string) { + return this.doc.has(id); + } + + public all(): Chat[] { + return this.doc.getChatIDs().map(id => this.get(id)); + } + + public deleteChat(id: string, broadcast = true) { + this.doc.delete(id); + this.search.delete(id); + } + + public searchChats(query: string) { + return this.search.query(query); + } + + public getPluginOptions(chatID?: string) { + const pluginOptions: Record<string, Record<string, any>> = {}; + + for (const description of pluginMetadata) { + pluginOptions[description.id] = this.options.getAllOptions(description.id, chatID); + } + + return pluginOptions; + } + + public setPluginOption(pluginID: string, optionID: string, value: any, chatID?: string) { + this.options.setOption(pluginID, optionID, value, chatID); + } + + public resetPluginOptions(pluginID: string, chatID?: string | null) { + this.options.resetOptions(pluginID, chatID); + } + + public getQuickSettings(): Array<{ groupID: string, option: Option }> { + const options = this.options.getAllOptions('quick-settings'); + return Object.keys(options) + .filter(key => options[key]) + .map(key => { + const groupID = key.split('--')[0]; + const optionID = key.split('--')[1]; + return { + groupID, + option: this.options.findOption(groupID, optionID)!, + }; + }) + .filter(o => !!o.option); + } +} \ No newline at end of file diff --git a/app/src/core/options/index.ts b/app/src/core/options/index.ts new file mode 100644 index 00000000..b2755615 --- /dev/null +++ b/app/src/core/options/index.ts @@ -0,0 +1,209 @@ +import { EventEmitter } from "events"; +import { PluginDescription } from "../plugins/plugin-description"; +import { Option } from "./option"; +import { YChat, YChatDoc } from "../chat/y-chat"; +import { globalOptions } from "../../global-options"; +import { OptionGroup } from "./option-group"; +import { BroadcastChannel } from "broadcast-channel"; + +export const broadcastChannel = new BroadcastChannel("options"); + +function cacheKey(groupID: string, optionID: string, chatID?: string | null) { + return chatID ? `${chatID}.${groupID}.${optionID}` : `${groupID}.${optionID}`; +} + +export class OptionsManager extends EventEmitter { + private optionGroups: OptionGroup[]; + private optionsCache: Map<string, any> = new Map(); + + constructor(private yDoc: YChatDoc, private pluginMetadata: PluginDescription[]) { + super(); + + this.optionGroups = [...globalOptions, ...this.pluginMetadata]; + + // Load options from localStorage and YChats + this.loadOptions(); + + // Listen for update events on the broadcast channel + broadcastChannel.onmessage = (event: MessageEvent) => { + this.loadOptions(); + + if (event.data?.groupID) { + this.emit('update', event.data.groupID); + } + }; + } + + private loadOption(groupID: string, option: Option, yChat?: YChat) { + if (option.scope === "chat") { + const key: string = cacheKey(groupID, option.id, yChat?.id); + let value: string | undefined | null; + if (yChat) { + value = yChat.getOption(groupID, option.id); + } + + // Fallback to localStorage if value is not found in YChat + if (typeof value === 'undefined' || value === null) { + const fallbackKey = cacheKey(groupID, option.id); + const raw = localStorage.getItem(fallbackKey); + value = raw ? JSON.parse(raw) : option.defaultValue; + } + + this.optionsCache.set(key, value); + } else if (option.scope === "user") { + const key = cacheKey(groupID, option.id); + const value = this.yDoc.getOption(groupID, option.id) || option.defaultValue; + this.optionsCache.set(key, value); + } else { + const key = cacheKey(groupID, option.id); + const raw = localStorage.getItem(key); + const value = raw ? JSON.parse(raw) : option.defaultValue; + this.optionsCache.set(key, value); + } + } + + private loadOptions() { + // Load browser and user-scoped options + this.optionGroups.forEach(group => { + group.options.forEach(option => { + this.loadOption(group.id, option); + }); + }); + + // Load chat-scoped options from YChats + this.yDoc.getChatIDs().forEach(chatID => { + const yChat = this.yDoc.getYChat(chatID)!; + this.optionGroups.forEach(group => { + group.options.forEach(option => { + if (option.scope === "chat") { + this.loadOption(group.id, option, yChat); + } + }); + }); + }); + + (window as any).options = this; + + this.emit("update"); + } + + public resetOptions(groupID: string, chatID?: string | null) { + console.log(`resetting ${groupID} options with chatID = ${chatID}`); + + const group = this.optionGroups.find(group => group.id === groupID); + + group?.options.forEach(option => { + if (option.resettable) { + this.setOption(group.id, option.id, option.defaultValue, option.scope === 'chat' ? chatID : null); + } + }); + } + + public getAllOptions(groupID: string, chatID?: string | null): Record<string, any> { + const options: Record<string, any> = {}; + + const group = this.optionGroups.find(group => group.id === groupID); + + group?.options.forEach(option => { + options[option.id] = this.getOption(groupID, option.id, chatID); + }); + + return options; + } + + public getOption<T=any>(groupID: string, optionID: string, chatID?: string | null, validate = false): T { + const option = this.findOption(groupID, optionID); + if (!option) { + throw new Error(`option not found (group = ${groupID}), option = ${optionID}`); + } + + const key = cacheKey(groupID, optionID, option.scope === 'chat' ? chatID : null); + let value = this.optionsCache.get(key); + + if (typeof value !== 'undefined' && value !== null) { + if (validate) { + const valid = !option.validate || option.validate(value, this); + if (valid) { + return value; + } + } else { + return value; + } + } + + const fallbackKey = cacheKey(groupID, optionID); + value = this.optionsCache.get(fallbackKey); + + if (typeof value !== 'undefined' && value !== null) { + if (validate) { + const valid = !option.validate || option.validate(value, this); + if (valid) { + return value; + } + } else { + return value; + } + } + + return option.defaultValue; + } + + public getValidatedOption(groupID: string, optionID: string, chatID?: string | null): any { + return this.getOption(groupID, optionID, chatID, true); + } + + public setOption(groupID: string, optionID: string, value: any, chatID?: string | null) { + const option = this.findOption(groupID, optionID); + + if (!option) { + console.warn(`option not found (group = ${groupID}), option = ${optionID}`); + return; + } + + const key = cacheKey(groupID, optionID, option.scope === 'chat' ? chatID : null); + + value = value ?? null; + + if (option.scope === "chat") { + if (!chatID) { + console.warn(`cannot set option for chat without chatID (group = ${groupID}), option = ${optionID}, chatID = ${chatID}`); + return; + } + const yChat = this.yDoc.getYChat(chatID); + yChat?.setOption(groupID, optionID, value); + + const fallbackKey = cacheKey(groupID, optionID); + localStorage.setItem(fallbackKey, JSON.stringify(value)); + } else if (option.scope === 'user') { + this.yDoc.setOption(groupID, optionID, value); + } else { + localStorage.setItem(key, JSON.stringify(value)); + } + + console.log(`setting ${groupID}.${optionID} = ${value} (${typeof value})`) + + // Update cache and emit update event + this.optionsCache.set(key, value); + this.emit("update", groupID, optionID); + + // Notify other tabs through the broadcast channel + broadcastChannel.postMessage({ groupID, optionID }); + } + + public findOption(groupID: string, optionID: string): Option | undefined { + const group = this.optionGroups.find(group => group.id === groupID); + const option = group?.options.find(option => option.id === optionID); + + if (option) { + return option; + } + + console.warn("couldn't find option " + groupID + "." + optionID); + return undefined; + } + + public destroy() { + this.removeAllListeners(); + broadcastChannel.onmessage = null; + } +} diff --git a/app/src/core/options/option-group.tsx b/app/src/core/options/option-group.tsx new file mode 100644 index 00000000..6edc45d6 --- /dev/null +++ b/app/src/core/options/option-group.tsx @@ -0,0 +1,20 @@ +import { Option } from "./option"; +import type { OptionsManager } from "."; +import { ReactNode } from "react"; + +/** + * @interface OptionGroup + * @description Represents a group of options within the OptionsManager. Each group is identified by a unique ID and can have a name, description, and a set of options. The group can be hidden based on a boolean value or a function that evaluates the visibility condition using the OptionsManager instance. + * @property {string} id - The unique identifier for the option group. + * @property {string} [name] - The display name for the option group. + * @property {string | ReactNode} [description] - A description for the option group, which can be a string or a ReactNode. + * @property {boolean | ((options: OptionsManager) => boolean)} [hidden] - Determines if the option group should be hidden. Can be a boolean value or a function that returns a boolean value based on the OptionsManager instance. + * @property {Option[]} options - An array of options within the group. + */ +export interface OptionGroup { + id: string; + name?: string; + description?: string | ReactNode; + hidden?: boolean | ((options: OptionsManager) => boolean); + options: Option[]; +} diff --git a/app/src/core/options/option.ts b/app/src/core/options/option.ts new file mode 100644 index 00000000..60bcb593 --- /dev/null +++ b/app/src/core/options/option.ts @@ -0,0 +1,34 @@ +import type { OptionsManager } from "."; +import { Context } from "../context"; +import { RenderProps, RenderPropsBuilder } from "./render-props"; + +/** + * Represents an option in the settings UI. + * @typedef {Object} Option + * @property {string} id - The unique identifier for the option. + * @property {any} defaultValue - The default value for the option. + * @property {'speech' | 'chat' | 'user'} tab - The tab ID in the settings UI where the option will be displayed. + * @property {boolean} [resettable] - Whether the option can be reset to its default value. + * @property {'chat' | 'user' | 'browser'} [scope] - Determines how the option value is saved (browser = local storage, user = synced to the user's account across devices, chat = saved for specific chat). + * @property {boolean} [displayAsSeparateSection] - Whether the option should be displayed inline in the settings UI or as a 'block' with a heading and separate section. + * @property {RenderProps | RenderPropsBuilder} renderProps - Customizes the appearance of the option's UI in the settings UI, and can see other options and app state. + * @property {(value: any, options: OptionsManager) => boolean} [validate] - If this function returns false, the defaultValue will be used instead. + */ +export interface Option { + id: string; + defaultValue: any; + scope?: 'chat' | 'user' | 'browser'; + + displayOnSettingsScreen: 'speech' | 'chat' | 'plugins' | 'ui' | 'user'; + displayAsSeparateSection?: boolean; + resettable?: boolean; + + renderProps: RenderProps | RenderPropsBuilder; + validate?: (value: any, options: OptionsManager) => boolean; + + displayInQuickSettings?: { + name: string; + displayByDefault?: boolean; + label: string | ((value: any, options: OptionsManager, context: Context) => string); + }; +} diff --git a/app/src/core/options/render-props.ts b/app/src/core/options/render-props.ts new file mode 100644 index 00000000..38362963 --- /dev/null +++ b/app/src/core/options/render-props.ts @@ -0,0 +1,41 @@ +import type { OptionsManager } from "."; +import type { Context } from "../context"; + +/** + * Represents the properties used to render an option in the settings UI. + * @typedef {Object} RenderProps + * @property {'text' | 'textarea' | 'select' | 'number' | 'slider' | 'checkbox'} type - The type of input for the option. + * @property {any} [label] - The label for the option. + * @property {any} [description] - The description for the option. + * @property {any} [placeholder] - The placeholder for the option. + * @property {boolean} [disabled] - Whether the option is disabled in the settings UI. + * @property {boolean} [hidden] - Whether the option is hidden in the settings UI. + * @property {number} [step] - The step value for number and slider inputs. + * @property {number} [min] - The minimum value for number and slider inputs. + * @property {number} [max] - The maximum value for number and slider inputs. + * @property {Array<{ label: string; value: string; }>} [options] - The options for the select input. + */ +export interface RenderProps { + type: 'text' | 'textarea' | 'select' | 'number' | 'slider' | 'checkbox' | 'password'; + + label?: any; + description?: any; + placeholder?: any; + + disabled?: boolean; + hidden?: boolean; + + // Number and slider input properties + step?: number; + min?: number; + max?: number; + + // Select input options property + options?: Array<{ label: string; value: string; }>; +} + +/** + * Represents a function that builds RenderProps based on the current value, options, and context. + * @typedef {(value: any, options: OptionsManager, context: Context) => RenderProps} RenderPropsBuilder + */ +export type RenderPropsBuilder = ((value: any, options: OptionsManager, context: Context) => RenderProps); \ No newline at end of file diff --git a/app/src/core/options/use-option.ts b/app/src/core/options/use-option.ts new file mode 100644 index 00000000..a601afb8 --- /dev/null +++ b/app/src/core/options/use-option.ts @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Context, useAppContext } from "../context"; +import { RenderProps } from "./render-props"; + +export function useOption<T=any>(groupID: string, optionID: string, chatID?: string): [T, (value: T) => void, RenderProps, number] { + const context = useAppContext(); + + const [value, setValue] = useState(context.chat.options.getValidatedOption(groupID, optionID, chatID)); + const [version, setVersion] = useState(0); + + const timer = useRef<any>(); + + const onUpdate = useCallback((updatedGroupID: string) => { + if (groupID === updatedGroupID) { + setValue(context.chat.options.getValidatedOption(groupID, optionID, chatID)); + setVersion(v => v + 1); + } else { + clearTimeout(timer.current); + timer.current = setTimeout(() => { + setValue(context.chat.options.getValidatedOption(groupID, optionID, chatID)); + setVersion(v => v + 1); + }, 500); + } + }, [groupID, optionID, chatID]); + + useEffect(() => { + context.chat.on('plugin-options-update', onUpdate); + return () => { + context.chat.off('plugin-options-update', onUpdate); + }; + }, [chatID, onUpdate]); + + const setOptionValue = useCallback((value: any) => { + context.chat.options.setOption(groupID, optionID, value, chatID); + }, [groupID, optionID, chatID]); + + const option = context.chat.options.findOption(groupID, optionID)!; + + return [ + value, + setOptionValue, + typeof option.renderProps === 'function' ? option.renderProps(value, context.chat.options, context) : option.renderProps, + version, + ]; +} \ No newline at end of file diff --git a/app/src/core/plugins/command.ts b/app/src/core/plugins/command.ts new file mode 100644 index 00000000..9d4d7450 --- /dev/null +++ b/app/src/core/plugins/command.ts @@ -0,0 +1,10 @@ +import { Context } from "../context"; +import { OptionsManager } from "../options"; + +export interface Command { + name: string; + params: Array<{ name: string, type: string }> + returnType: string; + run: any; + disabled?: (options: OptionsManager, context: Context) => boolean; +} \ No newline at end of file diff --git a/app/src/core/plugins/index.ts b/app/src/core/plugins/index.ts new file mode 100644 index 00000000..5872ee6a --- /dev/null +++ b/app/src/core/plugins/index.ts @@ -0,0 +1,30 @@ +import { OpenAIMessage, Parameters } from "../chat/types"; +import { PluginContext } from "./plugin-context"; +import { PluginDescription } from "./plugin-description"; + +export default class Plugin<T=any> { + constructor(public context?: PluginContext) { + } + + async initialize() { + } + + describe(): PluginDescription { + throw new Error('not implemented'); + } + + get options(): T | undefined { + return this.context?.getOptions(); + } + + async preprocessModelInput(messages: OpenAIMessage[], parameters: Parameters): Promise<{ + messages: OpenAIMessage[], + parameters: Parameters, + }> { + return { messages, parameters }; + } + + async postprocessModelOutput(message: OpenAIMessage, context: OpenAIMessage[], parameters: Parameters, done: boolean): Promise<OpenAIMessage> { + return message; + } +} \ No newline at end of file diff --git a/app/src/core/plugins/metadata.ts b/app/src/core/plugins/metadata.ts new file mode 100644 index 00000000..0c0fdc38 --- /dev/null +++ b/app/src/core/plugins/metadata.ts @@ -0,0 +1,15 @@ +import type { PluginDescription } from "./plugin-description"; +import TTSPlugin from "../tts/tts-plugin"; +import { registeredPlugins } from "../../plugins"; + +export const pluginMetadata: Array<PluginDescription> = registeredPlugins.map(p => new p().describe()); +export const pluginIDs: string[] = pluginMetadata.map(d => d.id); + +export const ttsPlugins = registeredPlugins.filter(p => { + const instance = new p(); + return instance instanceof TTSPlugin; +}); + +export function getPluginByName(name: string) { + return registeredPlugins.find(p => new p().describe().name === name); +} diff --git a/app/src/core/plugins/plugin-context.ts b/app/src/core/plugins/plugin-context.ts new file mode 100644 index 00000000..c755547c --- /dev/null +++ b/app/src/core/plugins/plugin-context.ts @@ -0,0 +1,18 @@ +import { Chat, OpenAIMessage, Parameters } from "../chat/types"; +import { OptionsManager } from "../options"; + +export interface PluginContext { + getOptions(): any; + getCurrentChat(): Chat; + createChatCompletion(messages: OpenAIMessage[], parameters: Parameters): Promise<string>; + setChatTitle(title: string): Promise<void>; +} + +export function createBasicPluginContext(pluginID: string, pluginOptions: OptionsManager, chatID?: string | null, chat?: Chat | null) { + return { + getOptions: (_pluginID = pluginID) => pluginOptions.getAllOptions(_pluginID, chatID), + getCurrentChat: () => chat, + createChatCompletion: async () => '', + setChatTitle: async (title: string) => { }, + } as PluginContext; +} diff --git a/app/src/core/plugins/plugin-description.ts b/app/src/core/plugins/plugin-description.ts new file mode 100644 index 00000000..8ae1cabe --- /dev/null +++ b/app/src/core/plugins/plugin-description.ts @@ -0,0 +1,9 @@ +import type { Command } from "./command"; +import type { OptionGroup } from "../options/option-group"; + + +export interface PluginDescription extends OptionGroup { + name: string; + commands?: Command[]; + category?: "internal" | "knowledge-sources" | "tts"; +} diff --git a/app/src/core/plugins/plugin-runner.ts b/app/src/core/plugins/plugin-runner.ts new file mode 100644 index 00000000..348acef1 --- /dev/null +++ b/app/src/core/plugins/plugin-runner.ts @@ -0,0 +1,24 @@ +import type { PluginContext } from "./plugin-context"; +import type Plugin from "."; +import { pluginMetadata } from "./metadata"; +import { registeredPlugins } from "../../plugins"; + +export async function pluginRunner(name: string, pluginContext: (pluginID: string) => PluginContext, callback: (p: Plugin<any>) => Promise<any>) { + const startTime = Date.now(); + + for (let i = 0; i < registeredPlugins.length; i++) { + const description = pluginMetadata[i]; + + const impl = registeredPlugins[i]; + const plugin = new impl(pluginContext(description.id)); + + try { + await callback(plugin); + } catch (e) { + console.warn(`[plugins:${name}] error in ` + description.name, e); + } + } + + const runtime = Date.now() - startTime; + // console.log(`[plugins:${name}] ran all plugins in ${runtime.toFixed(1)} ms`); +} diff --git a/app/src/core/search.ts b/app/src/core/search.ts new file mode 100644 index 00000000..8e7aa672 --- /dev/null +++ b/app/src/core/search.ts @@ -0,0 +1,90 @@ +import MiniSearch, { SearchResult } from 'minisearch' +import { ellipsize } from './utils'; +import { ChatManager } from '.'; +import { Chat, Message } from './chat/types'; + +export class Search { + private index = new MiniSearch({ + fields: ['value'], + storeFields: ['id', 'value'], + }); + + constructor(private context: ChatManager) { + } + + public update(id: string) { + const chat = this.context.get(id); + if (!chat) { + return; + } + const messages = chat.messages.serialize(); + const contents = messages.map((m: Message) => m.content).join('\n\n'); + const doc = { + id, + value: chat.title ? (chat.title + '\n\n' + contents) : contents, + }; + if (!this.index.has(id)) { + this.index.add(doc); + } else { + this.index.replace(doc); + } + } + + public delete(id: string) { + if (this.index.has(id)) { + this.index.discard(id); + this.index.vacuum(); + } + } + + public query(query: string) { + if (!query?.trim().length) { + const searchResults = this.context.all() + .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; + let chat = this.context.get(chatID); + if (!chat) { + continue; + } + + chat = { ...chat }; + + let description = chat.messages?.first?.content || ''; + description = ellipsize(description, 400); + + if (!chat.title) { + chat.title = ellipsize(description, 100); + } + + if (!chat.title || !description) { + continue; + } + + output.push({ + chatID, + title: chat.title, + description, + }); + } + return output; + } +} diff --git a/app/src/speech-recognition-types.ts b/app/src/core/speech-recognition-types.ts similarity index 100% rename from app/src/speech-recognition-types.ts rename to app/src/core/speech-recognition-types.ts diff --git a/app/src/tokenizer/bpe.ts b/app/src/core/tokenizer/bpe.ts similarity index 100% rename from app/src/tokenizer/bpe.ts rename to app/src/core/tokenizer/bpe.ts diff --git a/app/src/tokenizer/chat-history-trimmer.ts b/app/src/core/tokenizer/chat-history-trimmer.ts similarity index 99% rename from app/src/tokenizer/chat-history-trimmer.ts rename to app/src/core/tokenizer/chat-history-trimmer.ts index a019feba..7081f81b 100644 --- a/app/src/tokenizer/chat-history-trimmer.ts +++ b/app/src/core/tokenizer/chat-history-trimmer.ts @@ -1,4 +1,4 @@ -import { OpenAIMessage } from '../types'; +import { OpenAIMessage } from '../chat/types'; import * as tokenizer from '.'; export interface ChatHistoryTrimmerOptions { diff --git a/app/src/tokenizer/cl100k_base.json b/app/src/core/tokenizer/cl100k_base.json similarity index 100% rename from app/src/tokenizer/cl100k_base.json rename to app/src/core/tokenizer/cl100k_base.json diff --git a/app/src/tokenizer/index.ts b/app/src/core/tokenizer/index.ts similarity index 96% rename from app/src/tokenizer/index.ts rename to app/src/core/tokenizer/index.ts index 2be6759e..e07998d7 100644 --- a/app/src/tokenizer/index.ts +++ b/app/src/core/tokenizer/index.ts @@ -1,4 +1,4 @@ -import { OpenAIMessage } from "../types"; +import { OpenAIMessage } from "../chat/types"; import { CoreBPE, RankMap } from "./bpe"; import ranks from './cl100k_base.json'; @@ -18,7 +18,6 @@ for (const text of Object.keys(special_tokens)) { const pattern = /('s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{1,3}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+/giu; const tokenizer = new CoreBPE(RankMap.from(ranks), special_tokens_map, pattern); -(window as any).tokenizer = tokenizer; const overheadTokens = { perMessage: 5, diff --git a/app/src/core/tokenizer/worker.ts b/app/src/core/tokenizer/worker.ts new file mode 100644 index 00000000..3fa81f3d --- /dev/null +++ b/app/src/core/tokenizer/worker.ts @@ -0,0 +1,16 @@ +import * as methods from "."; +import { OpenAIMessage } from "../chat/types"; +import { ChatHistoryTrimmer, ChatHistoryTrimmerOptions } from "./chat-history-trimmer"; + +export function runChatTrimmer(messages: OpenAIMessage[], options: ChatHistoryTrimmerOptions) { + const trimmer = new ChatHistoryTrimmer(messages, options); + return trimmer.process(); +} + +export function countTokensForText(text: string) { + return methods.countTokensForText(text); +} + +export function countTokensForMessages(messages: OpenAIMessage[]) { + return methods.countTokensForMessages(messages); +} \ No newline at end of file diff --git a/app/src/core/tokenizer/wrapper.ts b/app/src/core/tokenizer/wrapper.ts new file mode 100644 index 00000000..2dfa6367 --- /dev/null +++ b/app/src/core/tokenizer/wrapper.ts @@ -0,0 +1,28 @@ +import { OpenAIMessage } from "../chat/types"; +import type { ChatHistoryTrimmerOptions } from "./chat-history-trimmer"; +// @ts-ignore +import tokenizer from 'workerize-loader!./worker'; + +let worker: any; + +async function getWorker() { + if (!worker) { + worker = await tokenizer(); + } + return worker; +} + +export async function runChatTrimmer(messages: OpenAIMessage[], options: ChatHistoryTrimmerOptions): Promise<OpenAIMessage[]> { + const worker = await getWorker(); + return worker.runChatTrimmer(messages, options); +} + +export async function countTokens(messages: OpenAIMessage[]) { + const worker = await getWorker(); + return await worker.countTokensForMessages(messages); +} + +// preload the worker +getWorker().then(w => { + (window as any).worker = w; +}) \ No newline at end of file diff --git a/app/src/core/tts/audio-file-player.ts b/app/src/core/tts/audio-file-player.ts new file mode 100644 index 00000000..7610d920 --- /dev/null +++ b/app/src/core/tts/audio-file-player.ts @@ -0,0 +1,286 @@ +import { AbstractTTSPlayer, TTSPlayerState } from './types'; +import { cloneArrayBuffer, md5, sleep } from '../utils'; +import { AsyncLoop } from "../utils/async-loop"; +import * as idb from '../utils/idb'; +import TTSPlugin from './tts-plugin'; + +export let audioContext = new AudioContext(); +export let audioContextInUse = false; + +export function resetAudioContext() { + if (audioContextInUse) { + const previousAudioContext = audioContext; + audioContext = new AudioContext(); + audioContextInUse = false; + setTimeout(() => previousAudioContext.close(), 0); + setTimeout(() => audioContext.suspend(), 100); + } + audioContext.resume(); +} + +const cache = new Map<string, ArrayBuffer>(); + +async function getAudioFile(plugin: TTSPlugin<any>, text: string) { + const voice = await plugin.getCurrentVoice(); + + const hash = await md5(text); + const cacheKey = `audio:${voice?.service}:${voice?.id}:${hash}`; + + let buffer = cache.get(cacheKey); + + if (!buffer) { + buffer = await idb.get(cacheKey); + } + + if (!buffer) { + try { + const result = await plugin.speakToBuffer(text); + if (result) { + buffer = result; + cache.set(cacheKey, cloneArrayBuffer(buffer)); + idb.set(cacheKey, cloneArrayBuffer(buffer)); + return buffer; + } + } catch (e) { + console.error(e); + } + } + + return buffer || null; +} + +export default class ExternalTTSAudioFilePlayer extends AbstractTTSPlayer { + private playing = true; + private ended = false; + private requestedSentenceIndex = 0; // sentence index requested by user + private currentSentenceIndex = 0; + private startTime = 0; + + private audioArrayBuffers: ArrayBuffer[] = []; + + private downloadLoop: AsyncLoop; + private schedulerLoop: AsyncLoop; + + private sourceNodes: AudioBufferSourceNode[] = []; + private durations: number[] = []; + private duration = 0; + + private destroyed = false; + + constructor(private plugin: TTSPlugin) { + super(); + + this.downloadLoop = new AsyncLoop(this.download, 1000); + this.schedulerLoop = new AsyncLoop(this.schedule, 100); + + audioContext.resume(); + + requestAnimationFrame(async () => { + audioContext.suspend(); + + this.downloadLoop.start(); + this.schedulerLoop.start(); + }); + + (window as any).player = this; + } + + private download = async () => { + const sentences = [...this.sentences]; + if (!this.complete) { + sentences.pop(); + } + + const maxSentencesToDownload = this.sourceNodes[this.currentSentenceIndex] ? 2 : 1; + + const sentencesToDownload: number[] = []; + for (let i = 0; i < sentences.length; i++) { + if (sentencesToDownload.length >= maxSentencesToDownload) { + break; + } + if (!this.audioArrayBuffers[i]) { + sentencesToDownload.push(i); + } + } + + const files = await Promise.all(sentencesToDownload.map(async sentenceIndex => { + try { + const text = sentences[sentenceIndex]; + return await getAudioFile(this.plugin, text); + } catch (e) { + console.warn('error downloading tts audio', e); + } + })); + + for (let i = 0; i < sentencesToDownload.length; i++) { + const sentenceIndex = sentencesToDownload[i]; + const file = files[i]; + if (file) { + this.audioArrayBuffers[sentenceIndex] = file; + } else { + await sleep(5000); // back off + } + } + + this.emit('state', this.getState()); + } + + private schedule = async () => { + let time = this.startTime; + + if (this.playing && this.sourceNodes[this.currentSentenceIndex] && audioContext.state === 'suspended') { + + try { + await this.resumeAudioContext(); + } catch (e: any) { + console.error(e); + } + } + + try { + for (let i = this.requestedSentenceIndex; i < this.sentences.length; i++) { + if (this.destroyed) { + return; + } + + const audioArrayBuffer = this.audioArrayBuffers[i]; + + if (!audioArrayBuffer) { + break; + } + + if (!this.sourceNodes[i]) { + const audioBuffer = await audioContext.decodeAudioData(cloneArrayBuffer(audioArrayBuffer)); + this.durations[i] = audioBuffer.duration; + + const sourceNode = audioContext.createBufferSource(); + sourceNode.buffer = audioBuffer; + + if (i === this.requestedSentenceIndex) { + this.startTime = audioContext.currentTime; + time = this.startTime; + } + + sourceNode.start(time); + this.duration = time + this.durations[i] - this.startTime; + audioContextInUse = true; + + this.sourceNodes[i] = sourceNode; + + sourceNode.connect(audioContext.destination); + + if (this.playing) { + await this.resumeAudioContext(); + } + + sourceNode.onended = async () => { + if (this.destroyed) { + return; + } + + this.currentSentenceIndex = i + 1; + + this.ended = this.complete && this.currentSentenceIndex === this.sentences.length; + const isBuffering = !this.ended && !this.sourceNodes[this.currentSentenceIndex]; + + if (this.ended || isBuffering) { + await this.suspendAudioContext(); + } + + if (this.ended) { + this.playing = false; + } + + this.emit('state', this.getState()); + }; + + this.emit('state', this.getState()); + } + + time += this.durations[i] + 0.25; + } + } catch (e: any) { + console.error(e); + } + } + + private async resumeAudioContext() { + try { + audioContext.resume(); + await sleep(10); + } catch (e) { + console.warn('error resuming audio context', e); + } + } + + private async suspendAudioContext() { + try { + await audioContext.suspend(); + } catch (e) { + console.warn('error suspending audio context', e); + } + } + + public getState(): TTSPlayerState { + return { + playing: this.playing, + ended: this.ended, + buffering: this.playing && !this.ended && !this.sourceNodes[this.currentSentenceIndex], + duration: this.duration, + length: this.sentences.length, + ready: this.audioArrayBuffers.filter(Boolean).length, + index: this.currentSentenceIndex, + downloadable: this.complete && this.sourceNodes.length === this.sentences.length, + } as any; + } + + public async pause() { + this.playing = false; + await this.suspendAudioContext(); + this.emit('state', this.getState()); + } + + public async play(index?: number) { + this.playing = true; + + if (typeof index === 'number') { + this.requestedSentenceIndex = index; + this.currentSentenceIndex = index; + + resetAudioContext(); + + if (this.sourceNodes.length) { + resetAudioContext(); + + this.sourceNodes = []; + this.durations = []; + this.duration = 0; + + this.ended = false; + } + } else if (this.ended) { + await this.play(0); + } else if (audioContext.currentTime < this.duration) { + await this.resumeAudioContext(); + } else { + await this.play(Math.max(0, this.sourceNodes.length - 1)); + } + this.emit('state', this.getState()); + } + + public destroy() { + this.playing = false; + this.destroyed = true; + + this.downloadLoop.cancelled = true; + this.schedulerLoop.cancelled = true; + + resetAudioContext(); + + this.sourceNodes = []; + this.durations = []; + this.duration = 0; + + this.removeAllListeners(); + } +} diff --git a/app/src/core/tts/direct-tts-player.ts b/app/src/core/tts/direct-tts-player.ts new file mode 100644 index 00000000..aa9d2b18 --- /dev/null +++ b/app/src/core/tts/direct-tts-player.ts @@ -0,0 +1,120 @@ +import DirectTTSPlugin from "./direct-tts-plugin"; +import { AsyncLoop } from "../utils/async-loop"; +import { AbstractTTSPlayer } from "./types"; +import WebSpeechPlugin from "../../tts-plugins/web-speech"; + +export default class DirectTTSPlayer extends AbstractTTSPlayer { + playing = false; + ended = false; + + private loop: AsyncLoop; + private currentIndex = 0; + private currentPlaybackIndex = 0; + + private promises: any[] = []; + + constructor(private plugin: WebSpeechPlugin) { + super(); + console.log('tts init, directttsplayer'); + + this.emit('state', this.getState()); + + this.loop = new AsyncLoop(() => this.tick(), 100); + this.loop.start(); + } + + private async tick() { + if (!this.playing) { + return; + } + + const sentences = [...this.sentences]; + if (!this.complete) { + sentences.pop(); + } + + if (this.currentPlaybackIndex >= sentences.length) { + if (this.complete) { + console.log(`tts finished 1, current index: ${this.currentPlaybackIndex}, sentences length: ${sentences.length}`); + try { + await Promise.all(this.promises); + } catch (e) { + console.error('an error occured while reading text aloud', e); + } + console.log(`tts finished 2, current index: ${this.currentPlaybackIndex}, sentences length: ${sentences.length}`); + this.playing = false; + this.ended = true; + this.currentIndex = 0; + this.currentPlaybackIndex = 0; + this.promises = []; + this.emit('state', this.getState()); + return; + } + } + + if (this.currentIndex >= sentences.length) { + return; + } + + this.ended = false; + + try { + this.emit('state', this.getState()); + const text = sentences[this.currentIndex]; + console.log(`tts speaking`, text); + const p = this.plugin.speak(text); + p.then(() => { + this.currentPlaybackIndex = this.currentIndex + 1; + }); + this.promises.push(p); + this.currentIndex += 1; + } catch (e) { + console.error('an error occured while reading text aloud', e); + } + } + + async play(index?: number): Promise<any> { + if (this.playing) { + await this.plugin.stop(); + this.promises = []; + } + + this.playing = true; + this.ended = false; + + if (typeof index === 'number') { + this.currentIndex = index; + this.currentPlaybackIndex = index; + } + + await this.plugin.resume(); + + this.emit('state', this.getState()); + } + + async pause(): Promise<any> { + await this.plugin.pause(); + this.playing = false; + this.emit('state', this.getState()); + } + + getState() { + return { + playing: this.playing, + ended: this.ended, + buffering: this.playing && !this.plugin.isSpeaking(), + index: this.currentPlaybackIndex, + length: this.sentences.length, + downloadable: false, + } + } + + async destroy() { + if (this.playing) { + this.plugin.stop(); + } + this.loop.cancelled = true; + this.playing = false; + this.removeAllListeners(); + } +} \ No newline at end of file diff --git a/app/src/core/tts/direct-tts-plugin.ts b/app/src/core/tts/direct-tts-plugin.ts new file mode 100644 index 00000000..d4cb35a8 --- /dev/null +++ b/app/src/core/tts/direct-tts-plugin.ts @@ -0,0 +1,10 @@ +import { Voice } from "./types"; +import TTSPlugin from "./tts-plugin"; + +export default class DirectTTSPlugin<T=any> extends TTSPlugin<T> { + async speak(text: string, voice?: Voice) { + } + + async stop() { + } +} \ No newline at end of file diff --git a/app/src/core/tts/tts-plugin.ts b/app/src/core/tts/tts-plugin.ts new file mode 100644 index 00000000..03813a70 --- /dev/null +++ b/app/src/core/tts/tts-plugin.ts @@ -0,0 +1,16 @@ +import Plugin from "../plugins"; +import { Voice } from "../tts/types"; + +export default class TTSPlugin<T=any> extends Plugin<T> { + async getVoices(): Promise<Voice[]> { + return []; + } + + async getCurrentVoice(): Promise<Voice> { + throw new Error("not implemented"); + } + + async speakToBuffer(text: string, voice?: Voice): Promise<ArrayBuffer | null | undefined> { + throw new Error("not implemented"); + } +} \ No newline at end of file diff --git a/app/src/core/tts/types.ts b/app/src/core/tts/types.ts new file mode 100644 index 00000000..3f2e9849 --- /dev/null +++ b/app/src/core/tts/types.ts @@ -0,0 +1,48 @@ +import EventEmitter from "events"; +import { split } from "sentence-splitter"; + +export interface TTSPlayerState { + playing: boolean; + ended: boolean; + buffering: boolean; + duration?: number; + index: number; + length: number; + ready?: number; + downloadable: boolean; +} + +export abstract class AbstractTTSPlayer extends EventEmitter { + private lines: string[] = []; + protected sentences: string[] = []; + protected complete = false; + + abstract play(index?: number): Promise<any>; + abstract pause(): Promise<any>; + abstract getState(): TTSPlayerState; + abstract destroy(): any; + + public setText(lines: string[], complete: boolean) { + this.lines = lines; + this.complete = complete; + this.updateSentences(); + } + + private updateSentences() { + const output: string[] = []; + for (const line of this.lines) { + const sentences = split(line); + for (const sentence of sentences) { + output.push(sentence.raw.trim()); + } + } + this.sentences = output.filter(s => s.length > 0); + } +} + +export interface Voice { + service: string; + id: string; + name?: string; + sampleAudioURL?: string; +} \ No newline at end of file diff --git a/app/src/core/tts/use-tts.tsx b/app/src/core/tts/use-tts.tsx new file mode 100644 index 00000000..d291269e --- /dev/null +++ b/app/src/core/tts/use-tts.tsx @@ -0,0 +1,162 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { useAppContext } from "../context"; +import { ttsPlugins } from "../plugins/metadata"; +import Plugin from "../plugins"; +import { AbstractTTSPlayer, TTSPlayerState, Voice } from "./types"; +import { createBasicPluginContext } from "../plugins/plugin-context"; +import DirectTTSPlayer from "./direct-tts-player"; +import DirectTTSPlugin from "./direct-tts-plugin"; +import TTSPlugin from "./tts-plugin"; +import ExternalTTSAudioFilePlayer from "./audio-file-player"; +import { split } from "sentence-splitter"; +import { useOption } from "../options/use-option"; + +function extractTextSegments(element: HTMLElement) { + const selector = 'p, li, th, td, blockquote, pre code, h1, h2, h3, h3, h5, h6'; + const nodes = Array.from(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); +} + +interface ITTSContext { + key: string | null; + voice: Voice | null; + autoplayEnabled: boolean; + state?: TTSPlayerState; + play(index?: number): void; + pause(): void; + cancel(): void; + setSourceElement(key: string, element: HTMLElement | null): void; + setComplete(complete: boolean): void; +} + +export function useTTSPlayerState(): ITTSContext { + const context = useAppContext(); + + const [ttsPluginID] = useOption<string>('tts', 'service'); + const [autoplayEnabled] = useOption<boolean>('tts', 'autoplay'); + const [voiceID] = useOption<string>(ttsPluginID, 'voice'); + + const voice = useMemo(() => ({ + service: ttsPluginID, + id: voiceID, + }), [ttsPluginID, voiceID]); + + const ttsPluginImpl = useMemo(() => { + const ttsPluginIndex = ttsPlugins.findIndex(p => new p().describe().id === ttsPluginID) || 0; + return ttsPlugins[ttsPluginIndex]; + }, [ttsPluginID]); + + const plugin = useRef<Plugin|null>(null); + const player = useRef<AbstractTTSPlayer|null>(null); + const elementRef = useRef<HTMLElement|null>(null); + + const [key, setKey] = useState<string|null>(null); + const [state, setState] = useState(() => player.current?.getState()); + const [complete, setComplete] = useState(false); + + const timer = useRef<any>(); + + const setSourceElement = useCallback((newKey: string | null, element: HTMLElement | null) => { + elementRef.current = element; + + if (key !== newKey || !element) { + plugin.current = null; + player.current?.destroy(); + player.current = null; + } + + setKey(newKey); + + if (element) { + if (!plugin.current) { + const pluginContext = createBasicPluginContext(ttsPluginID, context.chat.options, context.id, context.currentChat.chat) + plugin.current = new ttsPluginImpl(pluginContext); + } + + if (!player.current) { + if (plugin.current instanceof DirectTTSPlugin) { + player.current = new DirectTTSPlayer(plugin.current as any); + } else if (plugin.current instanceof TTSPlugin) { + player.current = new ExternalTTSAudioFilePlayer(plugin.current); + } + + player.current!.on('state', setState); + } + } else { + setState(undefined); + } + }, [ttsPluginID, context, complete, key]); + + useEffect(() => { + setSourceElement(null, null); + }, [ttsPluginID, voiceID]); + + useEffect(() => { + clearInterval(timer.current); + + const update = () => { + if (!player.current || !elementRef.current) { + return; + } + + player.current.setText(extractTextSegments(elementRef.current), complete); + }; + + update(); + + if (!complete) { + timer.current = setInterval(update, 1000); + } + }, [key, complete]); + + return { + key, + voice: voiceID ? voice : null, + autoplayEnabled, + state: !state?.ended ? state : undefined, + play(index?: number) { + player.current?.play(index); + }, + pause() { + player.current?.pause(); + }, + cancel() { + setSourceElement(null, null); + }, + setSourceElement, + setComplete, + } +} + +const TTSContext = createContext<ITTSContext>({ + key: null, + voice: null, + autoplayEnabled: false, + play() {}, + pause() {}, + cancel() {}, + setSourceElement() {}, + setComplete() {}, +}); + +export function useTTS() { + return useContext(TTSContext); +} + +export function TTSContextProvider(props: { children: React.ReactNode }) { + const context = useTTSPlayerState(); + return <TTSContext.Provider value={context}>{props.children}</TTSContext.Provider>; +} \ No newline at end of file diff --git a/app/src/core/utils/async-loop.ts b/app/src/core/utils/async-loop.ts new file mode 100644 index 00000000..8b63fe96 --- /dev/null +++ b/app/src/core/utils/async-loop.ts @@ -0,0 +1,46 @@ +import { sleep } from '.'; + +/** + * AsyncLoop class provides a mechanism to execute a given function + * asynchronously in a loop with a specified delay between each execution. + * Unlike setInterval, it ensures that each iteration finishes before + * starting the next one. + */ + +export class AsyncLoop { + public cancelled = false; + + /** + * Creates a new instance of the AsyncLoop class. + * @param {Function} handler - The function to be executed in the loop. + * @param {number} pauseBetween - The delay (in milliseconds) between each execution of the handler. Default is 1000 ms. + */ + constructor(private handler: any, private pauseBetween: number = 1000) { + } + + /** + * Starts the asynchronous loop by calling the loop() method. + */ + public start() { + this.loop().then(() => { }); + } + + /** + * The main loop function that executes the given handler function + * while the loop is not cancelled. It catches any errors thrown by + * the handler function and logs them to the console. + * @private + * @returns {Promise<void>} A Promise that resolves when the loop is cancelled. + */ + private async loop() { + while (!this.cancelled) { + try { + await this.handler(); + } catch (e) { + console.error(e); + } + + await sleep(this.pauseBetween); + } + } +} diff --git a/app/src/core/utils/event-emitter-async-iterator.ts b/app/src/core/utils/event-emitter-async-iterator.ts new file mode 100644 index 00000000..16f205e1 --- /dev/null +++ b/app/src/core/utils/event-emitter-async-iterator.ts @@ -0,0 +1,91 @@ +import EventEmitter from 'events'; + +export interface EventEmitterAsyncIteratorOutput<T> { + eventName: string; + value: T; +} + +/** + * The EventEmitterAsyncIterator class provides a way to create an async iterator + * that listens to multiple events from an EventEmitter instance, and yields + * the emitted event name and value as an EventEmitterAsyncIteratorOutput object. + * + * This class implements the AsyncIterableIterator interface, which allows it + * to be used in for-await-of loops and other asynchronous iteration contexts. + * + * @typeparam T - The type of values emitted by the events. + * + * @example + * const eventEmitter = new EventEmitter(); + * const asyncIterator = new EventEmitterAsyncIterator(eventEmitter, ['event1', 'event2']); + * + * for await (const event of asyncIterator) { + * console.log(`Received event: ${event.eventName} with value: ${event.value}`); + * } + */ + +export class EventEmitterAsyncIterator<T> implements AsyncIterableIterator<EventEmitterAsyncIteratorOutput<T>> { + private eventQueue: EventEmitterAsyncIteratorOutput<T>[] = []; + private resolveQueue: ((value: IteratorResult<EventEmitterAsyncIteratorOutput<T>>) => void)[] = []; + + /** + * Constructor takes an EventEmitter instance and an array of event names to listen to. + * For each event name, it binds the pushEvent method with the eventName, which + * will be called when the event is emitted. + * + * @param eventEmitter - The EventEmitter instance to listen to events from. + * @param eventNames - An array of event names to listen to. + */ + constructor(private eventEmitter: EventEmitter, eventNames: string[]) { + for (const eventName of eventNames) { + this.eventEmitter.on(eventName, this.pushEvent.bind(this, eventName)); + } + } + + /** + * The next method is called when the iterator is requested to return the next value. + * If there is an event in the eventQueue, it will return the next event from the queue. + * If the eventQueue is empty, it will return a Promise that resolves when a new event is received. + * + * @returns A Promise that resolves with the next event or waits for a new event if the queue is empty. + */ + async next(): Promise<IteratorResult<EventEmitterAsyncIteratorOutput<T>>> { + if (this.eventQueue.length > 0) { + const value = this.eventQueue.shift(); + return { value: value as EventEmitterAsyncIteratorOutput<T>, done: false }; + } else { + return new Promise<IteratorResult<EventEmitterAsyncIteratorOutput<T>>>(resolve => { + this.resolveQueue.push(value => { + resolve(value); + }); + }); + } + } + + /** + * The pushEvent method is called when an event is emitted from the EventEmitter. + * If there is a pending Promise in the resolveQueue, it resolves the Promise with the new event. + * If there is no pending Promise, it adds the event to the eventQueue. + * + * @param eventName - The name of the emitted event. + * @param value - The value emitted with the event. + */ + private pushEvent(eventName: string, value: T): void { + const output: EventEmitterAsyncIteratorOutput<T> = { + eventName, + value, + }; + if (this.resolveQueue.length > 0) { + const resolve = this.resolveQueue.shift(); + if (resolve) { + resolve({ value: output, done: false }); + } + } else { + this.eventQueue.push(output); + } + } + + [Symbol.asyncIterator](): AsyncIterableIterator<EventEmitterAsyncIteratorOutput<T>> { + return this; + } +} diff --git a/app/src/idb.ts b/app/src/core/utils/idb.ts similarity index 72% rename from app/src/idb.ts rename to app/src/core/utils/idb.ts index aeb5ecda..1360886a 100644 --- a/app/src/idb.ts +++ b/app/src/core/utils/idb.ts @@ -1,3 +1,20 @@ +/* + * This file provides a wrapper for IndexedDB (IDB), specifically designed to handle cases + * where IDB is unavailable, such as when the user is in private browsing mode. The wrapper + * uses the 'idb-keyval' library for interacting with IDB and maintains an in-memory cache + * as a fallback mechanism when IDB is not accessible. + * + * The module exports various functions for working with key-value pairs, such as getting, + * setting, deleting, and retrieving keys and entries. These functions first attempt to + * interact with IDB, and if it fails (e.g., due to unavailability), they fall back to + * the in-memory cache. This ensures that the application can continue to function even + * in cases where IDB is not supported or disabled. + * + * The wrapper performs an initial test to check whether IDB is supported in the current + * environment. If not, it sets the 'supported' flag to false, and all subsequent operations + * will rely on the in-memory cache. + */ + import * as idb from 'idb-keyval'; let supported = true; diff --git a/app/src/core/utils/index.ts b/app/src/core/utils/index.ts new file mode 100644 index 00000000..ac1ec395 --- /dev/null +++ b/app/src/core/utils/index.ts @@ -0,0 +1,101 @@ +import * as hashes from 'jshashes'; + +/** + * Pauses the execution of the function for a specified duration. + * + * @export + * @param {number} ms - The duration (in milliseconds) to pause the execution. + * @returns {Promise} A Promise that resolves after the specified duration. + */ +export function sleep(ms: number): Promise<any> { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Truncates a given string to a specified length and appends ellipsis (...) if needed. + * + * @export + * @param {string} text - The input string to be ellipsized. + * @param {number} maxLength - The maximum length of the output string (including the ellipsis). + * @returns {string} The ellipsized string. + */ +export function ellipsize(text: string, maxLength: number): string { + if (text.length > maxLength) { + return text.substring(0, maxLength) + '...'; + } + return text; +} + +/** + * Creates a deep clone of the given ArrayBuffer. + * + * @export + * @param {ArrayBuffer} buffer - The ArrayBuffer to clone. + * @returns {ArrayBuffer} A new ArrayBuffer containing the same binary data as the input buffer. + */ +export function cloneArrayBuffer(buffer: ArrayBuffer): ArrayBuffer { + const newBuffer = new ArrayBuffer(buffer.byteLength); + new Uint8Array(newBuffer).set(new Uint8Array(buffer)); + return newBuffer; +} + +/** + * Shares the specified text using the Web Share API if available in the user's browser. + * + * @function + * @async + * @param {string} text - The text to be shared. + * @example + * share("Hello, World!"); + */ +export async function share(text: string) { + if (navigator.share) { + await navigator.share({ + text, + }); + } +} + +/* +Hashing +*/ + +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)!; +} + +/* +Rate limiting +*/ + +export function getRateLimitResetTimeFromResponse(response: Response): number { + const now = Date.now(); + const fallbackValue = now + 20*1000; + const maxValue = now + 2*60*1000; + + const rateLimitReset = response.headers.get("x-ratelimit-reset"); + if (!rateLimitReset) { + return fallbackValue; + } + + let resetTime = parseInt(rateLimitReset, 10); + if (isNaN(resetTime)) { + return fallbackValue; + } + + resetTime *= 1000; + + if (resetTime > fallbackValue) { + return maxValue; + } + + return resetTime; +} \ No newline at end of file diff --git a/app/src/sse.ts b/app/src/core/utils/sse.ts similarity index 52% rename from app/src/sse.ts rename to app/src/core/utils/sse.ts index c05e8547..e8889df5 100644 --- a/app/src/sse.ts +++ b/app/src/core/utils/sse.ts @@ -1,124 +1,97 @@ /** + * This class is an implementation of Server-Side Events (SSE) that allows sending POST request bodies. + * + * It's an adapted version of an open-source implementation, and it's designed to support streaming + * completions for OpenAI requests + * + * Original Copyright: * Copyright (C) 2016 Maxime Petazzoni <maxime.petazzoni@bulix.org>. * All rights reserved. */ - export default class SSE { + // Constants representing the ready state of the SSE connection 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.options.method : (this.payload ? 'POST' : 'GET'); - public withCredentials = !!this.options.withCredentials; + // Connection settings + private headers = this.options.headers || {}; + private payload = this.options.payload !== undefined ? this.options.payload : ''; + private method = this.options.method ? this.options.method : (this.payload ? 'POST' : 'GET'); + private withCredentials = !!this.options.withCredentials; - public FIELD_SEPARATOR = ':'; - public listeners: any = {}; + // Internal properties + private FIELD_SEPARATOR = ':'; + private listeners: any = {}; - public xhr: any = null; - public readyState = this.INITIALIZING; - public progress = 0; - public chunk = ''; + private xhr: any = null; + private readyState = this.INITIALIZING; + private progress = 0; + private chunk = ''; - public constructor(public url: string, public options: any) {} + public constructor(public url: string, public options: any) { } - public addEventListener = (type: string, listener: any) => { - if (this.listeners[type] === undefined) { - this.listeners[type] = []; - } + /** + * Starts streaming data from the SSE connection. + */ + public stream = () => { + this.setReadyState(this.CONNECTING); - if (this.listeners[type].indexOf(listener) === -1) { - this.listeners[type].push(listener); + 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 removeEventListener = (type: string, listener: any) => { - if (this.listeners[type] === undefined) { + /** + * Closes the SSE connection. + */ + public close = () => { + if (this.readyState === this.CLOSED) { 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; - }); + try { + this.xhr.abort(); + this.xhr = null; + this.setReadyState(this.CLOSED); + } catch (e) { + console.error(e); } - - 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) => { + /** + * Processes incoming data from the SSE connection and dispatches events based on the received data. + */ + private onStreamProgress = (e: any) => { if (!this.xhr) { return; } if (this.xhr.status !== 200) { - this._onStreamFailure(e); + this.onStreamFailure(e); return; } if (this.readyState === this.CONNECTING) { this.dispatchEvent(new CustomEvent('open')); - this._setReadyState(this.OPEN); + this.setReadyState(this.OPEN); } - var data = this.xhr.responseText.substring(this.progress); + const 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.dispatchEvent(this.parseEventChunk(this.chunk.trim())); this.chunk = ''; } else { this.chunk += part; @@ -126,38 +99,30 @@ export default class SSE { }); }; - 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. + * Parses a received SSE event chunk and constructs an event object based on the chunk data. */ - public _parseEventChunk = (chunk: string) => { + private parseEventChunk = (chunk: string) => { if (!chunk || chunk.length === 0) { return null; } - var e: any = { 'id': null, 'retry': null, 'data': '', 'event': 'message' }; + const 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); + const 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); + const field = line.substring(0, index); if (!(field in e)) { return; } - var value = line.substring(index + 1).trimLeft(); + const value = line.substring(index + 1).trimLeft(); if (field === 'data') { e[field] += value; } else { @@ -165,50 +130,126 @@ export default class SSE { } }); - var event: any = new CustomEvent(e.event); + const event: any = new CustomEvent(e.event); event.data = e.data; event.id = e.id; return event; }; - public _checkStreamClosed = () => { - if (!this.xhr) { + /** + * Handles the 'load' event for the SSE connection and processes the remaining data. + */ + private onStreamLoaded = (e: any) => { + this.onStreamProgress(e); + + // Parse the last chunk. + this.dispatchEvent(this.parseEventChunk(this.chunk)); + this.chunk = ''; + }; + + /** + * Adds an event listener for a given event type. + */ + 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); + } + }; + + /** + * Removes an event listener for a given event type. + */ + public removeEventListener = (type: string, listener: any) => { + if (this.listeners[type] === undefined) { return; } - if (this.xhr.readyState === XMLHttpRequest.DONE) { - this._setReadyState(this.CLOSED); + const 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 stream = () => { - this._setReadyState(this.CONNECTING); + /** + * Dispatches an event to all registered listeners. + */ + private dispatchEvent = (e: any) => { + if (!e) { + return true; + } - 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]); + e.source = this; + + const onHandler = 'on' + e.type; + if (this.hasOwnProperty(onHandler)) { + // @ts-ignore + this[onHandler].call(this, e); + if (e.defaultPrevented) { + return false; + } } - this.xhr.withCredentials = this.withCredentials; - this.xhr.send(this.payload); + + if (this.listeners[e.type]) { + return this.listeners[e.type].every((callback: (arg0: any) => void) => { + callback(e); + return !e.defaultPrevented; + }); + } + + return true; }; - public close = () => { - if (this.readyState === this.CLOSED) { + /** + * Sets the ready state of the SSE connection and dispatches a 'readystatechange' event. + */ + private setReadyState = (state: number) => { + const event = new CustomEvent<any>('readystatechange'); + // @ts-ignore + event.readyState = state; + this.readyState = state; + this.dispatchEvent(event); + }; + + /** + * Handles an error during the SSE connection and dispatches an 'error' event. + */ + private onStreamFailure = (e: { currentTarget: { response: any; }; }) => { + const event = new CustomEvent('error'); + // @ts-ignore + event.data = e.currentTarget.response; + this.dispatchEvent(event); + this.close(); + } + + /** + * Handles an abort event during the SSE connection and dispatches an 'abort' event. + */ + private onStreamAbort = (e: any) => { + this.dispatchEvent(new CustomEvent('abort')); + this.close(); + } + + /** + * Checks if the SSE connection is closed and sets the ready state to CLOSED if needed. + */ + private checkStreamClosed = () => { + if (!this.xhr) { return; } - try { - this.xhr.abort(); - this.xhr = null; - this._setReadyState(this.CLOSED); - } catch (e) { - console.error(e); + if (this.xhr.readyState === XMLHttpRequest.DONE) { + this.setReadyState(this.CLOSED); } }; }; \ No newline at end of file diff --git a/app/src/global-options/index.tsx b/app/src/global-options/index.tsx new file mode 100644 index 00000000..b75f250f --- /dev/null +++ b/app/src/global-options/index.tsx @@ -0,0 +1,43 @@ +import { pluginMetadata } from "../core/plugins/metadata"; +import { Option } from "../core/options/option"; +import { OptionGroup } from "../core/options/option-group"; +import { openAIOptions } from "./openai"; +import { parameterOptions } from "./parameters"; +import { ttsServiceOptions } from "./tts-service"; +import { autoScrollOptions, inputOptions } from "./ui"; +import { whisperOptions } from "./whisper"; + +export const globalOptions: OptionGroup[] = [ + openAIOptions, + autoScrollOptions, + parameterOptions, + inputOptions, + whisperOptions, + ttsServiceOptions, +]; + +const optionsForQuickSettings: Option[] = []; +[...globalOptions, ...pluginMetadata].forEach(plugin => { + plugin.options.forEach(option => { + if (option.displayInQuickSettings) { + optionsForQuickSettings.push({ + id: plugin.id + "--" + option.id, + defaultValue: !!option.displayInQuickSettings?.displayByDefault, + displayOnSettingsScreen: "ui", + displayAsSeparateSection: false, + renderProps: { + type: 'checkbox', + label: option.displayInQuickSettings?.name || option.id, + }, + }); + } + }); +}) + +export const quickSettings: OptionGroup = { + id: 'quick-settings', + name: "Quick Settings", + options: optionsForQuickSettings, +} + +globalOptions.push(quickSettings); \ No newline at end of file diff --git a/app/src/global-options/openai.tsx b/app/src/global-options/openai.tsx new file mode 100644 index 00000000..ac223e1a --- /dev/null +++ b/app/src/global-options/openai.tsx @@ -0,0 +1,32 @@ +import { FormattedMessage } from "react-intl"; +import { OptionGroup } from "../core/options/option-group"; + +export const openAIOptions: OptionGroup = { + id: 'openai', + options: [ + { + id: 'apiKey', + defaultValue: "", + displayOnSettingsScreen: "user", + displayAsSeparateSection: true, + renderProps: () => ({ + type: "password", + label: "Your OpenAI API Key", + placeholder: "sk-************************************************", + description: <> + <p> + <a href="https://platform.openai.com/account/api-keys" target="_blank" rel="noreferrer"> + <FormattedMessage defaultMessage="Find your API key here." description="Label for the link that takes the user to the page on the OpenAI website where they can find their API key." /> + </a> + </p> + <p> + <FormattedMessage defaultMessage="Your API key is stored only on this device and never transmitted to anyone except OpenAI." /> + </p> + <p> + <FormattedMessage defaultMessage="OpenAI API key usage is billed at a pay-as-you-go rate, separate from your ChatGPT subscription." /> + </p> + </>, + }), + }, + ], +} \ No newline at end of file diff --git a/app/src/global-options/parameters.tsx b/app/src/global-options/parameters.tsx new file mode 100644 index 00000000..bd3fb880 --- /dev/null +++ b/app/src/global-options/parameters.tsx @@ -0,0 +1,64 @@ +import { defaultModel } from "../core/chat/openai"; +import { OptionGroup } from "../core/options/option-group"; + +export const parameterOptions: OptionGroup = { + id: 'parameters', + options: [ + { + id: "model", + defaultValue: defaultModel, + resettable: false, + scope: "user", + displayOnSettingsScreen: "chat", + displayAsSeparateSection: true, + displayInQuickSettings: { + name: "Model", + displayByDefault: true, + label: (value) => value, + }, + renderProps: (value, options, context) => ({ + type: "select", + label: "Model", + description: value === 'gpt-4' && context.intl.formatMessage( + { + defaultMessage: "Note: GPT-4 will only work if your OpenAI account has been granted access to the new model. <a>Request access here.</a>", + }, + { + a: (text: string) => <a href="https://openai.com/waitlist/gpt-4-api" target="_blank" rel="noreferer">{text}</a> + } as any, + ), + options: [ + { + label: "GPT 3.5 Turbo (default)", + value: "gpt-3.5-turbo", + }, + { + label: "GPT 4 (requires invite)", + value: "gpt-4", + }, + ], + }), + }, + { + id: "temperature", + defaultValue: 0.5, + resettable: true, + scope: "chat", + displayOnSettingsScreen: "chat", + displayAsSeparateSection: true, + displayInQuickSettings: { + name: "Temperature", + displayByDefault: false, + label: (value) => "Temperature: " + value.toFixed(1), + }, + renderProps: (value, options, context) => ({ + type: "slider", + label: "Temperature: " + value.toFixed(1), + min: 0, + max: 1, + step: 0.1, + description: context.intl.formatMessage({ defaultMessage: "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." }), + }) + } + ] +}; \ No newline at end of file diff --git a/app/src/global-options/tts-service.tsx b/app/src/global-options/tts-service.tsx new file mode 100644 index 00000000..101a93fb --- /dev/null +++ b/app/src/global-options/tts-service.tsx @@ -0,0 +1,34 @@ +import { ttsPlugins } from "../core/plugins/metadata"; +import { OptionGroup } from "../core/options/option-group"; + +const ttsPluginMetadata = ttsPlugins.map(p => new p().describe()); + +export const ttsServiceOptions: OptionGroup = { + id: 'tts', + options: [ + { + id: 'autoplay', + displayOnSettingsScreen: "speech", + defaultValue: false, + displayAsSeparateSection: true, + renderProps: { + type: "checkbox", + label: "Read messages aloud automatically", + }, + }, + { + id: 'service', + displayOnSettingsScreen: "speech", + defaultValue: "elevenlabs", + displayAsSeparateSection: true, + renderProps: { + type: "select", + label: "Choose a Text-to-Speech Provider", + options: ttsPluginMetadata.map(p => ({ + label: p.name, + value: p.id, + })), + }, + }, + ], +} \ No newline at end of file diff --git a/app/src/global-options/ui.tsx b/app/src/global-options/ui.tsx new file mode 100644 index 00000000..016c975e --- /dev/null +++ b/app/src/global-options/ui.tsx @@ -0,0 +1,50 @@ +import { OptionGroup } from "../core/options/option-group"; + +export const autoScrollOptions: OptionGroup = { + id: 'auto-scroll', + name: "Autoscroll", + options: [ + { + id: 'auto-scroll-when-opening-chat', + defaultValue: false, + displayOnSettingsScreen: "ui", + displayAsSeparateSection: false, + renderProps: { + type: "checkbox", + label: "Auto-scroll to the bottom of the page when opening a chat", + }, + }, + { + id: 'auto-scroll-while-generating', + defaultValue: true, + displayOnSettingsScreen: "ui", + displayAsSeparateSection: false, + renderProps: { + type: "checkbox", + label: "Auto-scroll while generating a response", + }, + }, + ], +} + +export const inputOptions: OptionGroup = { + id: 'input', + name: "Message Input", + options: [ + { + id: 'submit-on-enter', + defaultValue: true, + displayOnSettingsScreen: "ui", + displayAsSeparateSection: false, + displayInQuickSettings: { + name: "Enable/disable submit message when Enter is pressed", + displayByDefault: false, + label: (value) => value ? "Disable submit on Enter" : "Enable submit on Enter", + }, + renderProps: { + type: "checkbox", + label: "Submit message when Enter is pressed", + }, + }, + ], +} \ No newline at end of file diff --git a/app/src/global-options/whisper.tsx b/app/src/global-options/whisper.tsx new file mode 100644 index 00000000..9e8e5762 --- /dev/null +++ b/app/src/global-options/whisper.tsx @@ -0,0 +1,31 @@ +import { OptionGroup } from "../core/options/option-group"; +import { supportsSpeechRecognition } from "../core/speech-recognition-types"; + +export const whisperOptions: OptionGroup = { + id: 'speech-recognition', + name: "Microphone", + hidden: !supportsSpeechRecognition, + options: [ + { + id: 'use-whisper', + defaultValue: false, + displayOnSettingsScreen: "speech", + displayAsSeparateSection: false, + renderProps: { + type: "checkbox", + label: "Use the OpenAI Whisper API for speech recognition", + hidden: !supportsSpeechRecognition, + }, + }, + { + id: 'show-microphone', + defaultValue: true, + displayOnSettingsScreen: "speech", + displayAsSeparateSection: false, + renderProps: { + type: "checkbox", + label: "Show microphone in message input", + }, + }, + ], +} \ No newline at end of file diff --git a/app/src/index.tsx b/app/src/index.tsx index 209c75b6..9c1f60b6 100644 --- a/app/src/index.tsx +++ b/app/src/index.tsx @@ -6,13 +6,12 @@ import { IntlProvider } from 'react-intl'; import { Provider } from 'react-redux'; import { createBrowserRouter, RouterProvider } from "react-router-dom"; import { PersistGate } from 'redux-persist/integration/react'; -import { AppContextProvider } from './context'; +import { AppContextProvider } from './core/context'; import store, { persistor } from './store'; import ChatPage from './components/pages/chat'; import LandingPage from './components/pages/landing'; -import './backend'; import './index.scss'; const router = createBrowserRouter([ diff --git a/app/src/message-tree.ts b/app/src/message-tree.ts deleted file mode 100644 index 510a0944..00000000 --- a/app/src/message-tree.ts +++ /dev/null @@ -1,132 +0,0 @@ -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(); - - constructor(messages: (Message | Node)[] = []) { - this.addMessages(messages); - } - - 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 get(id: string) { - return this.nodes.get(id); - } - - 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 addMessages(messages: Message[]) { - for (const message of messages) { - try { - this.addMessage(message); - } catch (e) { - console.error(e); - } - } - } - - 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/app/src/plugins/index.ts b/app/src/plugins/index.ts new file mode 100644 index 00000000..00387399 --- /dev/null +++ b/app/src/plugins/index.ts @@ -0,0 +1,16 @@ +import Plugin from "../core/plugins"; + +import { SystemPromptPlugin } from "./system-prompt"; +import { TitlePlugin } from "./titles"; +import { ContextTrimmerPlugin } from "./trimmer"; + +import ElevenLabsPlugin from "../tts-plugins/elevenlabs"; +import WebSpeechPlugin from "../tts-plugins/web-speech"; + +export const registeredPlugins: Array<typeof Plugin<any>> = [ + SystemPromptPlugin, + ContextTrimmerPlugin, + TitlePlugin, + WebSpeechPlugin, + ElevenLabsPlugin, +]; \ No newline at end of file diff --git a/app/src/plugins/system-prompt.tsx b/app/src/plugins/system-prompt.tsx new file mode 100644 index 00000000..483795ec --- /dev/null +++ b/app/src/plugins/system-prompt.tsx @@ -0,0 +1,60 @@ +import { FormattedMessage } from "react-intl"; +import Plugin from "../core/plugins"; +import { PluginDescription } from "../core/plugins/plugin-description"; +import { OpenAIMessage, Parameters } from "../core/chat/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 SystemPromptPluginOptions { + systemPrompt: string; +} + +export class SystemPromptPlugin extends Plugin<SystemPromptPluginOptions> { + describe(): PluginDescription { + return { + id: "system-prompt", + name: "System Prompt", + options: [ + { + id: "systemPrompt", + defaultValue: defaultSystemPrompt, + displayOnSettingsScreen: "chat", + resettable: true, + scope: "chat", + renderProps: { + type: "textarea", + description: <p> + <FormattedMessage defaultMessage={"The System Prompt is an invisible message inserted at the start of the chat and can be used to give ChatGPT information about itself and general guidelines for how it should respond. The <code>'{{ datetime }}'</code> tag is automatically replaced by the current date and time (use this to give the AI access to the time)."} + values={{ code: v => <code>{v}</code> }} /> + </p>, + }, + displayInQuickSettings: { + name: "System Prompt", + displayByDefault: true, + label: "Customize system prompt", + }, + }, + ], + }; + } + + async preprocessModelInput(messages: OpenAIMessage[], parameters: Parameters): Promise<{ messages: OpenAIMessage[]; parameters: Parameters; }> { + const output = [ + { + role: 'system', + content: (this.options?.systemPrompt || defaultSystemPrompt) + .replace('{{ datetime }}', new Date().toLocaleString()), + }, + ...messages, + ]; + + return { + messages: output, + parameters, + }; + } +} \ No newline at end of file diff --git a/app/src/plugins/titles.ts b/app/src/plugins/titles.ts new file mode 100644 index 00000000..ffdf15d4 --- /dev/null +++ b/app/src/plugins/titles.ts @@ -0,0 +1,75 @@ +import Plugin from "../core/plugins"; +import { PluginDescription } from "../core/plugins/plugin-description"; +import { OpenAIMessage, Parameters } from "../core/chat/types"; +import { countTokens, runChatTrimmer } from "../core/tokenizer/wrapper"; +import { defaultModel } from "../core/chat/openai"; + +export const systemPrompt = ` +Please read the following exchange and write a short, concise title describing the topic (in the user's language). +If there is no clear topic for the exchange, respond with: N/A +`.trim(); + +export const systemPromptForLongExchanges = ` +Please read the following exchange and write a short, concise title describing the topic (in the user's language). +`.trim(); + +export interface TitlePluginOptions { +} + +const userPrompt = (messages: OpenAIMessage[]) => { + return messages.map(m => `${m.role.toLocaleUpperCase()}:\n${m.content}`) + .join("\n===\n") + + "\n===\nTitle:"; +} + +export class TitlePlugin extends Plugin<TitlePluginOptions> { + describe(): PluginDescription { + return { + id: "titles", + name: "Title Generator", + options: [], + }; + } + + async postprocessModelOutput(message: OpenAIMessage, contextMessages: OpenAIMessage[], parameters: Parameters, done: boolean): Promise<OpenAIMessage> { + if (done && !this.context?.getCurrentChat().title) { + (async () => { + let messages = [ + ...contextMessages.filter(m => m.role === 'user' || m.role === 'assistant'), + message, + ]; + + const tokens = await countTokens(messages); + + messages = await runChatTrimmer(messages, { + maxTokens: 1024, + preserveFirstUserMessage: true, + preserveSystemPrompt: false, + }); + + messages = [ + { + role: 'system', + content: tokens.length > 512 ? systemPromptForLongExchanges : systemPrompt, + }, + { + role: 'user', + content: userPrompt(messages), + }, + ] + + const output = await this.context?.createChatCompletion(messages, { + model: defaultModel, + temperature: 0, + }); + + if (!output || output === 'N/A') { + return; + } + + this.context?.setChatTitle(output); + })(); + } + return message; + } +} \ No newline at end of file diff --git a/app/src/plugins/trimmer.ts b/app/src/plugins/trimmer.ts new file mode 100644 index 00000000..db603eb1 --- /dev/null +++ b/app/src/plugins/trimmer.ts @@ -0,0 +1,106 @@ +import Plugin from "../core/plugins"; +import { PluginDescription } from "../core/plugins/plugin-description"; +import { OpenAIMessage, Parameters } from "../core/chat/types"; +import { maxTokensByModel } from "../core/chat/openai"; +import { countTokens, runChatTrimmer } from "../core/tokenizer/wrapper"; + +export interface ContextTrimmerPluginOptions { + maxTokens: number; + maxMessages: number | null; + preserveSystemPrompt: boolean; + preserveFirstUserMessage: boolean; +} + +export class ContextTrimmerPlugin extends Plugin<ContextTrimmerPluginOptions> { + describe(): PluginDescription { + return { + id: "context-trimmer", + name: "Message Context", + options: [ + { + id: 'maxTokens', + displayOnSettingsScreen: "chat", + defaultValue: 2048, + scope: "chat", + renderProps: (value, options) => ({ + label: `Include a maximum of ${value} tokens`, + type: "slider", + min: 512, + max: maxTokensByModel[options.getOption('parameters', 'model')] || 2048, + step: 512, + }), + validate: (value, options) => { + const max = maxTokensByModel[options.getOption('parameters', 'model')] || 2048; + return value < max; + }, + displayInQuickSettings: { + name: "Max Tokens", + displayByDefault: false, + label: value => `Max tokens: ${value}`, + }, + }, + // { + // id: 'maxMessages', + // displayOnSettingsScreen: "chat", + // defaultValue: null, + // scope: "chat", + // renderProps: (value) => ({ + // label: `Include only the last ${value || 'N'} messages (leave blank for all)`, + // type: "number", + // min: 1, + // max: 10, + // step: 1, + // }), + // displayInQuickSettings: { + // name: "Max Messages", + // displayByDefault: false, + // label: value => `Include ${value ?? 'all'} messages`, + // }, + // }, + { + id: 'preserveSystemPrompt', + displayOnSettingsScreen: "chat", + defaultValue: true, + scope: "chat", + renderProps: { + label: "Try to always include the System Prompt", + type: "checkbox", + }, + }, + { + id: 'preserveFirstUserMessage', + displayOnSettingsScreen: "chat", + defaultValue: true, + scope: "chat", + renderProps: { + label: "Try to always include your first message", + type: "checkbox", + }, + }, + ], + }; + } + + async preprocessModelInput(messages: OpenAIMessage[], parameters: Parameters): Promise<{ messages: OpenAIMessage[]; parameters: Parameters; }> { + const before = await countTokens(messages); + + const options = this.options; + + const trimmed = await runChatTrimmer(messages, { + maxTokens: options?.maxTokens ?? 2048, + nMostRecentMessages: options?.maxMessages ?? undefined, + preserveFirstUserMessage: options?.preserveFirstUserMessage || true, + preserveSystemPrompt: options?.preserveSystemPrompt || true, + }); + + const after = await countTokens(trimmed); + + const diff = after - before; + console.log(`[context trimmer] trimmed ${diff} tokens from context`); + + return { + messages: trimmed, + parameters, + }; + } +} \ No newline at end of file diff --git a/app/src/spotlight.tsx b/app/src/spotlight.tsx index 419b4c22..ce4f2f41 100644 --- a/app/src/spotlight.tsx +++ b/app/src/spotlight.tsx @@ -1,34 +1,38 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useIntl } from "react-intl"; import { useNavigate } from "react-router-dom"; -import { useAppContext } from "./context"; +import { useAppContext } from "./core/context"; export function useChatSpotlightProps() { const navigate = useNavigate(); - const context = useAppContext(); + const { chat } = useAppContext(); const intl = useIntl(); const [version, setVersion] = useState(0); useEffect(() => { - context.chat.on('update', () => setVersion(v => v + 1)); - }, [context.chat]); + const handleUpdate = () => setVersion(v => v + 1); + chat.on('update', handleUpdate); + return () => { + chat.off('update', handleUpdate); + }; + }, [chat]); - const search = useCallback((query: string) => { - return context.chat.search.query(query) - .map((result: any) => ({ + const search = useCallback((query) => { + return chat.searchChats(query) + .map((result) => ({ ...result, - onTrigger: () => navigate('/chat/' + result.chatID + (result.messageID ? '#msg-' + result.messageID : '')), + onTrigger: () => navigate(`/chat/${result.chatID}${result.messageID ? `#msg-${result.messageID}` : ''}`), })) - }, [context.chat, navigate, version]); // eslint-disable-line react-hooks/exhaustive-deps + }, [chat, navigate, version]); const props = useMemo(() => ({ - shortcut: ['mod + P'], + shortcut: ['/'], overlayColor: '#000000', searchPlaceholder: intl.formatMessage({ defaultMessage: 'Search your chats' }), searchIcon: <i className="fa fa-search" />, actions: search, - filter: (query: string, items: any) => items, + filter: (query, items) => items, }), [search]); return props; diff --git a/app/src/store/api-keys.ts b/app/src/store/api-keys.ts deleted file mode 100644 index eced56c4..00000000 --- a/app/src/store/api-keys.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import type { RootState } from '.'; - -const initialState: { - openAIApiKey?: string | null | undefined; - useOpenAIWhisper: boolean; - elevenLabsApiKey?: string | null | undefined; - -} = { - openAIApiKey: localStorage.getItem('openai-api-key'), - useOpenAIWhisper: false, - elevenLabsApiKey: localStorage.getItem('elevenlabs-api-key'), -}; - -export const apiKeysSlice = createSlice({ - name: 'apiKeys', - initialState, - reducers: { - setOpenAIApiKey: (state, action: PayloadAction<string>) => { - state.openAIApiKey = action.payload; - }, - setElevenLabsApiKey: (state, action: PayloadAction<string>) => { - state.elevenLabsApiKey = action.payload; - }, - setUseOpenAIWhisper: (state, action: PayloadAction<boolean>) => { - state.useOpenAIWhisper = action.payload; - } - - }, -}) - -export const { setOpenAIApiKey, setElevenLabsApiKey } = apiKeysSlice.actions; - -export const setOpenAIApiKeyFromEvent = (event: React.ChangeEvent<HTMLInputElement>) => apiKeysSlice.actions.setOpenAIApiKey(event.target.value); -export const setElevenLabsApiKeyFromEvent = (event: React.ChangeEvent<HTMLInputElement>) => apiKeysSlice.actions.setElevenLabsApiKey(event.target.value); -export const setUseOpenAIWhisperFromEvent = (event: React.ChangeEvent<HTMLInputElement>) => apiKeysSlice.actions.setUseOpenAIWhisper(event.target.checked); - -export const selectOpenAIApiKey = (state: RootState) => state.apiKeys.openAIApiKey; -export const selectElevenLabsApiKey = (state: RootState) => state.apiKeys.elevenLabsApiKey; -export const selectUseOpenAIWhisper = (state: RootState) => state.apiKeys.useOpenAIWhisper; - -export default apiKeysSlice.reducer; \ No newline at end of file diff --git a/app/src/store/index.ts b/app/src/store/index.ts index be1fdde8..1870901d 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -3,11 +3,8 @@ import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; import storage from 'redux-persist/lib/storage'; import { persistReducer, persistStore } from 'redux-persist'; import messageReducer from './message'; -import parametersReducer from './parameters'; -import apiKeysReducer from './api-keys'; -import voiceReducer from './voices'; -import settingsUIReducer from './settings-ui'; import uiReducer from './ui'; +import settingsUIReducer from './settings-ui'; import sidebarReducer from './sidebar'; const persistConfig = { @@ -29,13 +26,9 @@ const persistMessageConfig = { const store = configureStore({ reducer: { - // auth: authReducer, - apiKeys: persistReducer(persistConfig, apiKeysReducer), - settingsUI: settingsUIReducer, - voices: persistReducer(persistConfig, voiceReducer), - parameters: persistReducer(persistConfig, parametersReducer), message: persistReducer(persistMessageConfig, messageReducer), ui: uiReducer, + settingsUI: settingsUIReducer, sidebar: persistReducer(persistSidebarConfig, sidebarReducer), }, }) diff --git a/app/src/store/parameters.ts b/app/src/store/parameters.ts deleted file mode 100644 index deb82d8f..00000000 --- a/app/src/store/parameters.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit' -import type { RootState } from '.'; -import { defaultSystemPrompt, defaultModel } from '../openai'; -import { defaultParameters } from '../parameters'; -import { Parameters } from '../types'; - -const initialState: Parameters = defaultParameters; - -export const parametersSlice = createSlice({ - name: 'parameters', - initialState, - reducers: { - setSystemPrompt: (state, action: PayloadAction<string>) => { - state.initialSystemPrompt = action.payload; - }, - resetSystemPrompt: (state) => { - state.initialSystemPrompt = defaultSystemPrompt; - }, - setModel: (state, action: PayloadAction<string>) => { - state.model = action.payload; - }, - resetModel: (state) => { - state.model = defaultModel; - }, - setTemperature: (state, action: PayloadAction<number>) => { - state.temperature = action.payload; - }, - }, -}) - -export const { setSystemPrompt, setModel, setTemperature, resetSystemPrompt, resetModel } = parametersSlice.actions; - -export const selectSystemPrompt = (state: RootState) => state.parameters.initialSystemPrompt; -export const selectModel = (state: RootState) => state.parameters.model; -export const selectTemperature = (state: RootState) => state.parameters.temperature; - -export default parametersSlice.reducer; \ No newline at end of file diff --git a/app/src/store/settings-ui.ts b/app/src/store/settings-ui.ts index 90320433..a7cd24e3 100644 --- a/app/src/store/settings-ui.ts +++ b/app/src/store/settings-ui.ts @@ -30,9 +30,9 @@ export const closeSettingsUI = () => settingsUISlice.actions.setTabAndOption({ t export const selectSettingsTab = (state: RootState) => state.settingsUI.tab; export const selectSettingsOption = (state: RootState) => state.settingsUI.option; -export const openOpenAIApiKeyPanel = () => settingsUISlice.actions.setTabAndOption({ tab: 'user', option: 'openai-api-key' }); +export const openOpenAIApiKeyPanel = () => settingsUISlice.actions.setTabAndOption({ tab: 'user', option: 'apiKey' }); export const openElevenLabsApiKeyPanel = () => settingsUISlice.actions.setTabAndOption({ tab: 'speech', option: 'elevenlabs-api-key' }); -export const openSystemPromptPanel = () => settingsUISlice.actions.setTabAndOption({ tab: 'options', option: 'system-prompt' }); +export const openSystemPromptPanel = () => settingsUISlice.actions.setTabAndOption({ tab: 'options', option: 'systemPrompt' }); export const openTemperaturePanel = () => settingsUISlice.actions.setTabAndOption({ tab: 'options', option: 'temperature' }); export default settingsUISlice.reducer; \ No newline at end of file diff --git a/app/src/store/voices.ts b/app/src/store/voices.ts deleted file mode 100644 index 970f8347..00000000 --- a/app/src/store/voices.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import type { RootState } from '.'; -import { defaultElevenLabsVoiceID } from '../tts/defaults'; - -const initialState = { - voice: defaultElevenLabsVoiceID, -}; - -export const voicesSlice = createSlice({ - name: 'voices', - initialState, - reducers: { - setVoice: (state, action: PayloadAction<string|null>) => { - state.voice = action.payload || ''; - }, - }, -}) - -export const { setVoice } = voicesSlice.actions; - -export const selectVoice = (state: RootState) => state.voices.voice; - -export default voicesSlice.reducer; \ No newline at end of file diff --git a/app/src/stub.js b/app/src/stub.js new file mode 100644 index 00000000..c3b42bc5 --- /dev/null +++ b/app/src/stub.js @@ -0,0 +1 @@ +module.exports = function() {}; \ No newline at end of file diff --git a/app/src/titles.ts b/app/src/titles.ts deleted file mode 100644 index 3ae66180..00000000 --- a/app/src/titles.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { createChatCompletion, defaultModel } from "./openai"; -import { OpenAIMessage, Chat } from "./types"; - -const systemPrompt = ` -Please read the following exchange and write a short, concise title describing the topic (in the user's language). -`.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, model: defaultModel, 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/app/src/tts/defaults.ts b/app/src/tts-plugins/elevenlabs-defaults.ts similarity index 100% rename from app/src/tts/defaults.ts rename to app/src/tts-plugins/elevenlabs-defaults.ts diff --git a/app/src/tts-plugins/elevenlabs.tsx b/app/src/tts-plugins/elevenlabs.tsx new file mode 100644 index 00000000..b9c8daf7 --- /dev/null +++ b/app/src/tts-plugins/elevenlabs.tsx @@ -0,0 +1,234 @@ +import { FormattedMessage } from "react-intl"; +import { PluginDescription } from "../core/plugins/plugin-description"; +import TTSPlugin from "../core/tts/tts-plugin"; +import { Voice } from "../core/tts/types"; +import { defaultElevenLabsVoiceID, defaultVoiceList } from "./elevenlabs-defaults"; +import { backend } from "../core/backend"; + +function isProxySupported() { + return !!backend.current?.user?.services?.includes('elevenlabs'); +} + +function shouldUseProxy(apiKey: string | undefined | null) { + return !apiKey && isProxySupported(); +} + +function getEndpoint(proxied = false) { + return proxied ? '/chatapi/proxies/elevenlabs' : 'https://api.elevenlabs.io'; +} + +function getVoiceFromElevenlabsVoiceObject(v: any) { + return { + service: "elevenlabs", + id: v.voice_id, + name: v.name, + sampleAudioURL: v.preview_url, + }; +} + +export interface ElevenLabsPluginOptions { + apiKey: string | null; + voice: string; + customVoiceID: string | null; +} + +/** + * Plugin for integrating with ElevenLabs Text-to-Speech service. + * + * If you want to add a plugin to support another cloud-based TTS service, this is a good example + * to use as a reference. + */ +export default class ElevenLabsPlugin extends TTSPlugin<ElevenLabsPluginOptions> { + static voices: Voice[] = defaultVoiceList.map(getVoiceFromElevenlabsVoiceObject); + + private proxied = shouldUseProxy(this.options?.apiKey); + private endpoint = getEndpoint(this.proxied); + + /** + * The `describe` function is responsible for providing a description of the ElevenLabsPlugin class, + * including its ID, name, and options. + * + * This information is used to configure the plugin and display its settings on the user interface. + * + * In this specific implementation, the `describe` function returns an object containing the plugin's + * ID ("elevenlabs"), name ("ElevenLabs Text-to-Speech"), and an array of options that can be + * configured by the user. These options include the API key, voice selection, and custom voice ID. + * + * Each option has its own set of properties, such as default values, display settings, and validation + * rules, which are used to render the plugin's settings on the user interface and ensure proper + * configuration. + */ + describe(): PluginDescription { + return { + id: "elevenlabs", + name: "ElevenLabs Text-to-Speech", + options: [ + { + id: "apiKey", + defaultValue: null, + + displayOnSettingsScreen: "speech", + displayAsSeparateSection: true, + resettable: false, + + renderProps: (value, options, context) => ({ + type: "password", + label: context.intl.formatMessage({ defaultMessage: "Your ElevenLabs API Key" }), + placeholder: context.intl.formatMessage({ defaultMessage: "Paste your API key here" }), + description: <> + <p> + <FormattedMessage + defaultMessage="Give ChatGPT a realisic human voice by connecting your ElevenLabs account (preview the available voices below). <a>Click here to sign up.</a>" + values={{ + a: (chunks: any) => <a href="https://beta.elevenlabs.io" target="_blank" rel="noreferrer">{chunks}</a> + }} /> + </p> + <p> + <FormattedMessage defaultMessage="You can find your API key by clicking your avatar or initials in the top right of the ElevenLabs website, then clicking Profile. Your API key is stored only on this device and never transmitted to anyone except ElevenLabs." /> + </p> + </>, + hidden: options.getOption('tts', 'service') !== 'elevenlabs', + }), + }, + { + id: "voice", + defaultValue: defaultElevenLabsVoiceID, + + displayOnSettingsScreen: "speech", + displayAsSeparateSection: true, + + renderProps: (value, options, context) => { + return { + type: "select", + label: "Voice", + disabled: !options.getOption('elevenlabs', 'apiKey') && !isProxySupported(), + hidden: options.getOption('tts', 'service') !== 'elevenlabs', + options: [ + ...ElevenLabsPlugin.voices.map(v => ({ + label: v.name!, + value: v.id, + })), + { + label: context.intl.formatMessage({ defaultMessage: "Custom Voice ID" }), + value: 'custom', + } + ], + }; + }, + }, + { + id: "customVoiceID", + defaultValue: null, + displayOnSettingsScreen: "speech", + renderProps: (value, options, context) => { + return { + type: "text", + label: context.intl.formatMessage({ defaultMessage: "Custom Voice ID" }), + + // hide when custom voice is not selected: + disabled: options.getOption('elevenlabs', 'voice') !== 'custom', + hidden: options.getOption('elevenlabs', 'voice') !== 'custom' || options.getOption('tts', 'service') !== 'elevenlabs', + }; + }, + validate: (value, options) => options.getOption('elevenlabs', 'voice') !== 'custom', + }, + ], + } + } + + /** + * Initializes the plugin by fetching available voices. + */ + async initialize() { + await this.getVoices(); + } + + /** + * Fetches and returns the available voices from ElevenLabs API. + * This function stores the list of voices in a static variable, which is used elsewhere. + * @returns {Promise<Voice[]>} A promise that resolves to an array of Voice objects. + */ + async getVoices(): Promise<Voice[]> { + const response = await fetch(`${this.endpoint}/v1/voices`, { + headers: this.createHeaders(), + }); + const json = await response.json(); + if (json?.voices?.length) { + ElevenLabsPlugin.voices = json.voices.map(getVoiceFromElevenlabsVoiceObject); + } + return ElevenLabsPlugin.voices; + } + + /** + * Returns the current voice based on the plugin options. + * @returns {Promise<Voice>} A promise that resolves to a Voice object. + */ + async getCurrentVoice(): Promise<Voice> { + let voiceID = this.options?.voice; + + // If using a custom voice ID, construct a voice object with the provided voice ID + if (voiceID === 'custom' && this.options?.customVoiceID) { + return { + service: 'elevenlabs', + id: this.options.customVoiceID, + name: 'Custom Voice', + }; + } + + // Search for a matching voice object + const voice = ElevenLabsPlugin.voices.find(v => v.id === voiceID); + if (voice) { + return voice; + } + + // If no matching voice is found, return a default Voice object + // with the defaultElevenLabsVoiceID and 'elevenlabs' as the service + return { + service: 'elevenlabs', + id: defaultElevenLabsVoiceID, + }; + } + + /** + * Converts the given text into speech using the specified voice and returns an audio file as a buffer. + * @param {string} text The text to be converted to speech. + * @param {Voice} [voice] The voice to be used for text-to-speech conversion. If not provided, the current voice will be used. + * @returns {Promise<ArrayBuffer | null>} A promise that resolves to an ArrayBuffer containing the audio data, or null if the conversion fails. + */ + async speakToBuffer(text: string, voice?: Voice): Promise<ArrayBuffer | null> { + if (!voice) { + voice = await this.getCurrentVoice(); + } + + const url = this.endpoint + '/v1/text-to-speech/' + voice.id; + + const response = await fetch(url, { + headers: this.createHeaders(), + method: 'POST', + body: JSON.stringify({ + text, + }), + }); + + if (response.ok) { + return await response.arrayBuffer(); + } else { + return null; + } + } + + /** + * Creates and returns the headers required for ElevenLabs API requests. + */ + private createHeaders(): Record<string, string> { + const headers: Record<string, string> = { + 'Content-Type': 'application/json', + } + + if (!this.proxied && this.options?.apiKey) { + headers['xi-api-key'] = this.options.apiKey; + } + + return headers; + } +} \ No newline at end of file diff --git a/app/src/tts-plugins/web-speech.ts b/app/src/tts-plugins/web-speech.ts new file mode 100644 index 00000000..3b0fb0fe --- /dev/null +++ b/app/src/tts-plugins/web-speech.ts @@ -0,0 +1,123 @@ +import { Voice } from "../core/tts/types"; +import DirectTTSPlugin from "../core/tts/direct-tts-plugin"; +import { PluginDescription } from "../core/plugins/plugin-description"; + +export interface WebSpeechPluginOptions { + voice: string | null; +} + +/** + * Plugin for integrating with the built-in Text-to-Speech service on the user's device via + * the Web Speech Synthesis API. + * + * If you want to add a plugin to support a cloud-based TTS service, this class is probably + * not relevant. Consider using ElevenLabsPlugin as an example instead. + */ +export default class WebSpeechPlugin extends DirectTTSPlugin<WebSpeechPluginOptions> { + static voices: Voice[] = []; + + private rejections: any[] = []; + private speaking = 0; + + async initialize() { + await this.getVoices(); + speechSynthesis.onvoiceschanged = () => this.getVoices(); + } + + describe(): PluginDescription { + const id = "web-speech"; + return { + id, + name: "Your Browser's Built-In Text-to-Speech", + options: [ + { + id: "voice", + defaultValue: null, + + displayOnSettingsScreen: "speech", + displayAsSeparateSection: true, + + renderProps: (value, options) => ({ + type: "select", + label: "Voice", + options: WebSpeechPlugin.voices.map(v => ({ + label: v.name!, + value: v.id, + })), + hidden: options.getOption('tts', 'service') !== id, + }), + }, + ], + } + } + + async getVoices() { + WebSpeechPlugin.voices = window.speechSynthesis.getVoices().map(v => ({ + service: 'web-speech', + id: v.name, + name: v.name, + })); + return WebSpeechPlugin.voices; + } + + async getCurrentVoice(): Promise<Voice> { + let voiceID = this.options?.voice; + + const voice = WebSpeechPlugin.voices.find(v => v.id === voiceID); + + if (voice) { + return voice; + } + + return WebSpeechPlugin.voices[0]; + } + + speak(text: string, voice?: Voice) { + return new Promise<void>(async (resolve, reject) => { + // this.stop(); + this.rejections.push(reject); + + if (!voice) { + voice = await this.getCurrentVoice(); + } + + const utterance = new SpeechSynthesisUtterance(text); + utterance.voice = window.speechSynthesis.getVoices().find(v => v.name === voice!.id)!; + + utterance.onstart = () => { + this.speaking++; + }; + utterance.onend = () => { + this.speaking--; + resolve(); + } + + speechSynthesis.speak(utterance); + }); + } + + async pause() { + if (!speechSynthesis.paused) { + speechSynthesis.pause(); + } + } + + async resume() { + if (speechSynthesis.paused) { + speechSynthesis.resume(); + } + } + + async stop() { + speechSynthesis.cancel(); + this.speaking = 0; + for (const reject of this.rejections) { + reject('cancelled'); + } + this.rejections = []; + } + + async isSpeaking() { + return this.speaking > 0; + } +} \ No newline at end of file diff --git a/app/src/tts/elevenlabs.tsx b/app/src/tts/elevenlabs.tsx deleted file mode 100644 index cb819720..00000000 --- a/app/src/tts/elevenlabs.tsx +++ /dev/null @@ -1,285 +0,0 @@ -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 { useAppDispatch, useAppSelector } from "../store"; -import { selectElevenLabsApiKey } from "../store/api-keys"; -import { selectVoice } from "../store/voices"; -import { openElevenLabsApiKeyPanel } from "../store/settings-ui"; -import { defaultElevenLabsVoiceID } from "./defaults"; -import { FormattedMessage, useIntl } from "react-intl"; - -const endpoint = 'https://api.elevenlabs.io'; - -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 elevenLabsApiKey = useAppSelector(selectElevenLabsApiKey); - const dispatch = useAppDispatch(); - const intl = useIntl(); - - const voice = useAppSelector(selectVoice); - - const [status, setStatus] = useState<'idle' | 'init' | 'playing' | 'buffering'>('idle'); - // const [error, setError] = useState(false); - const reader = useRef(new ElevenLabsReader()); - - useEffect(() => { - const currentReader = reader.current; - - currentReader.on('init', () => setStatus('init')); - currentReader.on('playing', () => setStatus('playing')); - currentReader.on('buffering', () => setStatus('buffering')); - currentReader.on('error', () => { - setStatus('idle'); - // setError(true); - }); - currentReader.on('done', () => setStatus('idle')); - - return () => { - currentReader.removeAllListeners(); - currentReader.stop(); - }; - }, [props.selector]); - - const onClick = useCallback(() => { - if (status === 'idle') { - if (!elevenLabsApiKey?.length) { - dispatch(openElevenLabsApiKeyPanel()); - return; - } - - audioContext.resume(); - reader.current.play(document.querySelector(props.selector)!, voice, elevenLabsApiKey); - } else { - reader.current.stop(); - } - }, [dispatch, status, props.selector, elevenLabsApiKey, voice]); - - return ( - <Button variant="subtle" size="sm" compact onClickCapture={onClick} loading={status === 'init'}> - {status !== 'init' && <i className="fa fa-headphones" />} - {status === 'idle' && <span> - <FormattedMessage defaultMessage="Play" description="Label for the button that starts text-to-speech playback" /> - </span>} - {status === 'buffering' && <span> - <FormattedMessage defaultMessage="Loading audio..." description="Message indicating that text-to-speech audio is buffering" /> - </span>} - {status !== 'idle' && status !== 'buffering' && <span> - <FormattedMessage defaultMessage="Stop" description="Label for the button that stops text-to-speech playback" /> - </span>} - </Button> - ); -} diff --git a/app/src/utils.ts b/app/src/utils.ts deleted file mode 100644 index 345b07d1..00000000 --- a/app/src/utils.ts +++ /dev/null @@ -1,61 +0,0 @@ -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; -} - -export class AsyncLoop { - public cancelled = false; - - constructor(private handler: any, private pauseBetween: number = 1000) { - } - - public async start() { - this.loop().then(() => {}); - } - - private async loop() { - while (!this.cancelled) { - try { - await this.handler(); - } catch (e) { - console.error(e); - } - - await sleep(this.pauseBetween); - } - } -} \ No newline at end of file diff --git a/server/generate-self-signed-certificate.sh b/server/generate-self-signed-certificate.sh new file mode 100755 index 00000000..e85136a9 --- /dev/null +++ b/server/generate-self-signed-certificate.sh @@ -0,0 +1,13 @@ +#!/usr/bin/bash + +if [ -z "$DOMAIN" ]; then + DOMAIN=localhost +fi + +mkdir -p data && \ + cd data && \ + openssl genrsa -out key.pem 2048 && \ + openssl req -new -key key.pem -out csr.pem -subj "/C=US/ST=California/L=San Francisco/O=ChatWithGPT/OU=ChatWithGPT/CN=localhost" && \ + openssl x509 -req -days 365 -in csr.pem -signkey key.pem -out cert.pem && \ + rm csr.pem && \ + echo "Generated self-signed certificate." \ No newline at end of file diff --git a/server/package.json b/server/package.json index a6b0df88..52b22a77 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "chat-with-gpt", - "version": "0.2.1", + "version": "0.2.3", "description": "An open-source ChatGPT app with a voice", "main": "index.js", "scripts": { @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.282.0", + "@msgpack/msgpack": "^3.0.0-beta2", "@types/bcrypt": "^5.0.0", "@types/compression": "^1.7.2", "@types/connect-sqlite3": "^0.9.2", @@ -42,7 +43,9 @@ "idb-keyval": "^6.2.0", "jsonwebtoken": "^9.0.0", "jwks-rsa": "^3.0.1", + "knex": "^2.4.2", "launchdarkly-eventsource": "^1.4.4", + "lib0": "^0.2.73", "localforage": "^1.10.0", "match-sorter": "^6.3.1", "nanoid": "^4.0.1", @@ -50,10 +53,12 @@ "passport": "^0.6.0", "passport-local": "^1.0.0", "pg": "^8.9.0", - "react-router-dom": "^6.8.2", "sort-by": "^0.0.2", "sqlite3": "^5.1.4", "ts-node": "^10.9.1", - "xhr2": "^0.2.1" + "xhr2": "^0.2.1", + "y-protocols": "^1.0.5", + "yaml": "^2.2.1", + "yjs": "^13.5.51" } } diff --git a/server/src/auth0.ts b/server/src/auth0.ts index 87d48b07..c67959d3 100644 --- a/server/src/auth0.ts +++ b/server/src/auth0.ts @@ -1,16 +1,14 @@ -import crypto from 'crypto'; import { auth, ConfigParams } from 'express-openid-connect'; import ChatServer from './index'; +import { config } from './config'; -const secret = process.env.AUTH_SECRET || crypto.randomBytes(32).toString('hex'); - -const config: ConfigParams = { +const auth0Config: ConfigParams = { authRequired: false, auth0Logout: false, - secret, - baseURL: process.env.PUBLIC_URL, - clientID: process.env.AUTH0_CLIENT_ID, - issuerBaseURL: process.env.AUTH0_ISSUER, + secret: config.authSecret, + baseURL: config.publicSiteURL, + clientID: config.auth0?.clientID, + issuerBaseURL: config.auth0?.issuer, routes: { login: false, logout: false, @@ -18,26 +16,36 @@ const config: ConfigParams = { }; export function configureAuth0(context: ChatServer) { - context.app.use(auth(config)); + if (!config.publicSiteURL) { + throw new Error('Missing public site URL in config, required for Auth0'); + } + if (!config.auth0?.clientID) { + throw new Error('Missing Auth0 client ID in config'); + } + if (!config.auth0?.issuer) { + throw new Error('Missing Auth0 issuer in config'); + } + + context.app.use(auth(auth0Config)); context.app.get('/chatapi/login', (req, res) => { res.oidc.login({ - returnTo: process.env.PUBLIC_URL, + returnTo: config.publicSiteURL, authorizationParams: { - redirect_uri: process.env.PUBLIC_URL + '/chatapi/login-callback', + redirect_uri: config.publicSiteURL + '/chatapi/login-callback', }, }); }); context.app.get('/chatapi/logout', (req, res) => { res.oidc.logout({ - returnTo: process.env.PUBLIC_URL, + returnTo: config.publicSiteURL, }); }); context.app.all('/chatapi/login-callback', (req, res) => { res.oidc.callback({ - redirectUri: process.env.PUBLIC_URL!, - }) + redirectUri: config.publicSiteURL!, + }); }); } \ No newline at end of file diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 00000000..f1b69d27 --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,140 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { parse } from 'yaml'; +import type { Knex } from 'knex'; + +/** + * The Config interface represents the configuration settings for various components + * of the application, such as the server, database and external services. + * + * You may provide a `config.yaml` file in the `data` directory to override the default values. + * (Or you can set the `CHATWITHGPT_CONFIG_FILENAME` environment variable to point to a different file.) + */ +export interface Config { + services?: { + openai?: { + // The API key required to authenticate with the OpenAI service. + // When provided, signed in users will be able to access OpenAI through the server + // without needing their own API key. + apiKey?: string; + }; + + elevenlabs?: { + // The API key required to authenticate with the ElevenLabs service. + // When provided, signed in users will be able to access ElevenLabs through the server + // without needing their own API key. + apiKey?: string; + }; + }; + + /* + Optional configuration for enabling Transport Layer Security (TLS) in the server. + Requires specifying the file paths for the key and cert files. Includes: + - key: The file path to the TLS private key file. + - cert: The file path to the TLS certificate file. + */ + tls?: { + selfSigned?: boolean; + key?: string; + cert?: string; + }; + + /* + The configuration object for the Knex.js database client. + Detailed configuration options can be found in the Knex.js documentation: + https://knexjs.org/guide/#configuration-options + */ + database: Knex.Config; + + /* + The secret session key used to encrypt the session cookie. + If not provided, a random key will be generated. + Changing this value will invalidate all existing sessions. + */ + authSecret: string; + + /* + Optional configuration object for the Auth0 authentication service. + If provided, the server will use Auth0 for authentication. + Otherwise, it will use a local authentication system. + */ + auth0?: { + clientID?: string; + issuer?: string; + }; + + /* + The URL of the public-facing server. + */ + publicSiteURL?: string; + + /* + The configuration object for the rate-limiting middleware. + Each IP address is limited to a certain number of requests (max) per time window (windowMs). + Detailed configuration options can be found in the Express Rate Limit documentation: + https://www.npmjs.com/package/express-rate-limit + */ + rateLimit: { + windowMs?: number; + max?: number; + }; +} + +// default config: +let config: Config = { + authSecret: crypto.randomBytes(32).toString('hex'), + database: { + client: 'sqlite3', + connection: { + filename: './data/chat.sqlite', + }, + useNullAsDefault: true, + }, + rateLimit: { + // limit each IP to 100 requests per minute: + max: 100, + windowMs: 60 * 1000, // 1 minute + } +}; + +if (!fs.existsSync('./data')) { + fs.mkdirSync('./data'); +} + +const filename = process.env.CHATWITHGPT_CONFIG_FILENAME + ? path.resolve(process.env.CHATWITHGPT_CONFIG_FILENAME) + : path.resolve(__dirname, '../data/config.yaml'); + +if (fs.existsSync(filename)) { + config = { + ...config, + ...parse(fs.readFileSync(filename).toString()), + }; + console.log("Loaded config from:", filename); +} + +if (process.env.AUTH_SECRET) { + config.authSecret = process.env.AUTH_SECRET; +} + +if (process.env.RATE_LIMIT_WINDOW_MS) { + config.rateLimit.windowMs = parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10); +} +if (process.env.RATE_LIMIT_MAX) { + config.rateLimit.max = parseInt(process.env.RATE_LIMIT_MAX, 10); +} + +if (process.argv.includes('--self-signed')) { + config.tls = { + selfSigned: true, + }; +} + +if (config.publicSiteURL) { + config.publicSiteURL = config.publicSiteURL.replace(/\/$/, ''); +} + +export { + config +}; \ No newline at end of file diff --git a/server/src/database/index.ts b/server/src/database/index.ts index 703d2ff6..be943642 100644 --- a/server/src/database/index.ts +++ b/server/src/database/index.ts @@ -1,3 +1,11 @@ +import ExpiryMap from "expiry-map"; + +// @ts-ignore +import type { Doc } from "yjs"; + +// const documents = new ExpiryMap<string, Doc>(60 * 60 * 1000); +const documents = new ExpiryMap<string, Doc>(48 * 60 * 60 * 1000); + export default abstract class Database { public async initialize() {} public abstract createUser(email: string, passwordHash: Buffer): Promise<void>; @@ -14,4 +22,17 @@ export default abstract class Database { public abstract setTitle(userID: string, chatID: string, title: string): Promise<void>; public abstract deleteChat(userID: string, chatID: string): Promise<any>; public abstract getDeletedChatIDs(userID: string): Promise<string[]>; + + protected abstract loadYDoc(userID: string): Promise<Doc>; + public abstract saveYUpdate(userID: string, update: Uint8Array): Promise<void>; + + public async getYDoc(userID: string): Promise<Doc> { + const doc = documents.get(userID); + if (doc) { + return doc; + } + const newDoc = await this.loadYDoc(userID); + documents.set(userID, newDoc); + return newDoc; + } } \ No newline at end of file diff --git a/server/src/database/knex.ts b/server/src/database/knex.ts new file mode 100644 index 00000000..0b5e1228 --- /dev/null +++ b/server/src/database/knex.ts @@ -0,0 +1,204 @@ +import { validate as validateEmailAddress } from 'email-validator'; +import { Knex, knex as KnexClient } from 'knex'; +import Database from "./index"; +import { config } from '../config'; + +const tableNames = { + authentication: 'authentication', + chats: 'chats', + deletedChats: 'deleted_chats', + messages: 'messages', + shares: 'shares', + yjsUpdates: 'updates', +}; + +export default class KnexDatabaseAdapter extends Database { + private knex = KnexClient(this.knexConfig); + + constructor(private knexConfig: Knex.Config = config.database) { + super(); + } + + public async initialize() { + await this.createTables(); + } + + private async createTables() { + await this.createTableIfNotExists(tableNames.authentication, (table) => { + table.text('id').primary(); + table.text('email'); + table.binary('password_hash'); + table.binary('salt'); + }); + + await this.createTableIfNotExists(tableNames.chats, (table) => { + table.text('id').primary(); + table.text('user_id'); + table.text('title'); + }); + + await this.createTableIfNotExists(tableNames.deletedChats, (table) => { + table.text('id').primary(); + table.text('user_id'); + table.dateTime('deleted_at'); + }); + + await this.createTableIfNotExists(tableNames.messages, (table) => { + table.text('id').primary(); + table.text('user_id'); + table.text('chat_id'); + table.text('data'); + }); + + await this.createTableIfNotExists(tableNames.shares, (table) => { + table.text('id').primary(); + table.text('user_id'); + table.dateTime('created_at'); + }); + + await this.createTableIfNotExists(tableNames.yjsUpdates, (table) => { + table.increments('id').primary(); + table.text('user_id'); + table.binary('update'); + table.index('user_id'); + }); + } + + private async createTableIfNotExists(tableName: string, tableBuilderCallback: (tableBuilder: Knex.CreateTableBuilder) => any) { + const exists = await this.knex.schema.hasTable(tableName); + if (!exists) { + await this.knex.schema.createTable(tableName, tableBuilderCallback); + } + } + + public async createUser(email: string, passwordHash: Buffer): Promise<void> { + if (!validateEmailAddress(email)) { + throw new Error('invalid email address'); + } + + await this.knex(tableNames.authentication).insert({ + id: email, + email, + password_hash: passwordHash, + }); + } + + public async getUser(email: string): Promise<any> { + const row = await this.knex(tableNames.authentication) + .where('email', email) + .first(); + + if (!row) { + return null; + } + + return { + ...row, + passwordHash: Buffer.from(row.password_hash), + salt: row.salt ? Buffer.from(row.salt) : null, + }; + } + + public async getChats(userID: string): Promise<any[]> { + return await this.knex(tableNames.chats) + .where('user_id', userID).select(); + } + + public async getMessages(userID: string): Promise<any[]> { + const rows = await this.knex(tableNames.messages) + .where('user_id', userID).select(); + + return rows.map((row: any) => { + // row.data = JSON.parse(row.data); + return row; + }); + } + + public async insertMessages(userID: string, messages: any[]): Promise<void> { + // deprecated + } + + public async createShare(userID: string | null, id: string): Promise<boolean> { + await this.knex(tableNames.shares) + .insert({ + id, + user_id: userID, + created_at: new Date(), + }); + + return true; + } + + public async setTitle(userID: string, chatID: string, title: string): Promise<void> { + // deprecated + } + + public async deleteChat(userID: string, chatID: string): Promise<any> { + await this.knex.transaction(async (trx) => { + await trx(tableNames.chats).where({ id: chatID, user_id: userID }).delete(); + await trx(tableNames.messages).where({ chat_id: chatID, user_id: userID }).delete(); + await trx(tableNames.deletedChats) + .insert({ id: chatID, user_id: userID, deleted_at: new Date() }); + }); + } + + public async getDeletedChatIDs(userID: string): Promise<string[]> { + const rows = await this.knex(tableNames.deletedChats) + .where('user_id', userID) + .select(); + return rows.map((row: any) => row.id); + } + + protected async loadYDoc(userID: string) { + const Y = await import('yjs'); + + const ydoc = new Y.Doc(); + + const updates = await this.knex(tableNames.yjsUpdates) + .where('user_id', userID) + .select(); + + updates.forEach((updateRow: any) => { + try { + const update = new Uint8Array(updateRow.update); + if (update.byteLength > 4) { + Y.applyUpdate(ydoc, update); + } + } catch (e) { + console.error('failed to apply update', updateRow, e); + } + }); + + const merged = Y.encodeStateAsUpdate(ydoc); + + if (updates.length) { + // In a transaction, insert the merged update, then delete all previous updates (lower ID). + // This needs to be done together in a transaction to avoid consistency errors or data loss! + await this.knex.transaction(async (trx) => { + await trx(tableNames.yjsUpdates) + .insert({ + user_id: userID, + update: Buffer.from(merged), + }); + + await trx(tableNames.yjsUpdates) + .where('user_id', userID) + .where('id', '<', updates[updates.length - 1].id) + .delete(); + }); + } + + return ydoc; + } + + public async saveYUpdate(userID: string, update: Uint8Array): Promise<void> { + if (update.byteLength <= 4) { + return; + } + await this.knex(tableNames.yjsUpdates) + .insert({ + user_id: userID, + update: Buffer.from(update), + }); + } +} \ No newline at end of file diff --git a/server/src/database/sqlite.ts b/server/src/database/sqlite.ts deleted file mode 100644 index a451ea8c..00000000 --- a/server/src/database/sqlite.ts +++ /dev/null @@ -1,202 +0,0 @@ -import fs from 'fs'; -import { verbose } from "sqlite3"; -import { validate as validateEmailAddress } from 'email-validator'; -import Database from "./index"; - -const sqlite3 = verbose(); - -if (!fs.existsSync('./data')) { - fs.mkdirSync('./data'); -} - -const db = new sqlite3.Database('./data/chat.sqlite'); - -// interface ChatRow { -// id: string; -// user_id: string; -// title: string; -// } - -// interface MessageRow { -// id: string; -// user_id: string; -// chat_id: string; -// data: any; -// } - -// interface ShareRow { -// id: string; -// user_id: string; -// created_at: Date; -// } - -export class SQLiteAdapter extends Database { - public async initialize() { - db.serialize(() => { - db.run(`CREATE TABLE IF NOT EXISTS authentication ( - id TEXT PRIMARY KEY, - email TEXT, - password_hash BLOB, - salt BLOB - )`); - - db.run(`CREATE TABLE IF NOT EXISTS chats ( - id TEXT PRIMARY KEY, - user_id TEXT, - title TEXT - )`); - - db.run(`CREATE TABLE IF NOT EXISTS deleted_chats ( - id TEXT PRIMARY KEY, - user_id TEXT, - deleted_at DATETIME - )`); - - db.run(`CREATE TABLE IF NOT EXISTS messages ( - id TEXT PRIMARY KEY, - user_id TEXT, - chat_id TEXT, - data TEXT - )`); - - db.run(`CREATE TABLE IF NOT EXISTS shares ( - id TEXT PRIMARY KEY, - user_id TEXT, - created_at DATETIME - )`); - }); - } - - public createUser(email: string, passwordHash: Buffer): Promise<void> { - return new Promise((resolve, reject) => { - if (!validateEmailAddress(email)) { - reject(new Error('invalid email address')); - return; - } - - db.run(`INSERT INTO authentication (id, email, password_hash) VALUES (?, ?, ?)`, [email, email, passwordHash], (err) => { - if (err) { - reject(err); - console.log(`[database:sqlite] failed to create user ${email}`); - } else { - resolve(); - console.log(`[database:sqlite] created user ${email}`); - } - }); - }); - } - - public async getUser(email: string): Promise<any> { - return new Promise((resolve, reject) => { - db.get(`SELECT * FROM authentication WHERE email = ?`, [email], (err: any, row: any) => { - if (err) { - reject(err); - console.log(`[database:sqlite] failed to get user ${email}`); - } else if (!row) { - resolve(null); - } else { - resolve({ - ...row, - passwordHash: Buffer.from(row.password_hash), - salt: row.salt ? Buffer.from(row.salt) : null, - }); - console.log(`[database:sqlite] retrieved user ${email}`); - } - }); - }); - } - - public async getChats(userID: string): Promise<any[]> { - return new Promise((resolve, reject) => { - db.all(`SELECT * FROM chats WHERE user_id = ?`, [userID], (err: any, rows: any) => { - if (err) { - reject(err); - console.log(`[database:sqlite] failed to get chats for user ${userID}`); - } else { - resolve(rows); - console.log(`[database:sqlite] retrieved ${rows.length} chats for user ${userID}`); - } - }); - }); - } - - public async getMessages(userID: string): Promise<any[]> { - return new Promise((resolve, reject) => { - db.all(`SELECT * FROM messages WHERE user_id = ?`, [userID], (err: any, rows: any) => { - if (err) { - reject(err); - console.log(`[database:sqlite] failed to get messages for user ${userID}`); - } else { - resolve(rows.map((row: any) => { - row.data = JSON.parse(row.data); - return row; - })); - console.log(`[database:sqlite] retrieved ${rows.length} messages for user ${userID}`); - } - }); - }); - } - - public async insertMessages(userID: string, messages: any[]): Promise<void> { - return new Promise((resolve, reject) => { - db.serialize(() => { - const stmt = db.prepare(`INSERT OR IGNORE INTO messages (id, user_id, chat_id, data) VALUES (?, ?, ?, ?)`); - messages.forEach((message) => { - stmt.run(message.id, userID, message.chatID, JSON.stringify(message)); - }); - stmt.finalize(); - console.log(`[database:sqlite] inserted ${messages.length} messages`); - resolve(); - }); - }); - } - - public async createShare(userID: string|null, id: string): Promise<boolean> { - return new Promise((resolve, reject) => { - db.run(`INSERT INTO shares (id, user_id, created_at) VALUES (?, ?, ?)`, [id, userID, new Date()], (err) => { - if (err) { - reject(err); - console.log(`[database:sqlite] failed to create share ${id}`); - } else { - resolve(true); - console.log(`[database:sqlite] created share ${id}`) - } - }); - }); - } - - public async setTitle(userID: string, chatID: string, title: string): Promise<void> { - return new Promise((resolve, reject) => { - db.run(`INSERT OR IGNORE INTO chats (id, user_id, title) VALUES (?, ?, ?)`, [chatID, userID, title], (err) => { - if (err) { - reject(err); - console.log(`[database:sqlite] failed to set title for chat ${chatID}`); - } else { - resolve(); - console.log(`[database:sqlite] set title for chat ${chatID}`) - } - }); - }); - } - - public async deleteChat(userID: string, chatID: string): Promise<any> { - db.serialize(() => { - db.run(`DELETE FROM chats WHERE id = ? AND user_id = ?`, [chatID, userID]); - db.run(`DELETE FROM messages WHERE chat_id = ? AND user_id = ?`, [chatID, userID]); - db.run(`INSERT INTO deleted_chats (id, user_id, deleted_at) VALUES (?, ?, ?)`, [chatID, userID, new Date()]); - console.log(`[database:sqlite] deleted chat ${chatID}`); - }); - } - - public async getDeletedChatIDs(userID: string): Promise<string[]> { - return new Promise((resolve, reject) => { - db.all(`SELECT * FROM deleted_chats WHERE user_id = ?`, [userID], (err: any, rows: any) => { - if (err) { - reject(err); - } else { - resolve(rows.map((row: any) => row.id)); - } - }); - }); - } -} \ No newline at end of file diff --git a/server/src/endpoints/base.ts b/server/src/endpoints/base.ts index 0a068263..f6f1d6dd 100644 --- a/server/src/endpoints/base.ts +++ b/server/src/endpoints/base.ts @@ -1,5 +1,11 @@ import express from 'express'; import ChatServer from '../index'; +import ExpirySet from 'expiry-set'; + +const recentUsers = new ExpirySet<string>(1000 * 60 * 5); +export function getActiveUsersInLast5Minutes() { + return Array.from(recentUsers.values()); +} export default abstract class RequestHandler { constructor(public context: ChatServer, private req: express.Request, private res: express.Response) { @@ -12,6 +18,10 @@ export default abstract class RequestHandler { return; } + if (this.userID) { + recentUsers.add(this.userID); + } + try { return await this.handler(this.req, this.res); } catch (e) { diff --git a/server/src/endpoints/completion/basic.ts b/server/src/endpoints/completion/basic.ts deleted file mode 100644 index 3292e761..00000000 --- a/server/src/endpoints/completion/basic.ts +++ /dev/null @@ -1,20 +0,0 @@ -import express from 'express'; -import { Configuration, OpenAIApi } from "openai"; -import RequestHandler from "../base"; - -const configuration = new Configuration({ - apiKey: process.env.OPENAI_API_KEY, -}); - -const openai = new OpenAIApi(configuration); - -export default class BasicCompletionRequestHandler extends RequestHandler { - async handler(req: express.Request, res: express.Response) { - const response = await openai.createChatCompletion(req.body); - res.json(response); - } - - public isProtected() { - return true; - } -} \ No newline at end of file diff --git a/server/src/endpoints/completion/streaming.ts b/server/src/endpoints/completion/streaming.ts deleted file mode 100644 index ce18fd61..00000000 --- a/server/src/endpoints/completion/streaming.ts +++ /dev/null @@ -1,57 +0,0 @@ -// @ts-ignore -import { EventSource } from "launchdarkly-eventsource"; -import express from 'express'; -import RequestHandler from "../base"; - -export default class StreamingCompletionRequestHandler extends RequestHandler { - async handler(req: express.Request, res: express.Response) { - res.set({ - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }); - - const eventSource = new EventSource('https://api.openai.com/v1/chat/completions', { - method: "POST", - headers: { - 'Accept': 'application/json, text/plain, */*', - 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - ...req.body, - stream: true, - }), - }); - - eventSource.addEventListener('message', async (event: any) => { - res.write(`data: ${event.data}\n\n`); - res.flush(); - - if (event.data === '[DONE]') { - res.end(); - eventSource.close(); - } - }); - - eventSource.addEventListener('error', (event: any) => { - res.end(); - }); - - eventSource.addEventListener('abort', (event: any) => { - res.end(); - }); - - req.on('close', () => { - eventSource.close(); - }); - - res.on('error', () => { - eventSource.close(); - }); - } - - public isProtected() { - return true; - } -} \ No newline at end of file diff --git a/server/src/endpoints/messages.ts b/server/src/endpoints/messages.ts deleted file mode 100644 index fb352c1f..00000000 --- a/server/src/endpoints/messages.ts +++ /dev/null @@ -1,18 +0,0 @@ -import express from 'express'; -import RequestHandler from "./base"; - -export default class MessagesRequestHandler extends RequestHandler { - async handler(req: express.Request, res: express.Response) { - if (!req.body.messages?.length) { - console.log("Invalid request") - res.sendStatus(400); - return; - } - await this.context.database.insertMessages(this.userID!, req.body.messages); - res.json({ status: 'ok' }); - } - - public isProtected() { - return true; - } -} \ No newline at end of file diff --git a/server/src/endpoints/service-proxies/elevenlabs/text-to-speech.ts b/server/src/endpoints/service-proxies/elevenlabs/text-to-speech.ts new file mode 100644 index 00000000..8f35aef4 --- /dev/null +++ b/server/src/endpoints/service-proxies/elevenlabs/text-to-speech.ts @@ -0,0 +1,28 @@ +import express from 'express'; +import RequestHandler from "../../base"; +import axios from 'axios'; +import { config } from '../../../config'; + +export const endpoint = 'https://api.elevenlabs.io'; +export const apiKey = config.services?.elevenlabs?.apiKey || process.env.ELEVENLABS_API_KEY; + +export default class ElevenLabsTTSProxyRequestHandler extends RequestHandler { + async handler(req: express.Request, res: express.Response) { + const voiceID = req.params.voiceID; + const response = await axios.post(endpoint + '/v1/text-to-speech/' + voiceID, + JSON.stringify(req.body), + { + headers: { + 'xi-api-key': apiKey || '', + 'content-type': 'application/json', + }, + responseType: 'arraybuffer', + }); + res.setHeader('Content-Type', response.headers['content-type'] || 'audio/mpeg'); + res.send(response.data); + } + + public isProtected() { + return true; + } +} \ No newline at end of file diff --git a/server/src/endpoints/service-proxies/elevenlabs/voices.ts b/server/src/endpoints/service-proxies/elevenlabs/voices.ts new file mode 100644 index 00000000..d9f2a2d3 --- /dev/null +++ b/server/src/endpoints/service-proxies/elevenlabs/voices.ts @@ -0,0 +1,21 @@ +import express from 'express'; +import RequestHandler from "../../base"; +import axios from 'axios'; +import { endpoint, apiKey } from './text-to-speech'; + +export default class ElevenLabsVoicesProxyRequestHandler extends RequestHandler { + async handler(req: express.Request, res: express.Response) { + const response = await axios.get(endpoint + '/v1/voices', + { + headers: { + 'xi-api-key': apiKey || '', + 'content-type': 'application/json', + } + }); + res.json(response.data); + } + + public isProtected() { + return true; + } +} \ No newline at end of file diff --git a/server/src/endpoints/service-proxies/openai/basic.ts b/server/src/endpoints/service-proxies/openai/basic.ts new file mode 100644 index 00000000..cccefa74 --- /dev/null +++ b/server/src/endpoints/service-proxies/openai/basic.ts @@ -0,0 +1,15 @@ +import express from 'express'; +import axios from 'axios'; +import { apiKey, endpoint } from '.'; + +export async function basicHandler(req: express.Request, res: express.Response) { + const response = await axios.post(endpoint, JSON.stringify(req.body), { + headers: { + 'Accept': 'application/json, text/plain, */*', + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }) + + res.json(response.data); +} \ No newline at end of file diff --git a/server/src/endpoints/service-proxies/openai/index.ts b/server/src/endpoints/service-proxies/openai/index.ts new file mode 100644 index 00000000..5c1eca1e --- /dev/null +++ b/server/src/endpoints/service-proxies/openai/index.ts @@ -0,0 +1,22 @@ +import express from 'express'; +import RequestHandler from "../../base"; +import { streamingHandler } from './streaming'; +import { basicHandler } from './basic'; +import { config } from '../../../config'; + +export const endpoint = 'https://api.openai.com/v1/chat/completions'; +export const apiKey = config.services?.openai?.apiKey || process.env.OPENAI_API_KEY; + +export default class OpenAIProxyRequestHandler extends RequestHandler { + async handler(req: express.Request, res: express.Response) { + if (req.body?.stream) { + await streamingHandler(req, res); + } else { + await basicHandler(req, res); + } + } + + public isProtected() { + return true; + } +} \ No newline at end of file diff --git a/server/src/endpoints/service-proxies/openai/streaming.ts b/server/src/endpoints/service-proxies/openai/streaming.ts new file mode 100644 index 00000000..f8f87028 --- /dev/null +++ b/server/src/endpoints/service-proxies/openai/streaming.ts @@ -0,0 +1,51 @@ +// @ts-ignore +import { EventSource } from "launchdarkly-eventsource"; +import express from 'express'; +import { apiKey } from "."; + +export async function streamingHandler(req: express.Request, res: express.Response) { + res.set({ + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + + const eventSource = new EventSource('https://api.openai.com/v1/chat/completions', { + method: "POST", + headers: { + 'Accept': 'application/json, text/plain, */*', + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ...req.body, + stream: true, + }), + }); + + eventSource.addEventListener('message', async (event: any) => { + res.write(`data: ${event.data}\n\n`); + res.flush(); + + if (event.data === '[DONE]') { + res.end(); + eventSource.close(); + } + }); + + eventSource.addEventListener('error', (event: any) => { + res.end(); + }); + + eventSource.addEventListener('abort', (event: any) => { + res.end(); + }); + + req.on('close', () => { + eventSource.close(); + }); + + res.on('error', e => { + eventSource.close(); + }); +} \ No newline at end of file diff --git a/server/src/endpoints/session.ts b/server/src/endpoints/session.ts index 28d7de9d..23ea0c18 100644 --- a/server/src/endpoints/session.ts +++ b/server/src/endpoints/session.ts @@ -1,20 +1,25 @@ import express from 'express'; import RequestHandler from "./base"; +import { config } from '../config'; export default class SessionRequestHandler extends RequestHandler { async handler(req: express.Request, res: express.Response) { const request = req as any; + const availableServiceNames = Object.keys(config.services || {}) + .filter(key => (config.services as any)?.[key]?.apiKey); + if (request.oidc) { const user = request.oidc.user; - console.log(user); if (user) { res.json({ + authProvider: this.context.authProvider, authenticated: true, userID: user.sub, name: user.name, email: user.email, picture: user.picture, + services: availableServiceNames, }); return; } @@ -23,14 +28,17 @@ export default class SessionRequestHandler extends RequestHandler { const userID = request.session?.passport?.user?.id; if (userID) { res.json({ + authProvider: this.context.authProvider, authenticated: true, userID, email: userID, + services: availableServiceNames, }); return; } res.json({ + authProvider: this.context.authProvider, authenticated: false, }); } diff --git a/server/src/endpoints/sync-legacy.ts b/server/src/endpoints/sync-legacy.ts new file mode 100644 index 00000000..ad76a32f --- /dev/null +++ b/server/src/endpoints/sync-legacy.ts @@ -0,0 +1,46 @@ +import express from 'express'; + +import RequestHandler from "./base"; +import ExpiryMap from 'expiry-map'; + +const cache = new ExpiryMap<string, any>(1000 * 60 * 60); + +interface Chat { + id: string; + messages: any[]; + title?: string | null; +} + +export default class LegacySyncRequestHandler extends RequestHandler { + async handler(req: express.Request, res: express.Response) { + if (cache.has(this.userID!)) { + res.json(cache.get(this.userID!)); + return; + } + + const [chats, messages, deletedChatIDs] = await Promise.all([ + this.context.database.getChats(this.userID!), + this.context.database.getMessages(this.userID!), + this.context.database.getDeletedChatIDs(this.userID!), + ]); + + + const response: Chat[] = []; + + for (const chat of chats) { + if (!deletedChatIDs.includes(chat.id)) { + const chatMessages = messages.filter((message) => message.chat_id === chat.id).map(m => m.data); + + response.push({ + id: chat.id, + messages: chatMessages, + title: chat.title, + }); + } + } + + cache.set(this.userID!, response); + + res.json(response); + } +} \ No newline at end of file diff --git a/server/src/endpoints/sync.ts b/server/src/endpoints/sync.ts index 32c4be84..80a3e8d1 100644 --- a/server/src/endpoints/sync.ts +++ b/server/src/endpoints/sync.ts @@ -1,39 +1,59 @@ import express from 'express'; +import { encode } from '@msgpack/msgpack'; +import ExpirySet from 'expiry-set'; + import RequestHandler from "./base"; +let totalUpdatesProcessed = 0; +const recentUpdates = new ExpirySet<number>(1000 * 60 * 5); + +export function getNumUpdatesProcessedIn5Minutes() { + return recentUpdates.size; +} + export default class SyncRequestHandler extends RequestHandler { async handler(req: express.Request, res: express.Response) { - const [chats, messages, deletedChatIDs] = await Promise.all([ - this.context.database.getChats(this.userID!), - this.context.database.getMessages(this.userID!), - this.context.database.getDeletedChatIDs(this.userID!), - ]); - - const output: Record<string, any> = {}; - - for (const m of messages) { - const chat = output[m.chat_id] || { - messages: [], - }; - chat.messages.push(m.data); - output[m.chat_id] = chat; - } + const encoding = await import('lib0/encoding'); + const decoding = await import('lib0/decoding'); + const syncProtocol = await import('y-protocols/sync'); - for (const c of chats) { - const chat = output[c.id] || { - messages: [], - }; - chat.title = c.title; - output[c.id] = chat; - } + const doc = await this.context.database.getYDoc(this.userID!); + + const Y = await import('yjs'); + + const encoder = encoding.createEncoder(); + const decoder = decoding.createDecoder(req.body); + + const messageType = decoding.readVarUint(decoder); - for (const chatID of deletedChatIDs) { - output[chatID] = { - deleted: true - }; + if (messageType === syncProtocol.messageYjsSyncStep2 || messageType === syncProtocol.messageYjsUpdate) { + await this.context.database.saveYUpdate(this.userID!, + decoding.readVarUint8Array(decoder)); + } + + decoder.pos = 0; + + syncProtocol.readSyncMessage(decoder, encoder, doc, 'server'); + + const responseBuffers = [ + encoding.toUint8Array(encoder), + ]; + + if (messageType === syncProtocol.messageYjsSyncStep1) { + const encoder = encoding.createEncoder(); + syncProtocol.writeSyncStep1(encoder, doc); + responseBuffers.push(encoding.toUint8Array(encoder)); + } else if (messageType === syncProtocol.messageYjsUpdate) { + totalUpdatesProcessed += 1; + recentUpdates.add(totalUpdatesProcessed); } - res.json(output); + const buffer = Buffer.from(encode(responseBuffers)); + + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Content-Length', buffer.length); + + res.send(buffer); } public isProtected() { diff --git a/server/src/endpoints/title.ts b/server/src/endpoints/title.ts deleted file mode 100644 index b4918b14..00000000 --- a/server/src/endpoints/title.ts +++ /dev/null @@ -1,13 +0,0 @@ -import express from 'express'; -import RequestHandler from "./base"; - -export default class TitleRequestHandler extends RequestHandler { - async handler(req: express.Request, res: express.Response) { - await this.context.database.setTitle(this.userID!, req.body.id, req.body.title); - res.json({ status: 'ok' }); - } - - public isProtected() { - return true; - } -} \ No newline at end of file diff --git a/server/src/endpoints/whisper.ts b/server/src/endpoints/whisper.ts deleted file mode 100644 index 94a81671..00000000 --- a/server/src/endpoints/whisper.ts +++ /dev/null @@ -1,8 +0,0 @@ -import express from 'express'; -import RequestHandler from "./base"; - -export default class WhisperRequestHandler extends RequestHandler { - handler(req: express.Request, res: express.Response): any { - res.json({ status: 'ok' }); - } -} \ No newline at end of file diff --git a/server/src/index.ts b/server/src/index.ts index 992a7dcc..a1a21044 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,27 +1,30 @@ require('dotenv').config() -import express from 'express'; import compression from 'compression'; +import express from 'express'; +import { execSync } from 'child_process'; import fs from 'fs'; +import https from 'https'; import path from 'path'; -import S3ObjectStore from './object-store/s3'; -import { SQLiteAdapter } from './database/sqlite'; -import SQLiteObjectStore from './object-store/sqlite'; -import ObjectStore from './object-store/index'; +import { configureAuth0 } from './auth0'; +import { config } from './config'; import Database from './database/index'; +import KnexDatabaseAdapter from './database/knex'; +import GetShareRequestHandler from './endpoints/get-share'; import HealthRequestHandler from './endpoints/health'; -import TitleRequestHandler from './endpoints/title'; -import MessagesRequestHandler from './endpoints/messages'; -import SyncRequestHandler from './endpoints/sync'; -import ShareRequestHandler from './endpoints/share'; -import BasicCompletionRequestHandler from './endpoints/completion/basic'; -import StreamingCompletionRequestHandler from './endpoints/completion/streaming'; +import DeleteChatRequestHandler from './endpoints/delete-chat'; +import ElevenLabsTTSProxyRequestHandler from './endpoints/service-proxies/elevenlabs/text-to-speech'; +import ElevenLabsVoicesProxyRequestHandler from './endpoints/service-proxies/elevenlabs/voices'; +import OpenAIProxyRequestHandler from './endpoints/service-proxies/openai'; import SessionRequestHandler from './endpoints/session'; -import GetShareRequestHandler from './endpoints/get-share'; -import WhisperRequestHandler from './endpoints/whisper'; +import ShareRequestHandler from './endpoints/share'; +import ObjectStore from './object-store/index'; +import S3ObjectStore from './object-store/s3'; +import SQLiteObjectStore from './object-store/sqlite'; import { configurePassport } from './passport'; -import { configureAuth0 } from './auth0'; -import DeleteChatRequestHandler from './endpoints/delete-chat'; +import SyncRequestHandler, { getNumUpdatesProcessedIn5Minutes } from './endpoints/sync'; +import LegacySyncRequestHandler from './endpoints/sync-legacy'; +import { getActiveUsersInLast5Minutes } from './endpoints/base'; process.on('unhandledRejection', (reason, p) => { console.error('Unhandled Rejection at: Promise', p, 'reason:', reason); @@ -32,19 +35,12 @@ if (process.env.CI) { } const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3001; -const webappPort = process.env.WEBAPP_PORT ? parseInt(process.env.WEBAPP_PORT, 10) : 3000; -const origins = (process.env.ALLOWED_ORIGINS || '').split(','); - -if (process.env['GITPOD_WORKSPACE_URL']) { - origins.push( - process.env['GITPOD_WORKSPACE_URL']?.replace('https://', `https://${webappPort}-`) - ); -} export default class ChatServer { + authProvider = 'local'; app: express.Application; objectStore: ObjectStore = process.env.S3_BUCKET ? new S3ObjectStore() : new SQLiteObjectStore(); - database: Database = new SQLiteAdapter(); + database: Database = new KnexDatabaseAdapter(); constructor() { this.app = express(); @@ -56,44 +52,65 @@ export default class ChatServer { this.app.use(express.urlencoded({ extended: false })); - if (process.env.AUTH0_CLIENT_ID && process.env.AUTH0_ISSUER && process.env.PUBLIC_URL) { + if (config.auth0?.clientID && config.auth0?.issuer && config.publicSiteURL) { console.log('Configuring Auth0.'); + this.authProvider = 'auth0'; configureAuth0(this); } else { console.log('Configuring Passport.'); + this.authProvider = 'local'; configurePassport(this); } this.app.use(express.json({ limit: '1mb' })); - this.app.use(compression()); - - - const rateLimitWindowMs = process.env.RATE_LIMIT_WINDOW_MS ? parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) : 15 * 60 * 1000; // 15 minutes - const rateLimitMax = process.env.RATE_LIMIT_MAX ? parseInt(process.env.RATE_LIMIT_MAX, 10) : 100; // limit each IP to 100 requests per windowMs + this.app.use(compression({ + filter: (req, res) => !req.path.includes("proxies"), + })); const { default: rateLimit } = await import('express-rate-limit'); // esm - const limiter = rateLimit({ - windowMs: rateLimitWindowMs, - max: rateLimitMax, - }); - this.app.use(limiter); this.app.get('/chatapi/health', (req, res) => new HealthRequestHandler(this, req, res)); - this.app.get('/chatapi/session', (req, res) => new SessionRequestHandler(this, req, res)); - this.app.post('/chatapi/messages', (req, res) => new MessagesRequestHandler(this, req, res)); - this.app.post('/chatapi/title', (req, res) => new TitleRequestHandler(this, req, res)); + + this.app.get('/chatapi/session', + rateLimit({ windowMs: 60 * 1000, max: 100 }), + (req, res) => new SessionRequestHandler(this, req, res)); + + this.app.post('/chatapi/y-sync', + rateLimit({ windowMs: 60 * 1000, max: 100 }), + express.raw({ type: 'application/octet-stream', limit: '10mb' }), + (req, res) => new SyncRequestHandler(this, req, res)); + + this.app.get('/chatapi/legacy-sync', + rateLimit({ windowMs: 60 * 1000, max: 100 }), + (req, res) => new LegacySyncRequestHandler(this, req, res)); + + this.app.use(rateLimit({ + windowMs: config.rateLimit.windowMs, + max: config.rateLimit.max, + })); + this.app.post('/chatapi/delete', (req, res) => new DeleteChatRequestHandler(this, req, res)); - this.app.post('/chatapi/sync', (req, res) => new SyncRequestHandler(this, req, res)); this.app.get('/chatapi/share/:id', (req, res) => new GetShareRequestHandler(this, req, res)); this.app.post('/chatapi/share', (req, res) => new ShareRequestHandler(this, req, res)); - this.app.post('/chatapi/whisper', (req, res) => new WhisperRequestHandler(this, req, res)); - if (process.env.ENABLE_SERVER_COMPLETION) { - this.app.post('/chatapi/completion', (req, res) => new BasicCompletionRequestHandler(this, req, res)); - this.app.post('/chatapi/completion/streaming', (req, res) => new StreamingCompletionRequestHandler(this, req, res)); + if (config.services?.openai?.apiKey) { + this.app.post('/chatapi/proxies/openai/v1/chat/completions', (req, res) => new OpenAIProxyRequestHandler(this, req, res)); + } + + if (config.services?.elevenlabs?.apiKey) { + this.app.post('/chatapi/proxies/elevenlabs/v1/text-to-speech/:voiceID', (req, res) => new ElevenLabsTTSProxyRequestHandler(this, req, res)); + this.app.get('/chatapi/proxies/elevenlabs/v1/voices', (req, res) => new ElevenLabsVoicesProxyRequestHandler(this, req, res)); } if (fs.existsSync('public')) { + const match = `<script> window.AUTH_PROVIDER = "local"; </script>`; + const replace = `<script> window.AUTH_PROVIDER = "${this.authProvider}"; </script>`; + + const indexFilename = "public/index.html"; + const indexSource = fs.readFileSync(indexFilename, 'utf8'); + + fs.writeFileSync(indexFilename, indexSource.replace(match, replace)); + this.app.use(express.static('public')); // serve index.html for all other routes @@ -106,12 +123,57 @@ export default class ChatServer { await this.database.initialize(); try { - this.app.listen(port, () => { + const callback = () => { console.log(`Listening on port ${port}.`); - }); + }; + + if (config.tls?.key && config.tls?.cert) { + console.log('Configuring TLS.'); + + const server = https.createServer({ + key: fs.readFileSync(config.tls.key), + cert: fs.readFileSync(config.tls.cert), + }, this.app); + + server.listen(port, callback); + } else if (config.tls?.selfSigned) { + console.log('Configuring self-signed TLS.'); + + if (!fs.existsSync('./data/key.pem') || !fs.existsSync('./data/cert.pem')) { + execSync('sh generate-self-signed-certificate.sh'); + } + + const server = https.createServer({ + key: fs.readFileSync('./data/key.pem'), + cert: fs.readFileSync('./data/cert.pem'), + }, this.app); + + server.listen(port, callback); + } else { + this.app.listen(port, callback); + } } catch (e) { console.log(e); } + + setInterval(() => { + const activeUsers = getActiveUsersInLast5Minutes(); + + const activeUsersToDisplay = activeUsers.slice(0, 10); + const extraActiveUsers = activeUsers.slice(10); + + const numRecentUpdates = getNumUpdatesProcessedIn5Minutes(); + + console.log(`Statistics (last 5m):`); + + if (extraActiveUsers.length) { + console.log(` - ${activeUsers.length} active users: ${activeUsersToDisplay.join(', ')} and ${extraActiveUsers.length} more`); + } else { + console.log(` - ${activeUsers.length} active users: ${activeUsersToDisplay.join(', ')}`); + } + + console.log(` - ${numRecentUpdates} updates processed`); + }, 1000 * 60); } } diff --git a/server/src/object-store/sqlite.ts b/server/src/object-store/sqlite.ts index c50a55c3..c7623f54 100644 --- a/server/src/object-store/sqlite.ts +++ b/server/src/object-store/sqlite.ts @@ -32,7 +32,6 @@ export default class SQLiteObjectStore extends ObjectStore { reject(err); } else { resolve(row?.value ?? null); - console.log(`[object-store:sqlite] retrieved object ${key}`) } }); }); @@ -44,7 +43,6 @@ export default class SQLiteObjectStore extends ObjectStore { if (err) { reject(err); } else { - console.log(`[object-store:sqlite] stored object ${key}`) resolve(); } }); diff --git a/server/src/passport.ts b/server/src/passport.ts index e6904171..96d40d4d 100644 --- a/server/src/passport.ts +++ b/server/src/passport.ts @@ -5,8 +5,9 @@ import session from 'express-session'; import createSQLiteSessionStore from 'connect-sqlite3'; import { Strategy as LocalStrategy } from 'passport-local'; import ChatServer from './index'; +import { config } from './config'; -const secret = process.env.AUTH_SECRET || crypto.randomBytes(32).toString('hex'); +const secret = config.authSecret; export function configurePassport(context: ChatServer) { const SQLiteStore = createSQLiteSessionStore(session); @@ -20,7 +21,6 @@ export function configurePassport(context: ChatServer) { } try { - console.log(user.salt ? 'Using pbkdf2' : 'Using bcrypt'); const isPasswordCorrect = user.salt ? crypto.timingSafeEqual(user.passwordHash, crypto.pbkdf2Sync(password, user.salt, 310000, 32, 'sha256')) : await bcrypt.compare(password, user.passwordHash.toString()); diff --git a/server/src/utils.ts b/server/src/utils.ts index 8d78e5b2..6562d722 100644 --- a/server/src/utils.ts +++ b/server/src/utils.ts @@ -1,5 +1,5 @@ import crypto from 'crypto'; -export function randomID() { - return crypto.randomBytes(16).toString('hex'); +export function randomID(bytes = 16) { + return crypto.randomBytes(bytes).toString('hex'); } \ No newline at end of file