-
+
- {backend.current!.user!.name || backend.current!.user!.email}
- {!!backend.current!.user!.name && {backend.current.user!.email}}
+ {context.user!.name || context.user!.email}
+ {!!context.user!.name && {context.user!.email}}
@@ -144,17 +156,17 @@ export default function Sidebar(props: {
}} icon={
}>
- {/*
+
backend.current?.logout()} icon={}>
-
- */}
+
+
)}
- ), [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 3127430..d1fa33d 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:
The chat "{c.title}" will be permanently deleted. This cannot be undone.
,
@@ -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:
+
+
+
,
+ });
+ }, [c.chatID, c.title]);
+
+ const [menuOpen, setMenuOpen] = useState(false);
+
+ const toggleMenu = useCallback((e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setMenuOpen(open => !open);
+ }, []);
+
return (
{c.title || }
- {props.selected && (
-
- )}
+
);
}
@@ -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 (
{recentChats.length > 0 &&
@@ -159,7 +217,10 @@ export default function RecentChats(props: any) {
))}
}
- {recentChats.length === 0 &&
+ {recentChats.length === 0 && !synced &&
+
+ }
+ {recentChats.length === 0 && synced &&
}
diff --git a/app/src/components/tts-button.tsx b/app/src/components/tts-button.tsx
new file mode 100644
index 0000000..6a70ec3
--- /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
();
+
+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 (<>
+
+ {JSON.stringify(state)}
+ >);
+}
diff --git a/app/src/components/tts-controls.tsx b/app/src/components/tts-controls.tsx
new file mode 100644
index 0000000..23bd3d4
--- /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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/src/context.tsx b/app/src/context.tsx
deleted file mode 100644
index 50009b9..0000000
--- 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;
- regenerateMessage: (message: Message) => Promise;
- editMessage: (message: Message, content: string) => Promise;
-}
-
-const AppContext = React.createContext({} 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(() => ({
- 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 {props.children};
-}
\ 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 0000000..14932a2
--- /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 {
+ 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.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 0000000..e672762
--- /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 0000000..112b3c0
--- /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(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 0000000..e05686a
--- /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;
+}
+
+/**
+ * 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 = 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[]} - An array of serialized message nodes.
+ */
+ public serialize(): Omit[] {
+ 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 d3f56ff..8b63def 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,84 +51,57 @@ function parseResponseChunk(buffer: any): OpenAIResponseChunk {
}
export async function createChatCompletion(messages: OpenAIMessage[], parameters: Parameters): Promise {
- 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 openai = new OpenAIApi(configuration);
-
- const response = await openai.createChatCompletion({
- model: parameters.model,
- temperature: parameters.temperature,
- messages: messages as any,
- });
-
- return response.data.choices[0].message?.content?.trim() || '';
-}
-
-export async function createStreamingChatCompletion(messages: OpenAIMessage[], parameters: Parameters) {
- if (!parameters.apiKey) {
- throw new Error('No API key provided');
- }
-
- const emitter = new EventEmitter();
-
- let messagesToSend = [...messages].filter(m => m.role !== 'app');
-
- for (let i = messagesToSend.length - 1; i >= 0; i--) {
- const m = messagesToSend[i];
- if (m.role === 'user') {
- break;
- }
- if (m.role === 'assistant') {
- messagesToSend.splice(i, 1);
- }
- }
-
- messagesToSend.unshift({
- role: 'system',
- content: (parameters.initialSystemPrompt || defaultSystemPrompt).replace('{{ datetime }}', new Date().toLocaleString()),
- });
-
- messagesToSend = await selectMessagesToSendSafely(messagesToSend, 2048);
-
- const eventSource = new SSE('https://api.openai.com/v1/chat/completions', {
+ const response = await fetch(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',
+ },
+ body: JSON.stringify({
+ "model": parameters.model,
+ "messages": messages,
+ "temperature": parameters.temperature,
+ }),
+ });
+
+ const data = await response.json();
+
+ return data.choices[0].message?.content?.trim() || '';
+}
+
+export async function createStreamingChatCompletion(messages: OpenAIMessage[], parameters: Parameters) {
+ const emitter = new EventEmitter();
+
+ const proxied = shouldUseProxy(parameters.apiKey);
+ const endpoint = getEndpoint(proxied);
+
+ if (!proxied && !parameters.apiKey) {
+ throw new Error('No API key provided');
+ }
+
+ const eventSource = new SSE(endpoint + '/v1/chat/completions', {
+ method: "POST",
+ headers: {
+ 'Accept': 'application/json, text/plain, */*',
+ 'Authorization': !proxied ? `Bearer ${parameters.apiKey}` : '',
'Content-Type': 'application/json',
},
payload: JSON.stringify({
"model": parameters.model,
- "messages": messagesToSend,
+ "messages": messages,
"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,
- "temperature": parameters.temperature,
- "stream": true,
- }),
- }) as SSE;
- */
-
let contents = '';
eventSource.addEventListener('error', (event: any) => {
@@ -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 8d86da8..cf43e1e 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;
+ pluginOptions?: Record;
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 637a5b2..54f43fd 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 0000000..9653b76
--- /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();
+ 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 {
+ return this.root.getMap(this.prefix + METADATA_KEY);
+ }
+
+ public get importedMetadata(): Y.Map {
+ return this.root.getMap(this.prefix + IMPORTED_METADATA_KEY);
+ }
+
+ public get pluginOptions(): Y.Map {
+ return this.root.getMap(this.prefix + PLUGIN_OPTIONS_KEY);
+ }
+
+ public get messages(): Y.Map {
+ return this.root.getMap(this.prefix + MESSAGES_KEY);
+ }
+
+ public get content(): Y.Map {
+ return this.root.getMap(this.prefix + CONTENT_KEY);
+ }
+
+ public get done(): Y.Map {
+ return this.root.getMap(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>('chats');
+ // public deletedChatIDs = this.root.getArray('deletedChatIDs');
+ public deletedChatIDsSet = new Set();
+ public options = this.root.getMap>('options');
+ private yChats = new Map();
+
+ private observed = new Set();
+
+ 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 0000000..3b33754
--- /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;
+ regenerateMessage: (message: Message) => Promise;
+ editMessage: (message: Message, content: string) => Promise;
+}
+
+const AppContext = React.createContext({} 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('openai', 'apiKey');
+
+ if (!openaiApiKey && !isProxySupported()) {
+ dispatch(openOpenAIApiKeyPanel());
+ return false;
+ }
+
+ const parameters: Parameters = {
+ model: chatManager.options.getOption('parameters', 'model', id),
+ temperature: chatManager.options.getOption('parameters', 'temperature', id),
+ };
+
+ if (id === nextID) {
+ setNextID(uuidv4());
+
+ const autoPlay = chatManager.options.getOption('tts', 'autoplay');
+
+ if (autoPlay) {
+ const ttsService = chatManager.options.getOption('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('openai', 'apiKey');
+
+ if (!openaiApiKey && !isProxySupported()) {
+ dispatch(openOpenAIApiKeyPanel());
+ return false;
+ }
+
+ const parameters: Parameters = {
+ model: chatManager.options.getOption('parameters', 'model', id),
+ temperature: chatManager.options.getOption('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('openai', 'apiKey');
+
+ if (!openaiApiKey && !isProxySupported()) {
+ dispatch(openOpenAIApiKeyPanel());
+ return false;
+ }
+
+ const parameters: Parameters = {
+ model: chatManager.options.getOption('parameters', 'model', id),
+ temperature: chatManager.options.getOption('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(() => ({
+ 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
+
+ {props.children}
+
+ ;
+}
\ 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 0000000..f47a2d5
--- /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();
+ private changedIDs = new Set();
+ 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 {
+ 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> = {};
+
+ 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 0000000..b275561
--- /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 = 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 {
+ const options: Record = {};
+
+ 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(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 0000000..6edc45d
--- /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 0000000..60bcb59
--- /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 0000000..3836296
--- /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 0000000..a601afb
--- /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(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();
+
+ 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 0000000..9d4d745
--- /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 0000000..5872ee6
--- /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 {
+ 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 {
+ 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 0000000..0c0fdc3
--- /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 = 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 0000000..c755547
--- /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;
+ setChatTitle(title: string): Promise;
+}
+
+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 0000000..8ae1cab
--- /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 0000000..348acef
--- /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) => Promise) {
+ 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 0000000..8e7aa67
--- /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 a019feb..7081f81 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 2be6759..e07998d 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 0000000..3fa81f3
--- /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 0000000..2dfa636
--- /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 {
+ 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 0000000..7610d92
--- /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();
+
+async function getAudioFile(plugin: TTSPlugin, 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 0000000..aa9d2b1
--- /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 {
+ 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 {
+ 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 0000000..d4cb35a
--- /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 extends TTSPlugin {
+ 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 0000000..03813a7
--- /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 extends Plugin {
+ async getVoices(): Promise {
+ return [];
+ }
+
+ async getCurrentVoice(): Promise {
+ throw new Error("not implemented");
+ }
+
+ async speakToBuffer(text: string, voice?: Voice): Promise {
+ 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 0000000..3f2e984
--- /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;
+ abstract pause(): Promise;
+ 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 0000000..d291269
--- /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('tts', 'service');
+ const [autoplayEnabled] = useOption('tts', 'autoplay');
+ const [voiceID] = useOption(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(null);
+ const player = useRef(null);
+ const elementRef = useRef(null);
+
+ const [key, setKey] = useState(null);
+ const [state, setState] = useState(() => player.current?.getState());
+ const [complete, setComplete] = useState(false);
+
+ const timer = useRef();
+
+ 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({
+ 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 {props.children};
+}
\ 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 0000000..8b63fe9
--- /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} 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 0000000..16f205e
--- /dev/null
+++ b/app/src/core/utils/event-emitter-async-iterator.ts
@@ -0,0 +1,91 @@
+import EventEmitter from 'events';
+
+export interface EventEmitterAsyncIteratorOutput {
+ 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 implements AsyncIterableIterator> {
+ private eventQueue: EventEmitterAsyncIteratorOutput[] = [];
+ private resolveQueue: ((value: IteratorResult>) => 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>> {
+ if (this.eventQueue.length > 0) {
+ const value = this.eventQueue.shift();
+ return { value: value as EventEmitterAsyncIteratorOutput, done: false };
+ } else {
+ return new Promise>>(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 = {
+ 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> {
+ 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 aeb5ecd..1360886 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 0000000..ac1ec39
--- /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 {
+ 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();
+
+export async function md5(data: string): Promise {
+ 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 c05e854..e8889df 100644
--- a/app/src/sse.ts
+++ b/app/src/core/utils/sse.ts
@@ -1,29 +1,155 @@
/**
+ * 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 .
* 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) { }
+ /**
+ * Starts streaming data from the SSE connection.
+ */
+ public stream = () => {
+ this.setReadyState(this.CONNECTING);
+
+ this.xhr = new XMLHttpRequest();
+ this.xhr.addEventListener('progress', this.onStreamProgress);
+ this.xhr.addEventListener('load', this.onStreamLoaded);
+ this.xhr.addEventListener('readystatechange', this.checkStreamClosed);
+ this.xhr.addEventListener('error', this.onStreamFailure);
+ this.xhr.addEventListener('abort', this.onStreamAbort);
+ this.xhr.open(this.method, this.url);
+ for (var header in this.headers) {
+ this.xhr.setRequestHeader(header, this.headers[header]);
+ }
+ this.xhr.withCredentials = this.withCredentials;
+ this.xhr.send(this.payload);
+ };
+
+ /**
+ * Closes the SSE connection.
+ */
+ public close = () => {
+ if (this.readyState === this.CLOSED) {
+ return;
+ }
+
+ try {
+ this.xhr.abort();
+ this.xhr = null;
+ this.setReadyState(this.CLOSED);
+ } catch (e) {
+ console.error(e);
+ }
+ };
+
+ /**
+ * 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);
+ return;
+ }
+
+ if (this.readyState === this.CONNECTING) {
+ this.dispatchEvent(new CustomEvent('open'));
+ this.setReadyState(this.OPEN);
+ }
+
+ 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.chunk = '';
+ } else {
+ this.chunk += part;
+ }
+ });
+ };
+
+ /**
+ * Parses a received SSE event chunk and constructs an event object based on the chunk data.
+ */
+ private parseEventChunk = (chunk: string) => {
+ if (!chunk || chunk.length === 0) {
+ return null;
+ }
+
+ const e: any = { 'id': null, 'retry': null, 'data': '', 'event': 'message' };
+ chunk.split(/\n|\r\n|\r/).forEach((line: string) => {
+ line = line.trimRight();
+ 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;
+ }
+
+ const field = line.substring(0, index);
+ if (!(field in e)) {
+ return;
+ }
+
+ const value = line.substring(index + 1).trimLeft();
+ if (field === 'data') {
+ e[field] += value;
+ } else {
+ e[field] = value;
+ }
+ });
+
+ const event: any = new CustomEvent(e.event);
+ event.data = e.data;
+ event.id = e.id;
+ return event;
+ };
+
+ /**
+ * 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] = [];
@@ -34,12 +160,15 @@ export default class SSE {
}
};
+ /**
+ * Removes an event listener for a given event type.
+ */
public removeEventListener = (type: string, listener: any) => {
if (this.listeners[type] === undefined) {
return;
}
- var filtered: any[] = [];
+ const filtered: any[] = [];
this.listeners[type].forEach((element: any) => {
if (element !== listener) {
filtered.push(element);
@@ -52,14 +181,17 @@ export default class SSE {
}
};
- public dispatchEvent = (e: any) => {
+ /**
+ * Dispatches an event to all registered listeners.
+ */
+ private dispatchEvent = (e: any) => {
if (!e) {
return true;
}
e.source = this;
- var onHandler = 'on' + e.type;
+ const onHandler = 'on' + e.type;
if (this.hasOwnProperty(onHandler)) {
// @ts-ignore
this[onHandler].call(this, e);
@@ -78,137 +210,46 @@ export default class SSE {
return true;
};
- public _setReadyState = (state: number) => {
- var event = new CustomEvent('readystatechange');
+ /**
+ * Sets the ready state of the SSE connection and dispatches a 'readystatechange' event.
+ */
+ private setReadyState = (state: number) => {
+ const event = new CustomEvent('readystatechange');
// @ts-ignore
event.readyState = state;
this.readyState = state;
this.dispatchEvent(event);
};
- public _onStreamFailure = (e: { currentTarget: { response: any; }; }) => {
- var event = new CustomEvent('error');
+ /**
+ * 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();
}
- public _onStreamAbort = (e: any) => {
+ /**
+ * Handles an abort event during the SSE connection and dispatches an 'abort' event.
+ */
+ private onStreamAbort = (e: any) => {
this.dispatchEvent(new CustomEvent('abort'));
this.close();
}
- public _onStreamProgress = (e: any) => {
- if (!this.xhr) {
- return;
- }
-
- if (this.xhr.status !== 200) {
- this._onStreamFailure(e);
- return;
- }
-
- if (this.readyState === this.CONNECTING) {
- this.dispatchEvent(new CustomEvent('open'));
- this._setReadyState(this.OPEN);
- }
-
- var data = this.xhr.responseText.substring(this.progress);
- this.progress += data.length;
- data.split(/(\r\n|\r|\n){2}/g).forEach((part: string) => {
- if (part.trim().length === 0) {
- this.dispatchEvent(this._parseEventChunk(this.chunk.trim()));
- this.chunk = '';
- } else {
- this.chunk += part;
- }
- });
- };
-
- public _onStreamLoaded = (e: any) => {
- this._onStreamProgress(e);
-
- // Parse the last chunk.
- this.dispatchEvent(this._parseEventChunk(this.chunk));
- this.chunk = '';
- };
-
/**
- * Parse a received SSE event chunk into a constructed event object.
+ * Checks if the SSE connection is closed and sets the ready state to CLOSED if needed.
*/
- public _parseEventChunk = (chunk: string) => {
- if (!chunk || chunk.length === 0) {
- return null;
- }
-
- var e: any = { 'id': null, 'retry': null, 'data': '', 'event': 'message' };
- chunk.split(/\n|\r\n|\r/).forEach((line: string) => {
- line = line.trimRight();
- var index = line.indexOf(this.FIELD_SEPARATOR);
- if (index <= 0) {
- // Line was either empty, or started with a separator and is a comment.
- // Either way, ignore.
- return;
- }
-
- var field = line.substring(0, index);
- if (!(field in e)) {
- return;
- }
-
- var value = line.substring(index + 1).trimLeft();
- if (field === 'data') {
- e[field] += value;
- } else {
- e[field] = value;
- }
- });
-
- var event: any = new CustomEvent(e.event);
- event.data = e.data;
- event.id = e.id;
- return event;
- };
-
- public _checkStreamClosed = () => {
+ private checkStreamClosed = () => {
if (!this.xhr) {
return;
}
if (this.xhr.readyState === XMLHttpRequest.DONE) {
- this._setReadyState(this.CLOSED);
- }
- };
-
- public stream = () => {
- this._setReadyState(this.CONNECTING);
-
- this.xhr = new XMLHttpRequest();
- this.xhr.addEventListener('progress', this._onStreamProgress);
- this.xhr.addEventListener('load', this._onStreamLoaded);
- this.xhr.addEventListener('readystatechange', this._checkStreamClosed);
- this.xhr.addEventListener('error', this._onStreamFailure);
- this.xhr.addEventListener('abort', this._onStreamAbort);
- this.xhr.open(this.method, this.url);
- for (var header in this.headers) {
- this.xhr.setRequestHeader(header, this.headers[header]);
- }
- this.xhr.withCredentials = this.withCredentials;
- this.xhr.send(this.payload);
- };
-
- public close = () => {
- if (this.readyState === this.CLOSED) {
- return;
- }
-
- try {
- this.xhr.abort();
- this.xhr = null;
- this._setReadyState(this.CLOSED);
- } catch (e) {
- console.error(e);
+ 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 0000000..b75f250
--- /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 0000000..ac223e1
--- /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: <>
+
+
+
+
+
+
+
+
+
+
+
+ >,
+ }),
+ },
+ ],
+}
\ 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 0000000..bd3fb88
--- /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. Request access here.",
+ },
+ {
+ a: (text: string) => {text}
+ } 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 0000000..101a93f
--- /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 0000000..016c975
--- /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 0000000..9e8e576
--- /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 209c75b..9c1f60b 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 510a094..0000000
--- 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;
-}
-
-export function createNode(message: Message): Node {
- return {
- ...message,
- parent: null,
- children: new Set(),
- };
-}
-
-export class MessageTree {
- public nodes: Map = 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 0000000..0038739
--- /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> = [
+ 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 0000000..483795e
--- /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 {
+ describe(): PluginDescription {
+ return {
+ id: "system-prompt",
+ name: "System Prompt",
+ options: [
+ {
+ id: "systemPrompt",
+ defaultValue: defaultSystemPrompt,
+ displayOnSettingsScreen: "chat",
+ resettable: true,
+ scope: "chat",
+ renderProps: {
+ type: "textarea",
+ description:
+ '{{ datetime }}' tag is automatically replaced by the current date and time (use this to give the AI access to the time)."}
+ values={{ code: v => {v}
}} />
+
,
+ },
+ 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 0000000..ffdf15d
--- /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 {
+ describe(): PluginDescription {
+ return {
+ id: "titles",
+ name: "Title Generator",
+ options: [],
+ };
+ }
+
+ async postprocessModelOutput(message: OpenAIMessage, contextMessages: OpenAIMessage[], parameters: Parameters, done: boolean): Promise {
+ 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 0000000..db603eb
--- /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 {
+ 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 419b4c2..ce4f2f4 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: ,
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 eced56c..0000000
--- 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) => {
- state.openAIApiKey = action.payload;
- },
- setElevenLabsApiKey: (state, action: PayloadAction) => {
- state.elevenLabsApiKey = action.payload;
- },
- setUseOpenAIWhisper: (state, action: PayloadAction) => {
- state.useOpenAIWhisper = action.payload;
- }
-
- },
-})
-
-export const { setOpenAIApiKey, setElevenLabsApiKey } = apiKeysSlice.actions;
-
-export const setOpenAIApiKeyFromEvent = (event: React.ChangeEvent) => apiKeysSlice.actions.setOpenAIApiKey(event.target.value);
-export const setElevenLabsApiKeyFromEvent = (event: React.ChangeEvent) => apiKeysSlice.actions.setElevenLabsApiKey(event.target.value);
-export const setUseOpenAIWhisperFromEvent = (event: React.ChangeEvent) => 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 be1fdde..1870901 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 deb82d8..0000000
--- 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) => {
- state.initialSystemPrompt = action.payload;
- },
- resetSystemPrompt: (state) => {
- state.initialSystemPrompt = defaultSystemPrompt;
- },
- setModel: (state, action: PayloadAction) => {
- state.model = action.payload;
- },
- resetModel: (state) => {
- state.model = defaultModel;
- },
- setTemperature: (state, action: PayloadAction) => {
- 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 9032043..a7cd24e 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 970f834..0000000
--- 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) => {
- 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 0000000..c3b42bc
--- /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 3ae6618..0000000
--- 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 {
- 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 0000000..b9c8daf
--- /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 {
+ 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: <>
+
+ {chunks}
+ }} />
+
+
+
+
+ >,
+ 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} A promise that resolves to an array of Voice objects.
+ */
+ async getVoices(): Promise {
+ 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} A promise that resolves to a Voice object.
+ */
+ async getCurrentVoice(): Promise {
+ 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} A promise that resolves to an ArrayBuffer containing the audio data, or null if the conversion fails.
+ */
+ async speakToBuffer(text: string, voice?: Voice): Promise {
+ 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 {
+ const headers: Record = {
+ '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 0000000..3b0fb0f
--- /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 {
+ 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 {
+ 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(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 cb81972..0000000
--- 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();
-
-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 (
-
- );
-}
diff --git a/app/src/utils.ts b/app/src/utils.ts
deleted file mode 100644
index 345b07d..0000000
--- 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();
-
-export async function md5(data: string): Promise {
- if (!hashCache.has(data)) {
- const hashHex = hasher.hex(data);
- hashCache.set(data, hashHex);
- }
- return hashCache.get(data)!;
-}
-
-export function sleep(ms: number) {
- return new Promise(resolve => setTimeout(resolve, ms));
-}
-
-export async function share(text: string) {
- if (navigator.share) {
- await navigator.share({
- text,
- });
- }
-}
-
-export function ellipsize(text: string, maxLength: number) {
- if (text.length > maxLength) {
- return text.substring(0, maxLength) + '...';
- }
- return text;
-}
-
-export function cloneArrayBuffer(buffer) {
- const newBuffer = new ArrayBuffer(buffer.byteLength);
- new Uint8Array(newBuffer).set(new Uint8Array(buffer));
- return newBuffer;
-}
-
-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 0000000..e85136a
--- /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 a6b0df8..52b22a7 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 87d48b0..c67959d 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 0000000..f1b69d2
--- /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 703d2ff..be94364 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(60 * 60 * 1000);
+const documents = new ExpiryMap(48 * 60 * 60 * 1000);
+
export default abstract class Database {
public async initialize() {}
public abstract createUser(email: string, passwordHash: Buffer): Promise;
@@ -14,4 +22,17 @@ export default abstract class Database {
public abstract setTitle(userID: string, chatID: string, title: string): Promise;
public abstract deleteChat(userID: string, chatID: string): Promise;
public abstract getDeletedChatIDs(userID: string): Promise;
+
+ protected abstract loadYDoc(userID: string): Promise;
+ public abstract saveYUpdate(userID: string, update: Uint8Array): Promise;
+
+ public async getYDoc(userID: string): Promise {
+ 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 0000000..0b5e122
--- /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 {
+ 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 {
+ 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 {
+ return await this.knex(tableNames.chats)
+ .where('user_id', userID).select();
+ }
+
+ public async getMessages(userID: string): Promise {
+ 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 {
+ // deprecated
+ }
+
+ public async createShare(userID: string | null, id: string): Promise {
+ 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 {
+ // deprecated
+ }
+
+ public async deleteChat(userID: string, chatID: string): Promise {
+ 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 {
+ 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 {
+ 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 a451ea8..0000000
--- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 0a06826..f6f1d6d 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(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 3292e76..0000000
--- 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 ce18fd6..0000000
--- 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 fb352c1..0000000
--- 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 0000000..8f35aef
--- /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 0000000..d9f2a2d
--- /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 0000000..cccefa7
--- /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 0000000..5c1eca1
--- /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 0000000..f8f8702
--- /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 28d7de9..23ea0c1 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 0000000..ad76a32
--- /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(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 32c4be8..80a3e8d 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(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 encoding = await import('lib0/encoding');
+ const decoding = await import('lib0/decoding');
+ const syncProtocol = await import('y-protocols/sync');
- const output: Record = {};
+ const doc = await this.context.database.getYDoc(this.userID!);
+
+ const Y = await import('yjs');
- for (const m of messages) {
- const chat = output[m.chat_id] || {
- messages: [],
- };
- chat.messages.push(m.data);
- output[m.chat_id] = chat;
+ const encoder = encoding.createEncoder();
+ const decoder = decoding.createDecoder(req.body);
+
+ const messageType = decoding.readVarUint(decoder);
+
+ 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);
}
- for (const c of chats) {
- const chat = output[c.id] || {
- messages: [],
- };
- chat.title = c.title;
- output[c.id] = chat;
- }
+ const buffer = Buffer.from(encode(responseBuffers));
- for (const chatID of deletedChatIDs) {
- output[chatID] = {
- deleted: true
- };
- }
+ res.setHeader('Content-Type', 'application/octet-stream');
+ res.setHeader('Content-Length', buffer.length);
- res.json(output);
+ res.send(buffer);
}
public isProtected() {
diff --git a/server/src/endpoints/title.ts b/server/src/endpoints/title.ts
deleted file mode 100644
index b4918b1..0000000
--- 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 94a8167..0000000
--- 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 992a7dc..a1a2104 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 Database from './database/index';
-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 SessionRequestHandler from './endpoints/session';
-import GetShareRequestHandler from './endpoints/get-share';
-import WhisperRequestHandler from './endpoints/whisper';
-import { configurePassport } from './passport';
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 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 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 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 = ``;
+ const replace = ``;
+
+ 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 c50a55c..c7623f5 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 e690417..96d40d4 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 8d78e5b..6562d72 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