main
Cogent Apps 2023-04-15 10:30:02 +00:00
parent 943bca2f4d
commit eb58d900b5
118 changed files with 5785 additions and 2471 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "chat-with-gpt", "name": "chat-with-gpt",
"version": "0.2.2", "version": "0.2.3",
"dependencies": { "dependencies": {
"@auth0/auth0-spa-js": "^2.0.4", "@auth0/auth0-spa-js": "^2.0.4",
"@chengsokdara/use-whisper": "^0.2.0", "@chengsokdara/use-whisper": "^0.2.0",
@ -12,6 +12,7 @@
"@mantine/modals": "^5.10.5", "@mantine/modals": "^5.10.5",
"@mantine/notifications": "^5.10.5", "@mantine/notifications": "^5.10.5",
"@mantine/spotlight": "^5.10.5", "@mantine/spotlight": "^5.10.5",
"@msgpack/msgpack": "^3.0.0-beta2",
"@reduxjs/toolkit": "^1.9.3", "@reduxjs/toolkit": "^1.9.3",
"@svgr/webpack": "^6.5.1", "@svgr/webpack": "^6.5.1",
"broadcast-channel": "^4.20.2", "broadcast-channel": "^4.20.2",
@ -20,9 +21,9 @@
"expiry-set": "^1.0.0", "expiry-set": "^1.0.0",
"idb-keyval": "^6.2.0", "idb-keyval": "^6.2.0",
"jshashes": "^1.0.8", "jshashes": "^1.0.8",
"lib0": "^0.2.73",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"match-sorter": "^6.3.1", "match-sorter": "^6.3.1",
"mic-recorder-to-mp3": "^2.2.2",
"minisearch": "^6.0.1", "minisearch": "^6.0.1",
"natural": "^6.2.0", "natural": "^6.2.0",
"openai": "^3.2.1", "openai": "^3.2.1",
@ -42,15 +43,20 @@
"sentence-splitter": "^4.2.0", "sentence-splitter": "^4.2.0",
"slugify": "^1.6.5", "slugify": "^1.6.5",
"sort-by": "^0.0.2", "sort-by": "^0.0.2",
"url": "^0.11.0",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4",
"workerize-loader": "^2.0.2",
"y-indexeddb": "^9.0.9",
"y-protocols": "^1.0.5",
"yjs": "^13.5.50"
}, },
"overrides": { "overrides": {
"@svgr/webpack": "$@svgr/webpack" "@svgr/webpack": "$@svgr/webpack"
}, },
"scripts": { "scripts": {
"start": "craco start", "start": "craco start",
"build": "GENERATE_SOURCEMAP=false craco build", "build": "craco build",
"test": "craco test", "test": "craco test",
"eject": "craco eject", "eject": "craco eject",
"extract": "formatjs extract 'src/**/*.ts*' --ignore='**/*.d.ts' --out-file public/lang/en-us.json --id-interpolation-pattern '[sha512:contenthash:base64:6]'" "extract": "formatjs extract 'src/**/*.ts*' --ignore='**/*.d.ts' --out-file public/lang/en-us.json --id-interpolation-pattern '[sha512:contenthash:base64:6]'"

View File

@ -46,6 +46,7 @@
height: 90vh; height: 90vh;
} }
</style> </style>
<script> window.AUTH_PROVIDER = "local"; </script>
</head> </head>
<body> <body>

View File

@ -2,6 +2,9 @@
"+G35mR": { "+G35mR": {
"defaultMessage": "Open sidebar" "defaultMessage": "Open sidebar"
}, },
"+H2Qtw": {
"defaultMessage": "Show quick settings below message input"
},
"+LMWDJ": { "+LMWDJ": {
"defaultMessage": "Chat History", "defaultMessage": "Chat History",
"description": "Heading for the chat history screen" "description": "Heading for the chat history screen"
@ -27,9 +30,6 @@
"3T9nRn": { "3T9nRn": {
"defaultMessage": "Your API key is stored only on this device and never transmitted to anyone except OpenAI." "defaultMessage": "Your API key is stored only on this device and never transmitted to anyone except OpenAI."
}, },
"4I+enA": {
"defaultMessage": "GPT 4 (requires invite)"
},
"5sg7KC": { "5sg7KC": {
"defaultMessage": "Password" "defaultMessage": "Password"
}, },
@ -56,8 +56,8 @@
"defaultMessage": "Save changes", "defaultMessage": "Save changes",
"description": "Label for a button that appears when the user is editing the text of one of their messages, to save the changes" "description": "Label for a button that appears when the user is editing the text of one of their messages, to save the changes"
}, },
"CJwO9s": { "E4+fv5": {
"defaultMessage": "GPT 3.5 Turbo (default)" "defaultMessage": "Auto-scroll to the bottom of the page when opening a chat"
}, },
"FEzBCd": { "FEzBCd": {
"defaultMessage": "Untitled", "defaultMessage": "Untitled",
@ -70,16 +70,15 @@
"HyS0qp": { "HyS0qp": {
"defaultMessage": "Close sidebar" "defaultMessage": "Close sidebar"
}, },
"JpZMMj": {
"defaultMessage": "Voice",
"description": "Heading for the setting that lets users choose an ElevenLabs text-to-speech voice, on the settings screen"
},
"KKa5Br": { "KKa5Br": {
"defaultMessage": "Give ChatGPT a realisic human voice by connecting your ElevenLabs account (preview the available voices below). <a>Click here to sign up.</a>" "defaultMessage": "Give ChatGPT a realisic human voice by connecting your ElevenLabs account (preview the available voices below). <a>Click here to sign up.</a>"
}, },
"L5s+z7": { "L5s+z7": {
"defaultMessage": "OpenAI API key usage is billed at a pay-as-you-go rate, separate from your ChatGPT subscription." "defaultMessage": "OpenAI API key usage is billed at a pay-as-you-go rate, separate from your ChatGPT subscription."
}, },
"LHOuNA": {
"defaultMessage": "Auto-scroll while generating a response"
},
"MI5gZ+": { "MI5gZ+": {
"defaultMessage": "Download SVG" "defaultMessage": "Download SVG"
}, },
@ -110,6 +109,9 @@
"defaultMessage": "Sign in <h>to sync</h>", "defaultMessage": "Sign in <h>to sync</h>",
"description": "Label for sign in button, indicating the purpose of signing in is to sync your data between devices" "description": "Label for sign in button, indicating the purpose of signing in is to sync your data between devices"
}, },
"T8gKkC": {
"defaultMessage": "Delete this chat"
},
"Tgo3vj": { "Tgo3vj": {
"defaultMessage": "Edit", "defaultMessage": "Edit",
"description": "Label for the button the user can click to edit the text of one of their messages" "description": "Label for the button the user can click to edit the text of one of their messages"
@ -117,10 +119,6 @@
"VL24Xt": { "VL24Xt": {
"defaultMessage": "Search your chats" "defaultMessage": "Search your chats"
}, },
"WOuVxP": {
"defaultMessage": "Model",
"description": "Heading for the setting that lets users choose a model to interact with, on the settings screen"
},
"Xzm66E": { "Xzm66E": {
"defaultMessage": "Connect your OpenAI account to get started" "defaultMessage": "Connect your OpenAI account to get started"
}, },
@ -128,26 +126,28 @@
"defaultMessage": "Or sign in to an existing account", "defaultMessage": "Or sign in to an existing account",
"description": "Label for a button on the Create Account page that lets the user sign into their existing account instead" "description": "Label for a button on the Create Account page that lets the user sign into their existing account instead"
}, },
"aR9WsJ": {
"defaultMessage": "UI Settings",
"description": "Heading for the setting that lets users customize various UI elements"
},
"bIacvz": { "bIacvz": {
"defaultMessage": "Chat with GPT - Unofficial ChatGPT app", "defaultMessage": "Chat with GPT - Unofficial ChatGPT app",
"description": "HTML title tag" "description": "HTML title tag"
}, },
"cAtzqn": {
"defaultMessage": "System Prompt",
"description": "Heading for the setting that lets users customize the System Prompt, on the settings screen"
},
"cmcjSh": {
"defaultMessage": "Preview voice",
"description": "Label for the button that plays a preview of the selected ElevenLabs text-to-speech voice"
},
"f/hGIY": { "f/hGIY": {
"defaultMessage": "Hello, how can I help you today?", "defaultMessage": "Hello, how can I help you today?",
"description": "A friendly message that appears at the start of new chat sessions" "description": "A friendly message that appears at the start of new chat sessions"
}, },
"gNu/AE": {
"defaultMessage": "Show microphone button in message input"
},
"gzJlXS": { "gzJlXS": {
"defaultMessage": "Share", "defaultMessage": "Share",
"description": "Label for a button which shares the text of a chat message using the user device's share functionality" "description": "Label for a button which shares the text of a chat message using the user device's share functionality"
}, },
"h9+jXQ": {
"defaultMessage": "The System Prompt is an invisible message inserted at the start of the chat and can be used to give ChatGPT information about itself and general guidelines for how it should respond. The <code>'{{ datetime }}'</code> tag is automatically replaced by the current date and time (use this to give the AI access to the time)."
},
"hJZwTS": { "hJZwTS": {
"defaultMessage": "Email address" "defaultMessage": "Email address"
}, },
@ -186,16 +186,9 @@
"defaultMessage": "User", "defaultMessage": "User",
"description": "Label that is shown above messages written by the user (as opposed to the AI) for publicly shared conversation (third person, formal)." "description": "Label that is shown above messages written by the user (as opposed to the AI) for publicly shared conversation (third person, formal)."
}, },
"sPtnbA": {
"defaultMessage": "The System Prompt is shown to ChatGPT by the &quot;System&quot; before your first message. The <code>'{{ datetime }}'</code> tag is automatically replaced by the current date and time."
},
"ss6kle": { "ss6kle": {
"defaultMessage": "Reset to default" "defaultMessage": "Reset to default"
}, },
"sskUPZ": {
"defaultMessage": "Your ElevenLabs Text-to-Speech API Key (optional)",
"description": "Heading for the ElevenLabs API key setting on the settings screen"
},
"tZdXp/": { "tZdXp/": {
"defaultMessage": "The temperature parameter controls the randomness of the AI's responses. Lower values will make the AI more predictable, while higher values will make it more creative." "defaultMessage": "The temperature parameter controls the randomness of the AI's responses. Lower values will make the AI more predictable, while higher values will make it more creative."
}, },
@ -207,6 +200,12 @@
"defaultMessage": "Customize system prompt", "defaultMessage": "Customize system prompt",
"description": "Label for the button that opens a modal for customizing the 'system prompt', a message used to customize and influence how the AI responds." "description": "Label for the button that opens a modal for customizing the 'system prompt', a message used to customize and influence how the AI responds."
}, },
"xXbJso": {
"defaultMessage": "Sign out"
},
"xqpqZE": {
"defaultMessage": "Your Elevenlabs API Key"
},
"y1F8Hs": { "y1F8Hs": {
"defaultMessage": "Your OpenAI API Key", "defaultMessage": "Your OpenAI API Key",
"description": "Heading for the OpenAI API key setting on the settings screen" "description": "Heading for the OpenAI API key setting on the settings screen"

View File

@ -1,173 +0,0 @@
import EventEmitter from 'events';
import chatManager from './chat-manager';
import { MessageTree } from './message-tree';
import { Chat, Message } from './types';
import { AsyncLoop } from './utils';
const endpoint = '/chatapi';
export let backend: {
current?: Backend | null
} = {};
export interface User {
email?: string;
name?: string;
avatar?: string;
}
export class Backend extends EventEmitter {
public user: User | null = null;
private sessionInterval = new AsyncLoop(() => this.getSession(), 1000 * 30);
private syncInterval = new AsyncLoop(() => this.sync(), 1000 * 60 * 2);
public constructor() {
super();
backend.current = this;
this.sessionInterval.start();
this.syncInterval.start();
chatManager.on('messages', async (messages: Message[]) => {
if (!this.isAuthenticated) {
return;
}
await this.post(endpoint + '/messages', { messages });
});
chatManager.on('title', async (id: string, title: string) => {
if (!this.isAuthenticated) {
return;
}
if (!title?.trim()) {
return;
}
await this.post(endpoint + '/title', { id, title });
});
}
public async getSession() {
const wasAuthenticated = this.isAuthenticated;
const session = await this.get(endpoint + '/session');
if (session?.authenticated) {
this.user = {
email: session.email,
name: session.name,
avatar: session.picture,
};
} else {
this.user = null;
}
if (wasAuthenticated !== this.isAuthenticated) {
this.emit('authenticated', this.isAuthenticated);
}
}
public async sync() {
if (!this.isAuthenticated) {
return;
}
const response = await this.post(endpoint + '/sync', {});
for (const chatID of Object.keys(response)) {
try {
const chat = chatManager.chats.get(chatID) || {
id: chatID,
messages: new MessageTree(),
} as Chat;
if (response[chatID].deleted) {
chatManager.deleteChat(chatID);
continue;
}
chat.title = response[chatID].title || chat.title;
chat.messages.addMessages(response[chatID].messages);
chatManager.loadChat(chat);
} catch (e) {
console.error('error loading chat', e);
}
}
chatManager.emit('update');
}
async signIn() {
window.location.href = endpoint + '/login';
}
get isAuthenticated() {
return this.user !== null;
}
async logout() {
window.location.href = endpoint + '/logout';
}
async shareChat(chat: Chat): Promise<string | null> {
try {
const { id } = await this.post(endpoint + '/share', {
...chat,
messages: chat.messages.serialize(),
});
if (typeof id === 'string') {
return id;
}
} catch (e) {
console.error(e);
}
return null;
}
async getSharedChat(id: string): Promise<Chat | null> {
const format = process.env.REACT_APP_SHARE_URL || (endpoint + '/share/:id');
const url = format.replace(':id', id);
try {
const chat = await this.get(url);
if (chat?.messages?.length) {
chat.messages = new MessageTree(chat.messages);
return chat;
}
} catch (e) {
console.error(e);
}
return null;
}
async deleteChat(id: string) {
if (!this.isAuthenticated) {
return;
}
return this.post(endpoint + '/delete', { id });
}
async get(url: string) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
}
async post(url: string, data: any) {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
}
}
if (process.env.REACT_APP_AUTH_PROVIDER) {
new Backend();
}

View File

@ -1,395 +0,0 @@
import { BroadcastChannel } from 'broadcast-channel';
import EventEmitter from 'events';
import MiniSearch, { SearchResult } from 'minisearch'
import { v4 as uuidv4 } from 'uuid';
import { Chat, deserializeChat, getOpenAIMessageFromMessage, Message, Parameters, serializeChat, UserSubmittedMessage } from './types';
import { MessageTree } from './message-tree';
import { createStreamingChatCompletion } from './openai';
import { createTitle } from './titles';
import { ellipsize, sleep } from './utils';
import * as idb from './idb';
export const channel = new BroadcastChannel('chats');
export class ChatManager extends EventEmitter {
public chats = new Map<string, Chat>();
public search = new Search(this.chats);
private loaded = false;
private changed = false;
private activeReplies = new Map<string, Message>();
constructor() {
super();
this.load();
this.on('update', () => {
this.changed = true;
});
channel.onmessage = (message: {
type: 'chat-update' | 'chat-delete',
data: string,
}) => {
switch (message.type) {
case 'chat-update':
const chat = deserializeChat(message.data);
const id = chat.id;
this.chats.set(id, chat);
this.emit(id);
break;
case 'chat-delete':
this.deleteChat(message.data, false);
break;
}
};
(async () => {
while (true) {
await sleep(100);
if (this.loaded && this.changed) {
this.changed = false;
await this.save();
}
}
})();
}
public async createChat(): Promise<string> {
const id = uuidv4();
const chat: Chat = {
id,
messages: new MessageTree(),
created: Date.now(),
updated: Date.now(),
};
this.chats.set(id, chat);
this.search.update(chat);
channel.postMessage({ type: 'chat-update', data: serializeChat(chat) });
return id;
}
public async sendMessage(message: UserSubmittedMessage) {
const chat = this.chats.get(message.chatID);
if (!chat || chat.deleted) {
throw new Error('Chat not found');
}
const newMessage: Message = {
id: uuidv4(),
parentID: message.parentID,
chatID: chat.id,
timestamp: Date.now(),
role: 'user',
content: message.content,
done: true,
};
chat.messages.addMessage(newMessage);
chat.updated = Date.now();
this.emit(chat.id);
this.emit('messages', [newMessage]);
channel.postMessage({ type: 'chat-update', data: serializeChat(chat) });
const messages: Message[] = message.parentID
? chat.messages.getMessageChainTo(message.parentID)
: [];
messages.push(newMessage);
await this.getReply(messages, message.requestedParameters);
}
public async regenerate(message: Message, requestedParameters: Parameters) {
const chat = this.chats.get(message.chatID);
if (!chat || chat.deleted) {
throw new Error('Chat not found');
}
const messages: Message[] = message.parentID
? chat.messages.getMessageChainTo(message.parentID)
: [];
await this.getReply(messages, requestedParameters);
}
private async getReply(messages: Message[], requestedParameters: Parameters) {
const latestMessage = messages[messages.length - 1];
const chat = this.chats.get(latestMessage.chatID);
if (!chat || chat.deleted) {
throw new Error('Chat not found');
}
const reply: Message = {
id: uuidv4(),
parentID: latestMessage.id,
chatID: latestMessage.chatID,
timestamp: Date.now(),
role: 'assistant',
model: requestedParameters.model,
content: '',
done: false,
};
this.activeReplies.set(reply.id, reply);
chat.messages.addMessage(reply);
chat.updated = Date.now();
this.emit(chat.id);
channel.postMessage({ type: 'chat-update', data: serializeChat(chat) });
const messagesToSend = messages.map(getOpenAIMessageFromMessage)
const { emitter, cancel } = await createStreamingChatCompletion(messagesToSend, requestedParameters);
let lastChunkReceivedAt = Date.now();
const onError = (error?: string) => {
if (reply.done) {
return;
}
clearInterval(timer);
cancel();
reply.content += `\n\nI'm sorry, I'm having trouble connecting to OpenAI (${error || 'no response from the API'}). Please make sure you've entered your OpenAI API key correctly and try again.`;
reply.content = reply.content.trim();
reply.done = true;
this.activeReplies.delete(reply.id);
chat.messages.updateMessage(reply);
chat.updated = Date.now();
this.emit(chat.id);
this.emit('messages', [reply]);
channel.postMessage({ type: 'chat-update', data: serializeChat(chat) });
};
let timer = setInterval(() => {
const sinceLastChunk = Date.now() - lastChunkReceivedAt;
if (sinceLastChunk > 30000 && !reply.done) {
onError('no response from OpenAI in the last 30 seconds');
}
}, 2000);
emitter.on('error', (e: any) => {
if (!reply.content && !reply.done) {
lastChunkReceivedAt = Date.now();
onError(e);
}
});
emitter.on('data', (data: string) => {
if (reply.done) {
return;
}
lastChunkReceivedAt = Date.now();
reply.content = data;
chat.messages.updateMessage(reply);
this.emit(chat.id);
channel.postMessage({ type: 'chat-update', data: serializeChat(chat) });
});
emitter.on('done', async () => {
if (reply.done) {
return;
}
clearInterval(timer);
lastChunkReceivedAt = Date.now();
reply.done = true;
this.activeReplies.delete(reply.id);
chat.messages.updateMessage(reply);
chat.updated = Date.now();
this.emit(chat.id);
this.emit('messages', [reply]);
this.emit('update');
channel.postMessage({ type: 'chat-update', data: serializeChat(chat) });
setTimeout(() => this.search.update(chat), 500);
if (!chat.title) {
chat.title = await createTitle(chat, requestedParameters.apiKey);
if (chat.title) {
this.emit(chat.id);
this.emit('title', chat.id, chat.title);
this.emit('update');
channel.postMessage({ type: 'chat-update', data: serializeChat(chat) });
setTimeout(() => this.search.update(chat), 500);
}
}
});
}
private async save() {
const serialized = Array.from(this.chats.values())
.map((c) => {
const serialized = { ...c } as any;
serialized.messages = c.messages.serialize();
return serialized;
});
await idb.set('chats', serialized);
}
public cancelReply(id: string) {
const reply = this.activeReplies.get(id);
if (reply) {
reply.done = true;
this.activeReplies.delete(reply.id);
const chat = this.chats.get(reply.chatID);
const message = chat?.messages.get(id);
if (message) {
message.done = true;
this.emit(reply.chatID);
this.emit('messages', [reply]);
this.emit('update');
channel.postMessage({ type: 'chat-update', data: serializeChat(chat!) });
}
} else {
console.log('failed to find reply');
}
}
private async load() {
const serialized = await idb.get('chats');
if (serialized) {
for (const chat of serialized) {
try {
if (chat.deleted) {
continue;
}
const messages = new MessageTree();
for (const m of chat.messages) {
messages.addMessage(m);
}
chat.messages = messages;
this.loadChat(chat);
} catch (e) {
console.error(e);
}
}
this.emit('update');
}
this.loaded = true;
}
public loadChat(chat: Chat) {
if (!chat?.id) {
return;
}
const existing = this.chats.get(chat.id);
if (existing && existing.deleted) {
return;
}
if (existing && existing.title && !chat.title) {
chat.title = existing.title;
}
chat.created = chat.messages.first?.timestamp || 0;
chat.updated = chat.messages.mostRecentLeaf().timestamp;
this.chats.set(chat.id, chat);
this.search.update(chat);
this.emit(chat.id);
}
public get(id: string): Chat | undefined {
return this.chats.get(id);
}
public deleteChat(id: string, broadcast = true) {
this.chats.delete(id);
this.search.delete(id);
this.emit(id);
if (broadcast) {
channel.postMessage({ type: 'chat-delete', data: id });
}
}
}
export class Search {
private index = new MiniSearch({
fields: ['value'],
storeFields: ['id', 'value'],
});
constructor(private chats: Map<string, Chat>) {
}
public update(chat: Chat) {
const messages = chat.messages.serialize()
.map((m: Message) => m.content)
.join('\n\n');
const doc = {
id: chat.id,
value: chat.title + '\n\n' + messages,
};
if (!this.index.has(chat.id)) {
this.index.add(doc);
} else {
this.index.replace(doc);
}
}
public delete(id: string) {
this.index.remove({ id });
}
public query(query: string) {
if (!query?.trim().length) {
const searchResults = Array.from(this.chats.values())
.sort((a, b) => b.updated - a.updated)
.slice(0, 10);
const results = this.processSearchResults(searchResults);
return results;
}
let searchResults = this.index.search(query, { fuzzy: 0.2 });
let output = this.processSearchResults(searchResults);
if (!output.length) {
searchResults = this.index.search(query, { prefix: true });
output = this.processSearchResults(searchResults);
}
return output;
}
private processSearchResults(searchResults: SearchResult[] | Chat[]) {
const output: any[] = [];
for (const item of searchResults) {
const chatID = item.id;
let chat = this.chats.get(chatID);
if (!chat) {
continue;
}
chat = { ...chat };
let description = chat.messages?.first?.content || '';
description = ellipsize(description, 400);
if (!chat.title) {
chat.title = ellipsize(description, 100);
}
if (!chat.title || !description) {
continue;
}
output.push({
chatID,
title: chat.title,
description,
});
}
return output;
}
}
const chatManager = new ChatManager();
export default chatManager;

View File

@ -1,9 +1,9 @@
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import { Button, Modal, PasswordInput, TextInput } from "@mantine/core"; import { Button, Modal, PasswordInput, TextInput } from "@mantine/core";
import { useCallback, useState } from "react"; import { useCallback } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { useAppDispatch, useAppSelector } from "../../store"; import { useAppDispatch, useAppSelector } from "../store";
import { closeModals, openLoginModal, openSignupModal, selectModal } from "../../store/ui"; import { closeModals, openLoginModal, openSignupModal, selectModal } from "../store/ui";
const Container = styled.form` const Container = styled.form`
* { * {

View File

@ -5,14 +5,25 @@ import { useSpotlight } from '@mantine/spotlight';
import { Burger, Button, ButtonProps } from '@mantine/core'; import { Burger, Button, ButtonProps } from '@mantine/core';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useAppContext } from '../context'; import { useAppContext } from '../core/context';
import { backend } from '../backend'; import { backend } from '../core/backend';
import { MenuItem, secondaryMenu } from '../menus'; import { MenuItem, secondaryMenu } from '../menus';
import { useAppDispatch, useAppSelector } from '../store'; import { useAppDispatch, useAppSelector } from '../store';
import { selectOpenAIApiKey } from '../store/api-keys';
import { setTab } from '../store/settings-ui'; import { setTab } from '../store/settings-ui';
import { selectSidebarOpen, toggleSidebar } from '../store/sidebar'; import { selectSidebarOpen, toggleSidebar } from '../store/sidebar';
import { openSignupModal } from '../store/ui'; import { openLoginModal, openSignupModal } from '../store/ui';
import { useOption } from '../core/options/use-option';
import { useHotkeys } from '@mantine/hooks';
const Banner = styled.div`
background: rgba(224, 49, 49, 0.2);
color: white;
text-align: center;
font-family: "Work Sans", sans-serif;
font-size: 80%;
padding: 0.5rem;
cursor: pointer;
`;
const HeaderContainer = styled.div` const HeaderContainer = styled.div`
display: flex; display: flex;
@ -61,6 +72,7 @@ const HeaderContainer = styled.div`
h2 { h2 {
margin: 0 0.5rem; margin: 0 0.5rem;
font-size: 1rem; font-size: 1rem;
white-space: nowrap;
} }
.spacer { .spacer {
@ -134,7 +146,7 @@ export default function Header(props: HeaderProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const spotlight = useSpotlight(); const spotlight = useSpotlight();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const openAIApiKey = useAppSelector(selectOpenAIApiKey); const [openAIApiKey] = useOption<string>('openai', 'apiKey');
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
@ -149,13 +161,37 @@ export default function Header(props: HeaderProps) {
setLoading(true); setLoading(true);
navigate(`/`); navigate(`/`);
setLoading(false); setLoading(false);
setTimeout(() => document.querySelector<HTMLTextAreaElement>('#message-input')?.focus(), 100);
}, [navigate]); }, [navigate]);
const openSettings = useCallback(() => { const openSettings = useCallback(() => {
dispatch(setTab(openAIApiKey ? 'options' : 'user')); dispatch(setTab(openAIApiKey ? 'chat' : 'user'));
}, [openAIApiKey, dispatch]); }, [openAIApiKey, dispatch]);
const header = useMemo(() => ( const signIn = useCallback(() => {
if ((window as any).AUTH_PROVIDER !== 'local') {
backend.current?.signIn();
} else {
dispatch(openLoginModal());
}
}, [dispatch])
const signUp = useCallback(() => {
if ((window as any).AUTH_PROVIDER !== 'local') {
backend.current?.signIn();
} else {
dispatch(openSignupModal());
}
}, [dispatch])
useHotkeys([
['c', onNewChat],
]);
const header = useMemo(() => (<>
{context.sessionExpired && <Banner onClick={signIn}>
You have been signed out. Click here to sign back in.
</Banner>}
<HeaderContainer className={context.isHome ? 'shaded' : ''}> <HeaderContainer className={context.isHome ? 'shaded' : ''}>
<Helmet> <Helmet>
<title> <title>
@ -172,15 +208,9 @@ export default function Header(props: HeaderProps) {
<FormattedMessage defaultMessage="Share" description="Label for the button used to create a public share URL for a chat log" /> <FormattedMessage defaultMessage="Share" description="Label for the button used to create a public share URL for a chat log" />
</HeaderButton>} </HeaderButton>}
{backend.current && !context.authenticated && ( {backend.current && !context.authenticated && (
<HeaderButton onClick={() => { <HeaderButton onClick={localStorage.getItem('registered') ? signIn : signUp}>
if (process.env.REACT_APP_AUTH_PROVIDER !== 'local') {
backend.current?.signIn();
} else {
dispatch(openSignupModal());
}
}}>
<FormattedMessage defaultMessage="Sign in <h>to sync</h>" <FormattedMessage defaultMessage="Sign in <h>to sync</h>"
description="Label for sign in button, indicating the purpose of signing in is to sync your data between devices" description="Label for sign in button, which indicates that the purpose of signing in is to sync your data between devices. Less important text inside <h> tags is hidden on small screens."
values={{ values={{
h: (chunks: any) => <span className="hide-on-mobile">{chunks}</span> h: (chunks: any) => <span className="hide-on-mobile">{chunks}</span>
}} /> }} />
@ -190,7 +220,8 @@ export default function Header(props: HeaderProps) {
<FormattedMessage defaultMessage="New Chat" description="Label for the button used to start a new chat session" /> <FormattedMessage defaultMessage="New Chat" description="Label for the button used to start a new chat session" />
</HeaderButton> </HeaderButton>
</HeaderContainer> </HeaderContainer>
), [sidebarOpen, onBurgerClick, props.title, props.share, props.canShare, props.onShare, openSettings, onNewChat, loading, context.authenticated, context.isHome, context.isShare, spotlight.openSpotlight]); </>), [sidebarOpen, onBurgerClick, props.title, props.share, props.canShare, props.onShare, openSettings, onNewChat,
loading, context.authenticated, context.sessionExpired, context.isHome, context.isShare, spotlight.openSpotlight, signIn, signUp]);
return header; return header;
} }

View File

@ -1,17 +1,17 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { Button, ActionIcon, Textarea, Loader, Popover, Checkbox, Center, Group } from '@mantine/core'; import { Button, ActionIcon, Textarea, Loader, Popover } from '@mantine/core';
import { useLocalStorage, useMediaQuery } from '@mantine/hooks'; import { getHotkeyHandler, useHotkeys, useMediaQuery } from '@mantine/hooks';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl'; import { FormattedMessage, useIntl } from 'react-intl';
import { useLocation } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useAppContext } from '../context'; import { useAppContext } from '../core/context';
import { useAppDispatch, useAppSelector } from '../store'; import { useAppDispatch, useAppSelector } from '../store';
import { selectMessage, setMessage } from '../store/message'; import { selectMessage, setMessage } from '../store/message';
import { selectTemperature } from '../store/parameters'; import { selectSettingsTab, openOpenAIApiKeyPanel } from '../store/settings-ui';
import { openOpenAIApiKeyPanel, openSystemPromptPanel, openTemperaturePanel } from '../store/settings-ui'; import { speechRecognition, supportsSpeechRecognition } from '../core/speech-recognition-types'
import { speechRecognition, supportsSpeechRecognition } from '../speech-recognition-types'
import { useWhisper } from '@chengsokdara/use-whisper'; import { useWhisper } from '@chengsokdara/use-whisper';
import { selectUseOpenAIWhisper, selectOpenAIApiKey } from '../store/api-keys'; import QuickSettings from './quick-settings';
import { useOption } from '../core/options/use-option';
const Container = styled.div` const Container = styled.div`
background: #292933; background: #292933;
@ -24,19 +24,8 @@ const Container = styled.div`
text-align: right; text-align: right;
} }
.inner > .bottom {
display: flex;
justify-content: space-between;
}
@media (max-width: 600px) {
.inner > .bottom {
flex-direction: column;
align-items: flex-start;
}
}
.settings-button { .settings-button {
margin: 0.5rem -0.4rem 0.5rem 1rem;
font-size: 0.7rem; font-size: 0.7rem;
color: #999; color: #999;
} }
@ -49,14 +38,12 @@ export interface MessageInputProps {
} }
export default function MessageInput(props: MessageInputProps) { export default function MessageInput(props: MessageInputProps) {
const temperature = useAppSelector(selectTemperature);
const message = useAppSelector(selectMessage); const message = useAppSelector(selectMessage);
const [recording, setRecording] = useState(false); const [recording, setRecording] = useState(false);
const [speechError, setSpeechError] = useState<string | null>(null); const [speechError, setSpeechError] = useState<string | null>(null);
const hasVerticalSpace = useMediaQuery('(min-height: 1000px)'); const hasVerticalSpace = useMediaQuery('(min-height: 1000px)');
const useOpenAIWhisper = useAppSelector(selectUseOpenAIWhisper); const [useOpenAIWhisper] = useOption<boolean>('speech-recognition', 'use-whisper');
const openAIApiKey = useAppSelector(selectOpenAIApiKey); const [openAIApiKey] = useOption<string>('openai', 'apiKey');
const [isEnterToSend, setIsEnterToSend] = useLocalStorage({ key: 'isEnterToSend', defaultValue: false})
const [initialMessage, setInitialMessage] = useState(''); const [initialMessage, setInitialMessage] = useState('');
const { const {
@ -69,12 +56,16 @@ export default function MessageInput(props: MessageInputProps) {
streaming: false, streaming: false,
}); });
const navigate = useNavigate();
const context = useAppContext(); const context = useAppContext();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
const onCustomizeSystemPromptClick = useCallback(() => dispatch(openSystemPromptPanel()), [dispatch]); const tab = useAppSelector(selectSettingsTab);
const onTemperatureClick = useCallback(() => dispatch(openTemperaturePanel()), [dispatch]);
const [showMicrophoneButton] = useOption<boolean>('speech-recognition', 'show-microphone');
const [submitOnEnter] = useOption<boolean>('input', 'submit-on-enter');
const onChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => { const onChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
dispatch(setMessage(e.target.value)); dispatch(setMessage(e.target.value));
}, [dispatch]); }, [dispatch]);
@ -84,10 +75,15 @@ export default function MessageInput(props: MessageInputProps) {
const onSubmit = useCallback(async () => { const onSubmit = useCallback(async () => {
setSpeechError(null); setSpeechError(null);
if (await context.onNewMessage(message)) { const id = await context.onNewMessage(message);
if (id) {
if (!window.location.pathname.includes(id)) {
navigate('/chat/' + id);
}
dispatch(setMessage('')); dispatch(setMessage(''));
} }
}, [context, message, dispatch]); }, [context, message, dispatch, navigate]);
const onSpeechError = useCallback((e: any) => { const onSpeechError = useCallback((e: any) => {
console.error('speech recognition error', e); console.error('speech recognition error', e);
@ -118,7 +114,7 @@ export default function MessageInput(props: MessageInputProps) {
} else if (result.state == 'denied') { } else if (result.state == 'denied') {
denied = true; denied = true;
} }
} catch (e) {} } catch (e) { }
if (!granted && !denied) { if (!granted && !denied) {
try { try {
@ -191,12 +187,13 @@ export default function MessageInput(props: MessageInputProps) {
} }
}, [initialMessage, transcript, recording, transcribing, useOpenAIWhisper, dispatch]); }, [initialMessage, transcript, recording, transcribing, useOpenAIWhisper, dispatch]);
const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => { useHotkeys([
if(e.key === 'Enter' && e.shiftKey === false && !props.disabled) { ['n', () => document.querySelector<HTMLTextAreaElement>('#message-input')?.focus()]
e.preventDefault(); ]);
onSubmit();
} const blur = useCallback(() => {
}, [isEnterToSend, onSubmit, props.disabled]); document.querySelector<HTMLTextAreaElement>('#message-input')?.blur();
}, []);
const rightSection = useMemo(() => { const rightSection = useMemo(() => {
return ( return (
@ -210,7 +207,7 @@ export default function MessageInput(props: MessageInputProps) {
}}> }}>
{context.generating && (<> {context.generating && (<>
<Button variant="subtle" size="xs" compact onClick={() => { <Button variant="subtle" size="xs" compact onClick={() => {
context.chat.cancelReply(context.currentChat.leaf!.id); context.chat.cancelReply(context.currentChat.chat?.id, context.currentChat.leaf!.id);
}}> }}>
<FormattedMessage defaultMessage={"Cancel"} description="Label for the button that can be clicked while the AI is generating a response to cancel generation" /> <FormattedMessage defaultMessage={"Cancel"} description="Label for the button that can be clicked while the AI is generating a response to cancel generation" />
</Button> </Button>
@ -218,7 +215,7 @@ export default function MessageInput(props: MessageInputProps) {
</>)} </>)}
{!context.generating && ( {!context.generating && (
<> <>
<Popover width={200} position="bottom" withArrow shadow="md" opened={speechError !== null}> {showMicrophoneButton && <Popover width={200} position="bottom" withArrow shadow="md" opened={speechError !== null}>
<Popover.Target> <Popover.Target>
<ActionIcon size="xl" <ActionIcon size="xl"
onClick={onSpeechStart}> onClick={onSpeechStart}>
@ -245,7 +242,7 @@ export default function MessageInput(props: MessageInputProps) {
</Button> </Button>
</div> </div>
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>}
<ActionIcon size="xl" <ActionIcon size="xl"
onClick={onSubmit}> onClick={onSubmit}>
<i className="fa fa-paper-plane" style={{ fontSize: '90%' }} /> <i className="fa fa-paper-plane" style={{ fontSize: '90%' }} />
@ -254,7 +251,7 @@ export default function MessageInput(props: MessageInputProps) {
)} )}
</div> </div>
); );
}, [recording, transcribing, onSubmit, onSpeechStart, props.disabled, context.generating, speechError, onHideSpeechError]); }, [recording, transcribing, onSubmit, onSpeechStart, props.disabled, context.generating, speechError, onHideSpeechError, showMicrophoneButton]);
const disabled = context.generating; const disabled = context.generating;
@ -263,9 +260,21 @@ export default function MessageInput(props: MessageInputProps) {
return null; return null;
} }
const hotkeyHandler = useMemo(() => {
const keys = [
['Escape', blur, { preventDefault: true }],
];
if (submitOnEnter) {
keys.unshift(['Enter', onSubmit, { preventDefault: true }]);
}
const handler = getHotkeyHandler(keys as any);
return handler;
}, [onSubmit, blur, submitOnEnter]);
return <Container> return <Container>
<div className="inner"> <div className="inner">
<Textarea disabled={props.disabled || disabled} <Textarea disabled={props.disabled || disabled}
id="message-input"
autosize autosize
minRows={(hasVerticalSpace || context.isHome) ? 3 : 2} minRows={(hasVerticalSpace || context.isHome) ? 3 : 2}
maxRows={12} maxRows={12}
@ -274,31 +283,8 @@ export default function MessageInput(props: MessageInputProps) {
onChange={onChange} onChange={onChange}
rightSection={rightSection} rightSection={rightSection}
rightSectionWidth={context.generating ? 100 : 55} rightSectionWidth={context.generating ? 100 : 55}
onKeyDown={onKeyDown} /> onKeyDown={hotkeyHandler} />
<div className="bottom"> <QuickSettings key={tab} />
<Group my="sm" spacing="xs">
<Button variant="subtle"
className="settings-button"
size="xs"
compact
onClick={onCustomizeSystemPromptClick}>
<span>
<FormattedMessage defaultMessage={"Customize system prompt"} description="Label for the button that opens a modal for customizing the 'system prompt', a message used to customize and influence how the AI responds." />
</span>
</Button>
<Button variant="subtle"
className="settings-button"
size="xs"
compact
onClick={onTemperatureClick}>
<span>
<FormattedMessage defaultMessage="Temperature: {temperature, number, ::.0}"
description="Label for the button that opens a modal for setting the 'temperature' (randomness) of AI responses"
values={{ temperature }} />
</span>
</Button>
</Group>
</div>
</div> </div>
</Container>; </Container>;
} }

View File

@ -75,7 +75,7 @@ export function Markdown(props: MarkdownProps) {
rehypePlugins={[rehypeKatex]} rehypePlugins={[rehypeKatex]}
components={{ components={{
ol({ start, children }) { ol({ start, children }) {
return <ol start={start ?? 1} style={{ counterReset: `list-item ${(start || 1) - 1}` }}> return <ol start={start ?? 1} style={{ counterReset: `list-item ${(start || 1)}` }}>
{children} {children}
</ol>; </ol>;
}, },

View File

@ -1,13 +1,15 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { Button, CopyButton, Loader, Textarea } from '@mantine/core'; import { Button, CopyButton, Loader, Textarea } from '@mantine/core';
import { Message } from "../types"; import { Message } from "../core/chat/types";
import { share } from '../utils'; import { share } from '../core/utils';
import { ElevenLabsReaderButton } from '../tts/elevenlabs'; import { TTSButton } from './tts-button';
import { Markdown } from './markdown'; import { Markdown } from './markdown';
import { useAppContext } from '../context'; import { useAppContext } from '../core/context';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl'; import { FormattedMessage, useIntl } from 'react-intl';
import { useAppSelector } from '../store';
import { selectSettingsTab } from '../store/settings-ui';
// hide for everyone but screen readers // hide for everyone but screen readers
const SROnly = styled.span` const SROnly = styled.span`
@ -138,6 +140,10 @@ const Container = styled.div`
.fa + span { .fa + span {
margin-left: 0.2em; margin-left: 0.2em;
@media (max-width: 40em) {
display: none;
}
} }
.mantine-Button-root { .mantine-Button-root {
@ -204,6 +210,8 @@ export default function MessageComponent(props: { message: Message, last: boolea
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const intl = useIntl(); const intl = useIntl();
const tab = useAppSelector(selectSettingsTab);
const getRoleName = useCallback((role: string, share = false) => { const getRoleName = useCallback((role: string, share = false) => {
switch (role) { switch (role) {
case 'user': case 'user':
@ -237,14 +245,17 @@ export default function MessageComponent(props: { message: Message, last: boolea
</strong> </strong>
{props.message.role === 'assistant' && props.last && !props.message.done && <InlineLoader />} {props.message.role === 'assistant' && props.last && !props.message.done && <InlineLoader />}
</span> </span>
{props.message.done && <ElevenLabsReaderButton selector={'.content-' + props.message.id} />} <TTSButton id={props.message.id}
selector={'.content-' + props.message.id}
complete={!!props.message.done}
autoplay={props.last && context.chat.lastReplyID === props.message.id} />
<div style={{ flexGrow: 1 }} /> <div style={{ flexGrow: 1 }} />
<CopyButton value={props.message.content}> <CopyButton value={props.message.content}>
{({ copy, copied }) => ( {({ copy, copied }) => (
<Button variant="subtle" size="sm" compact onClick={copy} style={{ marginLeft: '1rem' }}> <Button variant="subtle" size="sm" compact onClick={copy} style={{ marginLeft: '1rem' }}>
<i className="fa fa-clipboard" /> <i className="fa fa-clipboard" />
{copied ? <FormattedMessage defaultMessage="Copied" description="Label for copy-to-clipboard button after a successful copy" /> {copied ? <FormattedMessage defaultMessage="Copied" description="Label for copy-to-clipboard button after a successful copy" />
: <FormattedMessage defaultMessage="Copy" description="Label for copy-to-clipboard button" />} : <span><FormattedMessage defaultMessage="Copy" description="Label for copy-to-clipboard button" /></span>}
</Button> </Button>
)} )}
</CopyButton> </CopyButton>
@ -293,7 +304,7 @@ export default function MessageComponent(props: { message: Message, last: boolea
{props.last && <EndOfChatMarker />} {props.last && <EndOfChatMarker />}
</Container> </Container>
) )
}, [props.last, props.share, editing, content, context, props.message, props.message.content]); }, [props.last, props.share, editing, content, context, props.message, props.message.content, tab]);
return elem; return elem;
} }

View File

@ -1,11 +1,12 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { SpotlightProvider } from '@mantine/spotlight'; import { SpotlightProvider } from '@mantine/spotlight';
import { useChatSpotlightProps } from '../spotlight'; import { useChatSpotlightProps } from '../spotlight';
import { LoginModal, CreateAccountModal } from './auth/modals'; import { LoginModal, CreateAccountModal } from './auth-modals';
import Header, { HeaderProps, SubHeader } from './header'; import Header, { HeaderProps, SubHeader } from './header';
import MessageInput from './input'; import MessageInput from './input';
import SettingsDrawer from './settings'; import SettingsDrawer from './settings';
import Sidebar from './sidebar'; import Sidebar from './sidebar';
import AudioControls from './tts-controls';
const Container = styled.div` const Container = styled.div`
position: absolute; position: absolute;
@ -13,12 +14,13 @@ const Container = styled.div`
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: #292933;
color: white;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
overflow: hidden; overflow: hidden;
background: #292933;
color: white;
.sidebar { .sidebar {
width: 0%; width: 0%;
height: 100%; height: 100%;
@ -55,10 +57,14 @@ const Container = styled.div`
`; `;
const Main = styled.div` const Main = styled.div`
flex-grow: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 1; overflow: scroll;
overflow: hidden;
@media (min-height: 30em) {
overflow: hidden;
}
`; `;
export function Page(props: { export function Page(props: {
@ -79,6 +85,7 @@ export function Page(props: {
onShare={props.headerProps?.onShare} /> onShare={props.headerProps?.onShare} />
{props.showSubHeader && <SubHeader />} {props.showSubHeader && <SubHeader />}
{props.children} {props.children}
<AudioControls />
<MessageInput key={localStorage.getItem('openai-api-key')} /> <MessageInput key={localStorage.getItem('openai-api-key')} />
<SettingsDrawer /> <SettingsDrawer />
<LoginModal /> <LoginModal />
@ -86,5 +93,4 @@ export function Page(props: {
</Main> </Main>
</Container> </Container>
</SpotlightProvider>; </SpotlightProvider>;
} }

View File

@ -1,20 +1,23 @@
import React, { Suspense } from 'react'; import React, { Suspense, useCallback } from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import slugify from 'slugify'; import slugify from 'slugify';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { Loader } from '@mantine/core'; import { Loader } from '@mantine/core';
import { useAppContext } from '../../context'; import { useAppContext } from '../../core/context';
import { backend } from '../../backend'; import { backend } from '../../core/backend';
import { Page } from '../page'; import { Page } from '../page';
import { useOption } from '../../core/options/use-option';
const Message = React.lazy(() => import(/* webpackPreload: true */ '../message')); const Message = React.lazy(() => import(/* webpackPreload: true */ '../message'));
const Messages = styled.div` const Messages = styled.div`
max-height: 100%; @media (min-height: 30em) {
flex-grow: 1; max-height: 100%;
overflow-y: scroll; flex-grow: 1;
overflow-y: scroll;
}
display: flex; display: flex;
flex-direction: column; flex-direction: column;
`; `;
@ -29,20 +32,22 @@ const EmptyMessage = styled.div`
font-family: "Work Sans", sans-serif; font-family: "Work Sans", sans-serif;
line-height: 1.7; line-height: 1.7;
gap: 1rem; gap: 1rem;
min-height: 10rem;
`; `;
export default function ChatPage(props: any) { export default function ChatPage(props: any) {
const { id } = useParams(); const { id } = useParams();
const context = useAppContext(); const context = useAppContext();
let firstLoad = true; const [autoScrollWhenOpeningChat] = useOption('auto-scroll', 'auto-scroll-when-opening-chat')
const [autoScrollWhileGenerating] = useOption('auto-scroll', 'auto-scroll-while-generating');
useEffect(() => { useEffect(() => {
if (props.share || !context.currentChat.chatLoadedAt) { if (props.share || !context.currentChat.chatLoadedAt) {
return; return;
} }
const shouldScroll = (Date.now() - context.currentChat.chatLoadedAt) > 5000 || firstLoad; const shouldScroll = autoScrollWhenOpeningChat || (Date.now() - context.currentChat.chatLoadedAt) > 5000;
firstLoad = false;
if (!shouldScroll) { if (!shouldScroll) {
return; return;
@ -56,9 +61,23 @@ export default function ChatPage(props: any) {
const offset = Math.max(0, latest.offsetTop - 100); const offset = Math.max(0, latest.offsetTop - 100);
setTimeout(() => { setTimeout(() => {
container?.scrollTo({ top: offset, behavior: 'smooth' }); container?.scrollTo({ top: offset, behavior: 'smooth' });
}, 500); }, 100);
} }
}, [context.currentChat?.chatLoadedAt, context.currentChat?.messagesToDisplay.length, props.share]); }, [context.currentChat?.chatLoadedAt, context.currentChat?.messagesToDisplay.length, props.share, autoScrollWhenOpeningChat]);
const autoScroll = useCallback(() => {
if (context.generating && autoScrollWhileGenerating) {
const container = document.querySelector('#messages') as HTMLElement;
container?.scrollTo({ top: 999999, behavior: 'smooth' });
container?.parentElement?.scrollTo({ top: 999999, behavior: 'smooth' });
}
}, [context.generating, autoScrollWhileGenerating]);
useEffect(() => {
const timer = setInterval(() => autoScroll(), 1000);
return () => {
clearInterval(timer);
};
}, [autoScroll]);
const messagesToDisplay = context.currentChat.messagesToDisplay; const messagesToDisplay = context.currentChat.messagesToDisplay;
@ -94,7 +113,7 @@ export default function ChatPage(props: any) {
{shouldShowChat && ( {shouldShowChat && (
<div style={{ paddingBottom: '4.5rem' }}> <div style={{ paddingBottom: '4.5rem' }}>
{messagesToDisplay.map((message) => ( {messagesToDisplay.map((message) => (
<Message key={message.id} <Message key={id + ":" + message.id}
message={message} message={message}
share={props.share} share={props.share}
last={context.currentChat.chat!.messages.leafs.some(n => n.id === message.id)} /> last={context.currentChat.chat!.messages.leafs.some(n => n.id === message.id)} />

View File

@ -2,10 +2,11 @@ import styled from '@emotion/styled';
import { Button } from '@mantine/core'; import { Button } from '@mantine/core';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { useAppDispatch, useAppSelector } from '../../store'; import { useAppDispatch } from '../../store';
import { selectOpenAIApiKey } from '../../store/api-keys';
import { openOpenAIApiKeyPanel } from '../../store/settings-ui'; import { openOpenAIApiKeyPanel } from '../../store/settings-ui';
import { Page } from '../page'; import { Page } from '../page';
import { useOption } from '../../core/options/use-option';
import { isProxySupported } from '../../core/chat/openai';
const Container = styled.div` const Container = styled.div`
flex-grow: 1; flex-grow: 1;
@ -20,7 +21,7 @@ const Container = styled.div`
`; `;
export default function LandingPage(props: any) { export default function LandingPage(props: any) {
const openAIApiKey = useAppSelector(selectOpenAIApiKey); const [openAIApiKey] = useOption<string>('openai', 'apiKey');
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const onConnectButtonClick = useCallback(() => dispatch(openOpenAIApiKeyPanel()), [dispatch]); const onConnectButtonClick = useCallback(() => dispatch(openOpenAIApiKeyPanel()), [dispatch]);
@ -30,16 +31,11 @@ export default function LandingPage(props: any) {
<FormattedMessage defaultMessage={'Hello, how can I help you today?'} <FormattedMessage defaultMessage={'Hello, how can I help you today?'}
description="A friendly message that appears at the start of new chat sessions" /> description="A friendly message that appears at the start of new chat sessions" />
</p> </p>
{!openAIApiKey && ( {!openAIApiKey && !isProxySupported() && (
<Button size="xs" variant="light" compact onClick={onConnectButtonClick}> <Button size="xs" variant="light" compact onClick={onConnectButtonClick}>
<FormattedMessage defaultMessage={'Connect your OpenAI account to get started'} /> <FormattedMessage defaultMessage={'Connect your OpenAI account to get started'} />
</Button> </Button>
)} )}
<p>
<Button size="xs" variant="light" component="a" href="https://www.chatwithgpt.ai" target="_blank">
Try the new beta app<i style={{ marginLeft: '0.5rem' }} className="fa fa-arrow-up-right-from-square" />
</Button>
</p>
</Container> </Container>
</Page>; </Page>;
} }

View File

@ -0,0 +1,66 @@
import styled from '@emotion/styled';
import { useAppContext } from '../core/context';
import { Option } from '../core/options/option';
import { useOption } from '../core/options/use-option';
import { Button } from '@mantine/core';
import { useAppDispatch, useAppSelector } from '../store';
import { useCallback } from 'react';
import { setTabAndOption } from '../store/settings-ui';
const Container = styled.div`
margin: 0.5rem -0.5rem;
display: flex;
flex-wrap: wrap;
text-align: left;
justify-content: center;
@media (min-width: 40em) {
justify-content: flex-end;
}
.mantine-Button-root {
font-size: 0.7rem;
color: #999;
}
`;
export function QuickSettingsButton(props: { groupID: string, option: Option }) {
const context = useAppContext();
const dispatch = useAppDispatch();
const [value] = useOption(props.groupID, props.option.id, context.id || undefined);
const onClick = useCallback(() => {
dispatch(setTabAndOption({ tab: props.option.displayOnSettingsScreen, option: props.option.id }));
}, [props.groupID, props.option.id, dispatch]);
const labelBuilder = props.option.displayInQuickSettings?.label;
let label = props.option.id;
if (labelBuilder) {
label = typeof labelBuilder === 'string' ? labelBuilder : labelBuilder(value, context.chat.options, context);
}
return (
<Button variant="subtle" size="xs" compact onClick={onClick}>
<span>
{label}
</span>
</Button>
)
}
export default function QuickSettings(props: any) {
const context = useAppContext();
const options = context.chat.getQuickSettings();
if (!options.length) {
return <div style={{ height: '1rem' }} />;
}
return <Container>
{options.map(o => <QuickSettingsButton groupID={o.groupID} option={o.option} key={o.groupID + "." + o.option.id} />)}
</Container>;
}

View File

@ -0,0 +1,5 @@
import SettingsTab from "./tab";
export default function ChatOptionsTab(props: any) {
return <SettingsTab name="chat" />
}

View File

@ -1,13 +1,14 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { Button, Drawer, Tabs } from "@mantine/core"; import { Button, Drawer, Tabs } from "@mantine/core";
import { useMediaQuery } from '@mantine/hooks'; import { useMediaQuery } from '@mantine/hooks';
import { useCallback } from 'react'; import { useCallback, useEffect } from 'react';
import UserOptionsTab from './user'; import UserOptionsTab from './user';
import GenerationOptionsTab from './options'; import ChatOptionsTab from './chat';
import { useAppDispatch, useAppSelector } from '../../store'; import { useAppDispatch, useAppSelector } from '../../store';
import { closeSettingsUI, selectSettingsTab, setTab } from '../../store/settings-ui'; import { closeSettingsUI, selectSettingsOption, selectSettingsTab, setTab } from '../../store/settings-ui';
import SpeechOptionsTab from './speech'; import SpeechOptionsTab from './speech';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import UIPreferencesTab from './ui-preferences';
const Container = styled.div` const Container = styled.div`
padding: .4rem 1rem 1rem 1rem; padding: .4rem 1rem 1rem 1rem;
@ -76,12 +77,19 @@ export interface SettingsDrawerProps {
export default function SettingsDrawer(props: SettingsDrawerProps) { export default function SettingsDrawer(props: SettingsDrawerProps) {
const tab = useAppSelector(selectSettingsTab); const tab = useAppSelector(selectSettingsTab);
const option = useAppSelector(selectSettingsOption);
const small = useMediaQuery('(max-width: 40em)'); const small = useMediaQuery('(max-width: 40em)');
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const close = useCallback(() => dispatch(closeSettingsUI()), [dispatch]); const close = useCallback(() => dispatch(closeSettingsUI()), [dispatch]);
const onTabChange = useCallback((tab: string) => dispatch(setTab(tab)), [dispatch]); const onTabChange = useCallback((tab: string) => dispatch(setTab(tab)), [dispatch]);
useEffect(() => {
setTimeout(() => {
document.querySelector('.focused')?.scrollIntoView();
}, 1000);
}, [tab, option]);
return ( return (
<Drawer size="50rem" <Drawer size="50rem"
position='right' position='right'
@ -93,13 +101,15 @@ export default function SettingsDrawer(props: SettingsDrawerProps) {
<Container> <Container>
<Tabs value={tab} onTabChange={onTabChange} style={{ margin: '0rem' }}> <Tabs value={tab} onTabChange={onTabChange} style={{ margin: '0rem' }}>
<Tabs.List grow={small}> <Tabs.List grow={small}>
<Tabs.Tab value="options">Options</Tabs.Tab> <Tabs.Tab value="chat">Chat</Tabs.Tab>
<Tabs.Tab value="user">User</Tabs.Tab>
<Tabs.Tab value="speech">Speech</Tabs.Tab> <Tabs.Tab value="speech">Speech</Tabs.Tab>
<Tabs.Tab value="ui">UI</Tabs.Tab>
<Tabs.Tab value="user">User</Tabs.Tab>
</Tabs.List> </Tabs.List>
<UserOptionsTab /> <ChatOptionsTab />
<GenerationOptionsTab />
<SpeechOptionsTab /> <SpeechOptionsTab />
<UIPreferencesTab />
<UserOptionsTab />
</Tabs> </Tabs>
<div id="save"> <div id="save">
<Button variant="light" fullWidth size="md" onClick={close}> <Button variant="light" fullWidth size="md" onClick={close}>

View File

@ -1,12 +1,25 @@
export default function SettingsOption(props: { export default function SettingsOption(props: {
focused?: boolean; focused?: boolean;
heading?: string; heading?: string;
description?: any;
children?: any; children?: any;
span?: number; span?: number;
collapsed?: boolean;
}) { }) {
if (!props.heading || props.collapsed) {
return props.children;
}
return ( return (
<section className={props.focused ? 'focused' : ''}> <section className={props.focused ? 'focused' : ''}>
{props.heading && <h3>{props.heading}</h3>} {props.heading && <h3>{props.heading}</h3>}
{props.description && <div style={{
fontSize: "90%",
opacity: 0.9,
marginTop: '-0.5rem',
}}>
{props.description}
</div>}
{props.children} {props.children}
</section> </section>
); );

View File

@ -1,101 +0,0 @@
import SettingsTab from "./tab";
import SettingsOption from "./option";
import { Button, Select, Slider, Textarea } from "@mantine/core";
import { useCallback, useMemo } from "react";
import { defaultSystemPrompt, defaultModel } from "../../openai";
import { useAppDispatch, useAppSelector } from "../../store";
import { resetModel, setModel, selectModel, resetSystemPrompt, selectSystemPrompt, selectTemperature, setSystemPrompt, setTemperature } from "../../store/parameters";
import { selectSettingsOption } from "../../store/settings-ui";
import { FormattedMessage, useIntl } from "react-intl";
export default function GenerationOptionsTab(props: any) {
const intl = useIntl();
const option = useAppSelector(selectSettingsOption);
const initialSystemPrompt = useAppSelector(selectSystemPrompt);
const model = useAppSelector(selectModel);
const temperature = useAppSelector(selectTemperature);
const dispatch = useAppDispatch();
const onSystemPromptChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => dispatch(setSystemPrompt(event.target.value)), [dispatch]);
const onModelChange = useCallback((value: string) => dispatch(setModel(value)), [dispatch]);
const onResetSystemPrompt = useCallback(() => dispatch(resetSystemPrompt()), [dispatch]);
const onResetModel = useCallback(() => dispatch(resetModel()), [dispatch]);
const onTemperatureChange = useCallback((value: number) => dispatch(setTemperature(value)), [dispatch]);
const resettableSystemPromopt = initialSystemPrompt
&& (initialSystemPrompt?.trim() !== defaultSystemPrompt.trim());
const resettableModel = model
&& (model?.trim() !== defaultModel.trim());
const systemPromptOption = useMemo(() => (
<SettingsOption heading={intl.formatMessage({ defaultMessage: "System Prompt", description: "Heading for the setting that lets users customize the System Prompt, on the settings screen" })}
focused={option === 'system-prompt'}>
<Textarea
value={initialSystemPrompt || defaultSystemPrompt}
onChange={onSystemPromptChange}
minRows={5}
maxRows={10}
autosize />
<p style={{ marginBottom: '0.7rem' }}>
<FormattedMessage defaultMessage="The System Prompt is shown to ChatGPT by the &quot;System&quot; before your first message. The <code>'{{ datetime }}'</code> tag is automatically replaced by the current date and time."
values={{ code: chunk => <code style={{ whiteSpace: 'nowrap' }}>{chunk}</code> }} />
</p>
{resettableSystemPromopt && <Button size="xs" compact variant="light" onClick={onResetSystemPrompt}>
<FormattedMessage defaultMessage="Reset to default" />
</Button>}
</SettingsOption>
), [option, initialSystemPrompt, resettableSystemPromopt, onSystemPromptChange, onResetSystemPrompt]);
const modelOption = useMemo(() => (
<SettingsOption heading={intl.formatMessage({ defaultMessage: "Model", description: "Heading for the setting that lets users choose a model to interact with, on the settings screen" })}
focused={option === 'model'}>
<Select
value={model || defaultModel}
data={[
{
label: intl.formatMessage({ defaultMessage: "GPT 3.5 Turbo (default)" }),
value: "gpt-3.5-turbo",
},
{
label: intl.formatMessage({ defaultMessage: "GPT 4 (requires invite)" }),
value: "gpt-4",
},
]}
onChange={onModelChange} />
{model === 'gpt-4' && (
<p style={{ marginBottom: '0.7rem' }}>
<FormattedMessage defaultMessage="Note: GPT-4 will only work if your OpenAI account has been granted access to the new model. <a>Request access here.</a>"
values={{ a: chunk => <a href="https://openai.com/waitlist/gpt-4-api" target="_blank" rel="noreferer">{chunk}</a> }} />
</p>
)}
{resettableModel && <Button size="xs" compact variant="light" onClick={onResetModel}>
<FormattedMessage defaultMessage="Reset to default" />
</Button>}
</SettingsOption>
), [option, model, resettableModel, onModelChange, onResetModel]);
const temperatureOption = useMemo(() => (
<SettingsOption heading={intl.formatMessage({
defaultMessage: "Temperature: {temperature, number, ::.0}",
description: "Label for the button that opens a modal for setting the 'temperature' (randomness) of AI responses",
}, { temperature })}
focused={option === 'temperature'}>
<Slider value={temperature} onChange={onTemperatureChange} step={0.1} min={0} max={1} precision={3} />
<p>
<FormattedMessage defaultMessage="The temperature parameter controls the randomness of the AI's responses. Lower values will make the AI more predictable, while higher values will make it more creative." />
</p>
</SettingsOption>
), [temperature, option, onTemperatureChange]);
const elem = useMemo(() => (
<SettingsTab name="options">
{systemPromptOption}
{modelOption}
{temperatureOption}
</SettingsTab>
), [systemPromptOption, modelOption, temperatureOption]);
return elem;
}

View File

@ -1,81 +1,5 @@
import SettingsTab from "./tab"; import SettingsTab from "./tab";
import SettingsOption from "./option";
import { Button, Select, TextInput } from "@mantine/core";
import { useAppDispatch, useAppSelector } from "../../store";
import { selectElevenLabsApiKey, setElevenLabsApiKey } from "../../store/api-keys";
import { useCallback, useEffect, useMemo, useState } from "react";
import { selectVoice, setVoice } from "../../store/voices";
import { getVoices } from "../../tts/elevenlabs";
import { selectSettingsOption } from "../../store/settings-ui";
import { defaultVoiceList } from "../../tts/defaults";
import { FormattedMessage, useIntl } from "react-intl";
export default function SpeechOptionsTab() { export default function SpeechOptionsTab() {
const intl = useIntl(); return <SettingsTab name="speech" />
const option = useAppSelector(selectSettingsOption);
const elevenLabsApiKey = useAppSelector(selectElevenLabsApiKey);
const voice = useAppSelector(selectVoice);
const dispatch = useAppDispatch();
const onElevenLabsApiKeyChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => dispatch(setElevenLabsApiKey(event.target.value)), [dispatch]);
const onVoiceChange = useCallback((value: string) => dispatch(setVoice(value)), [dispatch]);
const [voices, setVoices] = useState<any[]>(defaultVoiceList);
useEffect(() => {
if (elevenLabsApiKey) {
getVoices().then(data => {
if (data?.voices?.length) {
setVoices(data.voices);
}
});
}
}, [elevenLabsApiKey]);
const apiKeyOption = useMemo(() => (
<SettingsOption heading={intl.formatMessage({ defaultMessage: 'Your ElevenLabs Text-to-Speech API Key (optional)', description: "Heading for the ElevenLabs API key setting on the settings screen" })}
focused={option === 'elevenlabs-api-key'}>
<TextInput placeholder={intl.formatMessage({ defaultMessage: "Paste your API key here" })}
value={elevenLabsApiKey || ''} onChange={onElevenLabsApiKeyChange} />
<p>
<FormattedMessage defaultMessage="Give ChatGPT a realisic human voice by connecting your ElevenLabs account (preview the available voices below). <a>Click here to sign up.</a>"
values={{
a: (chunks: any) => <a href="https://beta.elevenlabs.io" target="_blank" rel="noreferrer">{chunks}</a>
}} />
</p>
<p>
<FormattedMessage defaultMessage="You can find your API key by clicking your avatar or initials in the top right of the ElevenLabs website, then clicking Profile. Your API key is stored only on this device and never transmitted to anyone except ElevenLabs." />
</p>
</SettingsOption>
), [option, elevenLabsApiKey, onElevenLabsApiKeyChange]);
const voiceOption = useMemo(() => (
<SettingsOption heading={intl.formatMessage({ defaultMessage: 'Voice', description: 'Heading for the setting that lets users choose an ElevenLabs text-to-speech voice, on the settings screen' })}
focused={option === 'elevenlabs-voice'}>
<Select
value={voice}
onChange={onVoiceChange}
data={[
...voices.map(v => ({ label: v.name, value: v.voice_id })),
]} />
<audio controls style={{ display: 'none' }} id="voice-preview" key={voice}>
<source src={voices.find(v => v.voice_id === voice)?.preview_url} type="audio/mpeg" />
</audio>
<Button onClick={() => (document.getElementById('voice-preview') as HTMLMediaElement)?.play()} variant='light' compact style={{ marginTop: '1rem' }}>
<i className='fa fa-headphones' />
<span>
<FormattedMessage defaultMessage="Preview voice" description="Label for the button that plays a preview of the selected ElevenLabs text-to-speech voice" />
</span>
</Button>
</SettingsOption>
), [option, voice, voices, onVoiceChange]);
const elem = useMemo(() => (
<SettingsTab name="speech">
{apiKeyOption}
{voices.length > 0 && voiceOption}
</SettingsTab>
), [apiKeyOption, voiceOption, voices.length]);
return elem;
} }

View File

@ -1,5 +1,15 @@
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import { Tabs } from "@mantine/core"; import { Button, NumberInput, PasswordInput, Select, Slider, Switch, Tabs, Text, TextInput, Textarea } from "@mantine/core";
import { Option } from "../../core/options/option";
import SettingsOption from "./option";
import { selectSettingsOption } from "../../store/settings-ui";
import { useAppSelector } from "../../store";
import { FormattedMessage } from "react-intl";
import { useOption } from "../../core/options/use-option";
import { Context, useAppContext } from "../../core/context";
import { pluginMetadata as pluginMetadata } from "../../core/plugins/metadata";
import { globalOptions } from "../../global-options";
import { useEffect } from "react";
const Settings = styled.div` const Settings = styled.div`
font-family: "Work Sans", sans-serif; font-family: "Work Sans", sans-serif;
@ -9,6 +19,11 @@ const Settings = styled.div`
margin-bottom: .618rem; margin-bottom: .618rem;
padding: 0.618rem; padding: 0.618rem;
section {
padding-left: 0;
padding-right: 0;
}
h3 { h3 {
font-size: 1rem; font-size: 1rem;
font-weight: bold; font-weight: bold;
@ -29,6 +44,13 @@ const Settings = styled.div`
code { code {
font-family: "Fira Code", monospace; font-family: "Fira Code", monospace;
} }
.mantine-NumberInput-root, .slider-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
} }
.focused { .focused {
@ -50,14 +72,184 @@ const Settings = styled.div`
} }
`; `;
const OptionWrapper = styled.div`
& {
margin-top: 1rem;
}
* {
font-family: "Work Sans", sans-serif;
color: white;
font-size: 1rem;
}
`;
export function PluginOptionWidget(props: { pluginID: string, option: Option, chatID?: string | null | undefined, context: Context }) {
const requestedOption = useAppSelector(selectSettingsOption);
const option = props.option;
const [_value, setValue, renderProps] = useOption(props.pluginID, option.id, props.chatID || undefined);
const value = _value ?? option.defaultValue;
if (option.defaultValue && (typeof value === 'undefined' || value === null)) {
console.warn(`expected option value for ${props.pluginID}.${option.id}, got:`, _value);
}
if (renderProps.hidden) {
return null;
}
let component: any;
switch (renderProps.type) {
case "textarea":
component = (
<Textarea label={!option.displayAsSeparateSection ? renderProps.label : null}
placeholder={renderProps.placeholder}
disabled={renderProps.disabled}
value={value || ''}
onChange={e => setValue(e.target.value)}
minRows={5} />
);
break;
case "select":
component = (
<Select label={!option.displayAsSeparateSection ? renderProps.label : null}
placeholder={renderProps.placeholder}
disabled={renderProps.disabled}
value={value || ''}
onChange={value => setValue(value)}
data={renderProps.options ?? []}
/>
);
break;
case "slider":
component = (
<div className="slider-wrapper">
{!option.displayAsSeparateSection && <Text size='sm' weight={500}>{renderProps.label}:</Text>}
<Slider label={value.toString()}
disabled={renderProps.disabled}
value={value}
onChange={v => setValue(v)}
min={renderProps.min}
max={renderProps.max}
step={renderProps.step}
style={{
minWidth: '10rem',
flexGrow: 1,
}} />
</div>
);
break;
case "number":
component = (
<NumberInput label={!option.displayAsSeparateSection ? (renderProps.label + ':') : null}
disabled={renderProps.disabled}
value={value ?? undefined}
onChange={v => setValue(v)}
min={renderProps.min}
max={renderProps.max}
step={renderProps.step} />
);
break;
case "checkbox":
component = (
<Switch label={!option.displayAsSeparateSection ? renderProps.label : null}
disabled={renderProps.disabled}
checked={value}
onChange={e => setValue(e.target.checked)} />
);
break;
case "password":
component = (
<PasswordInput label={!option.displayAsSeparateSection ? renderProps.label : null}
placeholder={renderProps.placeholder}
disabled={renderProps.disabled}
value={value}
onChange={e => setValue(e.target.value)} />
);
break;
case "text":
default:
component = (
<TextInput label={!option.displayAsSeparateSection ? renderProps.label : null}
placeholder={renderProps.placeholder}
disabled={renderProps.disabled}
value={value}
onChange={e => setValue(e.target.value)} />
);
break;
}
const focused = !!requestedOption && option.id === requestedOption;
const elem = <OptionWrapper className={(focused && !option.displayAsSeparateSection) ? 'focused' : ''}>
{component}
{typeof renderProps.description?.props === 'undefined' && <p style={{ marginBottom: '0.7rem' }}>{renderProps.description}</p>}
{typeof renderProps.description?.props !== 'undefined' && renderProps.description}
</OptionWrapper>;
if (option.displayAsSeparateSection) {
return <SettingsOption heading={renderProps.label} focused={focused}>
{elem}
{option.resettable && <div style={{
display: 'flex',
gap: '1rem',
marginTop: '1rem',
}}>
<Button size="xs" compact variant="light" onClick={() => setValue(option.defaultValue)}>
<FormattedMessage defaultMessage="Reset to default" />
</Button>
</div>}
</SettingsOption>;
}
return elem;
}
export default function SettingsTab(props: { export default function SettingsTab(props: {
name: string; name: string;
children?: any; children?: any;
}) { }) {
const context = useAppContext();
const optionSets = [...globalOptions, ...pluginMetadata]
.map(metadata => ({
id: metadata.id,
name: metadata.name,
description: metadata.description,
options: metadata.options.filter(o => o.displayOnSettingsScreen === props.name),
resettable: metadata.options.filter(o => o.displayOnSettingsScreen === props.name && o.resettable && !o.displayAsSeparateSection).length > 0,
collapsed: metadata.options.filter(o => o.displayOnSettingsScreen === props.name && o.displayAsSeparateSection).length > 0,
hidden: typeof metadata.hidden === 'function' ? metadata.hidden(context.chat.options) : metadata.hidden,
}))
.filter(({ options, hidden }) => options.length && !hidden);
return ( return (
<Tabs.Panel value={props.name}> <Tabs.Panel value={props.name}>
<Settings> <Settings>
{props.children} {props.children}
{optionSets.map(({ name, id, description, options, resettable, collapsed }) => <>
<SettingsOption heading={name} description={description} collapsed={collapsed} key={id}>
{options.map(o => <PluginOptionWidget
pluginID={id}
option={o}
chatID={context.id}
context={context}
key={id + "." + o.id} />)}
{resettable && <div style={{
display: 'flex',
gap: '1rem',
marginTop: '1rem',
}}>
<Button size="xs" compact variant="light" onClick={() => context.chat.resetPluginOptions(id, context.id)}>
<FormattedMessage defaultMessage="Reset to default" />
</Button>
</div>}
</SettingsOption>
</>)}
</Settings> </Settings>
</Tabs.Panel> </Tabs.Panel>
); );

View File

@ -0,0 +1,5 @@
import SettingsTab from "./tab";
export default function UIPreferencesTab(props: any) {
return <SettingsTab name="ui" />
}

View File

@ -1,34 +1,23 @@
import SettingsTab from "./tab"; import { Button, FileButton } from "@mantine/core";
import { importChat } from "../../core/chat/chat-persistance";
import { Chat, serializeChat } from "../../core/chat/types";
import { useAppContext } from "../../core/context";
import SettingsOption from "./option"; import SettingsOption from "./option";
import { Button, Checkbox, TextInput } from "@mantine/core"; import SettingsTab from "./tab";
import { useCallback, useMemo } from "react"; import { useState, useCallback } from "react";
import { useAppDispatch, useAppSelector } from "../../store";
import { selectOpenAIApiKey, setOpenAIApiKeyFromEvent, selectUseOpenAIWhisper, setUseOpenAIWhisperFromEvent } from "../../store/api-keys";
import { selectSettingsOption } from "../../store/settings-ui";
import { FormattedMessage, useIntl } from "react-intl";
import { supportsSpeechRecognition } from "../../speech-recognition-types";
import { useAppContext } from "../../context";
import { serializeChat } from "../../types";
export default function UserOptionsTab(props: any) { export default function UserOptionsTab(props: any) {
const option = useAppSelector(selectSettingsOption);
const openaiApiKey = useAppSelector(selectOpenAIApiKey);
const useOpenAIWhisper = useAppSelector(selectUseOpenAIWhisper);
const intl = useIntl()
const dispatch = useAppDispatch();
const onOpenAIApiKeyChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => dispatch(setOpenAIApiKeyFromEvent(event)), [dispatch]);
const onUseOpenAIWhisperChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => dispatch(setUseOpenAIWhisperFromEvent(event)), [dispatch]);
const context = useAppContext(); const context = useAppContext();
const doc = context.chat.doc;
const getData = useCallback(async () => { const getData = useCallback(async () => {
const chats = Array.from(context.chat.chats.values()); const chats = context.chat.all() as Chat[];
return chats.map(chat => ({ return chats.map(chat => serializeChat(chat));
...chat,
messages: chat.messages.serialize(),
}));
}, [context.chat]); }, [context.chat]);
const [importedChats, setImportedChats] = useState<number | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const handleExport = useCallback(async () => { const handleExport = useCallback(async () => {
const data = await getData(); const data = await getData();
const json = JSON.stringify(data); const json = JSON.stringify(data);
@ -40,42 +29,62 @@ export default function UserOptionsTab(props: any) {
link.click(); link.click();
}, [getData]); }, [getData]);
const elem = useMemo(() => ( const handleImport = useCallback(
async (file: File) => {
try {
const reader = new FileReader();
reader.onload = (e) => {
const json = e.target?.result as string;
const data = JSON.parse(json) as Chat[];
if (data.length > 0) {
context.chat.doc.transact(() => {
for (const chat of data) {
try {
importChat(doc, chat);
} catch (e) {
console.error(e);
}
}
});
setImportedChats(data.length);
setErrorMessage(null);
} else {
setErrorMessage("The imported file does not contain any chat data.");
}
};
reader.readAsText(file);
} catch (error) {
setErrorMessage("Failed to import chat data.");
}
},
[doc]
);
const successMessage = importedChats ? (
<div style={{ color: 'green' }}>
<i className="fa fa-check-circle"></i>
<span style={{ marginLeft: '0.5em' }}>Imported {importedChats} chat(s)</span>
</div>
) : null;
const errorMessageElement = errorMessage ? (
<div style={{ color: 'red' }}>{errorMessage}</div>
) : null;
return (
<SettingsTab name="user"> <SettingsTab name="user">
<SettingsOption heading="Export"> <SettingsOption heading="Import and Export">
<div> <div>
<Button variant="light" onClick={handleExport} style={{ <Button variant="light" onClick={handleExport} style={{
marginRight: '1rem', marginRight: '1rem',
}}>Export</Button> }}>Export</Button>
<FileButton onChange={handleImport} accept=".json">
{(props) => <Button variant="light" {...props}>Import</Button>}
</FileButton>
</div> </div>
</SettingsOption> {successMessage}
<SettingsOption heading={intl.formatMessage({ defaultMessage: "Your OpenAI API Key", description: "Heading for the OpenAI API key setting on the settings screen" })} {errorMessageElement}
focused={option === 'openai-api-key'}>
<TextInput
placeholder={intl.formatMessage({ defaultMessage: "Paste your API key here" })}
value={openaiApiKey || ''}
onChange={onOpenAIApiKeyChange} />
<p>
<a href="https://platform.openai.com/account/api-keys" target="_blank" rel="noreferrer">
<FormattedMessage defaultMessage="Find your API key here." description="Label for the link that takes the user to the page on the OpenAI website where they can find their API key." />
</a>
</p>
{supportsSpeechRecognition && <Checkbox
style={{ marginTop: '1rem' }}
id="use-openai-whisper-api" checked={useOpenAIWhisper!} onChange={onUseOpenAIWhisperChange}
label="Use the OpenAI Whisper API for speech recognition."
/>}
<p>
<FormattedMessage defaultMessage="Your API key is stored only on this device and never transmitted to anyone except OpenAI." />
</p>
<p>
<FormattedMessage defaultMessage="OpenAI API key usage is billed at a pay-as-you-go rate, separate from your ChatGPT subscription." />
</p>
</SettingsOption> </SettingsOption>
</SettingsTab> </SettingsTab>
), [option, openaiApiKey, useOpenAIWhisper, onOpenAIApiKeyChange]); );
}
return elem;
}

View File

@ -1,10 +1,10 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { ActionIcon, Avatar, Burger, Button, Menu } from '@mantine/core'; import { ActionIcon, Avatar, Burger, Button, Menu } from '@mantine/core';
import { useElementSize } from '@mantine/hooks'; import { useElementSize } from '@mantine/hooks';
import { useCallback, useMemo } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl'; import { FormattedMessage, useIntl } from 'react-intl';
import { backend } from '../../backend'; import { backend } from '../../core/backend';
import { useAppContext } from '../../context'; import { useAppContext } from '../../core/context';
import { useAppDispatch, useAppSelector } from '../../store'; import { useAppDispatch, useAppSelector } from '../../store';
import { setTab } from '../../store/settings-ui'; import { setTab } from '../../store/settings-ui';
import { selectSidebarOpen, toggleSidebar } from '../../store/sidebar'; import { selectSidebarOpen, toggleSidebar } from '../../store/sidebar';
@ -109,6 +109,18 @@ export default function Sidebar(props: {
const onBurgerClick = useCallback(() => dispatch(toggleSidebar()), [dispatch]); const onBurgerClick = useCallback(() => dispatch(toggleSidebar()), [dispatch]);
const { ref, width } = useElementSize(); const { ref, width } = useElementSize();
const [version, setVersion] = useState(0);
const update = useCallback(() => {
setVersion(v => v + 1);
}, []);
useEffect(() => {
context.chat.on('update', update);
return () => {
context.chat.off('update', update);
};
}, []);
const burgerLabel = sidebarOpen const burgerLabel = sidebarOpen
? intl.formatMessage({ defaultMessage: "Close sidebar" }) ? intl.formatMessage({ defaultMessage: "Close sidebar" })
: intl.formatMessage({ defaultMessage: "Open sidebar" }); : intl.formatMessage({ defaultMessage: "Open sidebar" });
@ -122,14 +134,14 @@ export default function Sidebar(props: {
<div className="sidebar-content"> <div className="sidebar-content">
<RecentChats /> <RecentChats />
</div> </div>
{backend.current && backend.current.isAuthenticated && ( {context.authenticated && (
<Menu width={width - 20}> <Menu width={width - 20}>
<Menu.Target> <Menu.Target>
<div className="sidebar-footer"> <div className="sidebar-footer">
<Avatar size="lg" src={backend.current!.user!.avatar} /> <Avatar size="lg" src={context.user!.avatar} />
<div className="user-info"> <div className="user-info">
<strong>{backend.current!.user!.name || backend.current!.user!.email}</strong> <strong>{context.user!.name || context.user!.email}</strong>
{!!backend.current!.user!.name && <span>{backend.current.user!.email}</span>} {!!context.user!.name && <span>{context.user!.email}</span>}
</div> </div>
<div className="spacer" /> <div className="spacer" />
@ -144,17 +156,17 @@ export default function Sidebar(props: {
}} icon={<i className="fas fa-gear" />}> }} icon={<i className="fas fa-gear" />}>
<FormattedMessage defaultMessage={"User settings"} description="Menu item that opens the user settings screen" /> <FormattedMessage defaultMessage={"User settings"} description="Menu item that opens the user settings screen" />
</Menu.Item> </Menu.Item>
{/*
<Menu.Divider /> <Menu.Divider />
<Menu.Item color="red" onClick={() => backend.current?.logout()} icon={<i className="fas fa-sign-out-alt" />}> <Menu.Item color="red" onClick={() => backend.current?.logout()} icon={<i className="fas fa-sign-out-alt" />}>
<FormattedMessage defaultMessage={"Sign out"} /> <FormattedMessage defaultMessage={"Sign out"} />
</Menu.Item> </Menu.Item>
*/}
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
)} )}
</Container> </Container>
), [sidebarOpen, width, ref, burgerLabel, onBurgerClick, dispatch, context.chat.chats.size]); ), [sidebarOpen, width, ref, burgerLabel, onBurgerClick, dispatch, version]);
return elem; return elem;
} }

View File

@ -1,13 +1,13 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useAppContext } from '../../context'; import { useAppContext } from '../../core/context';
import { useAppDispatch } from '../../store'; import { useAppDispatch } from '../../store';
import { toggleSidebar } from '../../store/sidebar'; 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 { useModals } from '@mantine/modals';
import { backend } from '../../backend'; import { backend } from '../../core/backend';
const Container = styled.div` const Container = styled.div`
margin: calc(1.618rem - 1rem); margin: calc(1.618rem - 1rem);
@ -56,9 +56,16 @@ const ChatListItemLink = styled(Link)`
.mantine-ActionIcon-root { .mantine-ActionIcon-root {
position: absolute; position: absolute;
right: 0.5rem; right: 0.0rem;
top: 50%; 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 modals = useModals();
const navigate = useNavigate(); const navigate = useNavigate();
const onDelete = useCallback(() => { const onDelete = useCallback((e?: React.MouseEvent) => {
e?.preventDefault();
e?.stopPropagation();
modals.openConfirmModal({ modals.openConfirmModal({
title: "Are you sure you want to delete this chat?", title: "Are you sure you want to delete this chat?",
children: <p style={{ lineHeight: 1.7 }}>The chat "{c.title}" will be permanently deleted. This cannot be undone.</p>, children: <p style={{ lineHeight: 1.7 }}>The chat "{c.title}" will be permanently deleted. This cannot be undone.</p>,
@ -93,33 +103,79 @@ function ChatListItem(props: { chat: any, onClick: any, selected: boolean }) {
confirm: "Try again", confirm: "Try again",
cancel: "Cancel", cancel: "Cancel",
}, },
onConfirm: onDelete, onConfirm: () => onDelete(),
}); });
} }
}, },
}); });
}, [c.chatID, c.title]); }, [c.chatID, c.title]);
const onRename = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// Display a modal with a TextInput
modals.openModal({
title: "Rename chat",
children: <div>
<Textarea
id="chat-title"
defaultValue={c.title}
maxLength={500}
autosize
required />
<Button
fullWidth
variant="light"
style={{ marginTop: '1rem' }}
onClick={() => {
const title = document.querySelector<HTMLInputElement>('#chat-title')?.value?.trim();
const ychat = context.chat.doc.getYChat(c.chatID);
if (ychat && title && title !== ychat?.title) {
ychat.title = title;
}
modals.closeAll();
}}
>
Save changes
</Button>
</div>,
});
}, [c.chatID, c.title]);
const [menuOpen, setMenuOpen] = useState(false);
const toggleMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(open => !open);
}, []);
return ( return (
<ChatListItemLink to={'/chat/' + c.chatID} <ChatListItemLink to={'/chat/' + c.chatID}
onClick={props.onClick} onClick={props.onClick}
data-chat-id={c.chatID} data-chat-id={c.chatID}
className={props.selected ? 'selected' : ''}> className={props.selected ? 'selected' : ''}>
<strong>{c.title || <FormattedMessage defaultMessage={"Untitled"} description="default title for untitled chat sessions" />}</strong> <strong>{c.title || <FormattedMessage defaultMessage={"Untitled"} description="default title for untitled chat sessions" />}</strong>
{props.selected && ( <Menu opened={menuOpen}
<Menu> closeOnClickOutside={true}
<Menu.Target> closeOnEscape={true}
<ActionIcon> onClose={() => setMenuOpen(false)}>
<i className="fas fa-ellipsis" /> <Menu.Target>
</ActionIcon> <ActionIcon size="xl" onClick={toggleMenu}>
</Menu.Target> <i className="fas fa-ellipsis" />
<Menu.Dropdown> </ActionIcon>
<Menu.Item onClick={onDelete} color="red" icon={<i className="fa fa-trash" />}> </Menu.Target>
<FormattedMessage defaultMessage={"Delete this chat"} /> <Menu.Dropdown>
</Menu.Item> <Menu.Item onClick={onRename} icon={<i className="fa fa-edit" />}>
</Menu.Dropdown> <FormattedMessage defaultMessage={"Rename this chat"} />
</Menu> </Menu.Item>
)} <Menu.Divider />
<Menu.Item onClick={onDelete} color="red" icon={<i className="fa fa-trash" />}>
<FormattedMessage defaultMessage={"Delete this chat"} />
</Menu.Item>
</Menu.Dropdown>
</Menu>
</ChatListItemLink> </ChatListItemLink>
); );
} }
@ -129,7 +185,7 @@ export default function RecentChats(props: any) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const currentChatID = context.currentChat.chat?.id; const currentChatID = context.currentChat.chat?.id;
const recentChats = context.chat.search.query(''); const recentChats = context.chat.searchChats('');
const onClick = useCallback((e: React.MouseEvent) => { const onClick = useCallback((e: React.MouseEvent) => {
if (e.currentTarget.closest('button')) { if (e.currentTarget.closest('button')) {
@ -152,6 +208,8 @@ export default function RecentChats(props: any) {
} }
}, [currentChatID]); }, [currentChatID]);
const synced = !backend.current || backend.current?.isSynced();
return ( return (
<Container> <Container>
{recentChats.length > 0 && <ChatList> {recentChats.length > 0 && <ChatList>
@ -159,7 +217,10 @@ export default function RecentChats(props: any) {
<ChatListItem key={c.chatID} chat={c} onClick={onClick} selected={c.chatID === currentChatID} /> <ChatListItem key={c.chatID} chat={c} onClick={onClick} selected={c.chatID === currentChatID} />
))} ))}
</ChatList>} </ChatList>}
{recentChats.length === 0 && <Empty> {recentChats.length === 0 && !synced && <Empty>
<Loader size="sm" variant="dots" />
</Empty>}
{recentChats.length === 0 && synced && <Empty>
<FormattedMessage defaultMessage={"No chats yet."} description="Message shown on the Chat History screen for new users who haven't started their first chat session" /> <FormattedMessage defaultMessage={"No chats yet."} description="Message shown on the Chat History screen for new users who haven't started their first chat session" />
</Empty>} </Empty>}
</Container> </Container>

View File

@ -0,0 +1,63 @@
import { Button } from "@mantine/core";
import { useCallback, useEffect, useState } from "react";
import { FormattedMessage } from "react-intl";
import { useTTS } from "../core/tts/use-tts";
import { useAppDispatch } from "../store";
import { setTabAndOption } from "../store/settings-ui";
const autoplayed = new Set<string>();
export function TTSButton(props: { id: string, selector: string, complete: boolean, autoplay?: boolean }) {
const dispatch = useAppDispatch();
const { key, state, voice, autoplayEnabled, play, pause, cancel, setSourceElement, setComplete } = useTTS();
const [clicked, setClicked] = useState(false);
const onClick = useCallback(() => {
setClicked(true);
if (!voice) {
dispatch(setTabAndOption({ tab: 'speech', option: 'service' }));
return;
}
if (!state || key !== props.id) {
setSourceElement(props.id, document.querySelector(props.selector)!);
play();
} else {
cancel();
}
setComplete(props.complete);
}, [state, key, props.id, props.selector, props.complete, voice]); //
useEffect(() => {
if (key === props.id) {
setComplete(props.complete);
}
}, [key, props.id, props.complete]);
useEffect(() => {
if (autoplayEnabled && props.autoplay && key !== props.id && voice && !clicked && !autoplayed.has(props.id) && document.visibilityState === 'visible') {
autoplayed.add(props.id);
setSourceElement(props.id, document.querySelector(props.selector)!);
play();
}
}, [clicked, key, voice, autoplayEnabled, props.id, props.selector, props.complete, props.autoplay]);
let active = state && key === props.id;
return (<>
<Button variant="subtle" size="sm" compact onClickCapture={onClick} loading={active && state?.buffering}>
{!active && <i className="fa fa-headphones" />}
{!active && <span>
<FormattedMessage defaultMessage="Play" description="Label for the button that starts text-to-speech playback" />
</span>}
{active && state?.buffering && <span>
<FormattedMessage defaultMessage="Loading audio..." description="Message indicating that text-to-speech audio is buffering" />
</span>}
{active && !state?.buffering && <span>
<FormattedMessage defaultMessage="Stop" description="Label for the button that stops text-to-speech playback" />
</span>}
</Button>
{JSON.stringify(state)}
</>);
}

View File

@ -0,0 +1,130 @@
import styled from '@emotion/styled';
import { ActionIcon, Button } from '@mantine/core';
import { useCallback, useEffect } from 'react';
import { useTTS } from '../core/tts/use-tts';
import { useAppContext } from '../core/context';
import { APP_NAME } from '../values';
import { useHotkeys } from '@mantine/hooks';
const Container = styled.div`
background: #292933;
border-top: thin solid #393933;
padding: 1rem;
// padding-bottom: 0.6rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
p {
font-family: "Work Sans", sans-serif;
font-size: 80%;
margin-bottom: 1rem;
}
.buttons {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
// .mantine-ActionIcon-root:disabled {
// background: transparent;
// border-color: transparent;
// }
}
`;
export default function AudioControls(props: any) {
const context = useAppContext();
const { state, play, pause, cancel } = useTTS();
const handlePlayPause = useCallback(() => {
if (state?.playing) {
pause();
} else {
play();
}
}, [state, pause, play]);
const handlePrevious = useCallback(() => {
if (!state) {
return;
}
play(state.index - 1);
}, [state, play]);
const handleNext = useCallback(() => {
if (!state) {
return;
}
play(state.index + 1);
}, [state, play]);
const handleJumpToStart = useCallback(() => {
play(0);
}, [play]);
const handleJumpToEnd = useCallback(() => {
if (!state) {
return;
}
play(state.length - 1);
}, [state, play]);
useEffect(() => {
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: context.currentChat.chat?.title || APP_NAME,
artist: APP_NAME,
});
navigator.mediaSession.setActionHandler('play', handlePlayPause);
navigator.mediaSession.setActionHandler('pause', handlePlayPause);
navigator.mediaSession.setActionHandler('previoustrack', handlePrevious);
navigator.mediaSession.setActionHandler('nexttrack', handleNext);
}
}, [context.currentChat.chat?.title, handlePlayPause, handlePrevious, handleNext]);
useEffect(() => {
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = state?.playing ? 'playing' : 'paused';
}
}, [state?.playing]);
useHotkeys([
['Space', handlePlayPause],
]);
if (!state) {
return null;
}
return (
<Container>
<div className="buttons">
<ActionIcon onClick={handleJumpToStart} variant='light' color='blue'>
<i className="fa fa-fast-backward" />
</ActionIcon>
<ActionIcon onClick={handlePrevious} variant='light' color='blue' disabled={state?.index === 0}>
<i className="fa fa-step-backward" />
</ActionIcon>
<ActionIcon onClick={handlePlayPause} variant='light' color='blue'>
<i className={state?.playing ? 'fa fa-pause' : 'fa fa-play'} />
</ActionIcon>
<ActionIcon onClick={handleNext} variant='light' color='blue' disabled={!state || (state.index === state.length - 1)}>
<i className="fa fa-step-forward" />
</ActionIcon>
<ActionIcon onClick={handleJumpToEnd} variant='light' color='blue'>
<i className="fa fa-fast-forward" />
</ActionIcon>
<ActionIcon onClick={cancel} variant='light' color='blue'>
<i className="fa fa-close" />
</ActionIcon>
</div>
</Container>
);
}

View File

@ -1,186 +0,0 @@
import React, { useState, useRef, useMemo, useEffect, useCallback } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { backend } from "./backend";
import ChatManagerInstance, { ChatManager } from "./chat-manager";
import store, { useAppDispatch } from "./store";
import { openOpenAIApiKeyPanel } from "./store/settings-ui";
import { Message } from "./types";
import { useChat, UseChatResult } from "./use-chat";
export interface Context {
authenticated: boolean;
chat: ChatManager;
id: string | undefined | null;
currentChat: UseChatResult;
isHome: boolean;
isShare: boolean;
generating: boolean;
onNewMessage: (message?: string) => Promise<boolean>;
regenerateMessage: (message: Message) => Promise<boolean>;
editMessage: (message: Message, content: string) => Promise<boolean>;
}
const AppContext = React.createContext<Context>({} as any);
export function useCreateAppContext(): Context {
const { id } = useParams();
const dispatch = useAppDispatch();
const pathname = useLocation().pathname;
const isHome = pathname === '/';
const isShare = pathname.startsWith('/s/');
const navigate = useNavigate();
const chatManager = useRef(ChatManagerInstance);
const currentChat = useChat(chatManager.current, id, isShare);
const [authenticated, setAuthenticated] = useState(backend.current?.isAuthenticated || false);
const updateAuth = useCallback((authenticated: boolean) => setAuthenticated(authenticated), []);
useEffect(() => {
backend.current?.on('authenticated', updateAuth);
return () => {
backend.current?.off('authenticated', updateAuth)
};
}, [updateAuth]);
const onNewMessage = useCallback(async (message?: string) => {
if (isShare) {
return false;
}
if (!message?.trim().length) {
return false;
}
const openaiApiKey = store.getState().apiKeys.openAIApiKey;
if (!openaiApiKey) {
dispatch(openOpenAIApiKeyPanel());
return false;
}
const parameters = store.getState().parameters;
if (id) {
await chatManager.current.sendMessage({
chatID: id,
content: message.trim(),
requestedParameters: {
...parameters,
apiKey: openaiApiKey,
},
parentID: currentChat.leaf?.id,
});
} else {
const id = await chatManager.current.createChat();
await chatManager.current.sendMessage({
chatID: id,
content: message.trim(),
requestedParameters: {
...parameters,
apiKey: openaiApiKey,
},
parentID: currentChat.leaf?.id,
});
navigate('/chat/' + id);
}
return true;
}, [dispatch, chatManager, id, currentChat.leaf, navigate, isShare]);
const regenerateMessage = useCallback(async (message: Message) => {
if (isShare) {
return false;
}
const openaiApiKey = store.getState().apiKeys.openAIApiKey;
if (!openaiApiKey) {
dispatch(openOpenAIApiKeyPanel());
return false;
}
const parameters = store.getState().parameters;
await chatManager.current.regenerate(message, {
...parameters,
apiKey: openaiApiKey,
});
return true;
}, [dispatch, chatManager, isShare]);
const editMessage = useCallback(async (message: Message, content: string) => {
if (isShare) {
return false;
}
if (!content?.trim().length) {
return false;
}
const openaiApiKey = store.getState().apiKeys.openAIApiKey;
if (!openaiApiKey) {
dispatch(openOpenAIApiKeyPanel());
return false;
}
const parameters = store.getState().parameters;
if (id) {
await chatManager.current.sendMessage({
chatID: id,
content: content.trim(),
requestedParameters: {
...parameters,
apiKey: openaiApiKey,
},
parentID: message.parentID,
});
} else {
const id = await chatManager.current.createChat();
await chatManager.current.sendMessage({
chatID: id,
content: content.trim(),
requestedParameters: {
...parameters,
apiKey: openaiApiKey,
},
parentID: message.parentID,
});
navigate('/chat/' + id);
}
return true;
}, [dispatch, chatManager, id, isShare, navigate]);
const generating = currentChat?.messagesToDisplay?.length > 0
? !currentChat.messagesToDisplay[currentChat.messagesToDisplay.length - 1].done
: false;
const context = useMemo<Context>(() => ({
authenticated,
id,
chat: chatManager.current,
currentChat,
isHome,
isShare,
generating,
onNewMessage,
regenerateMessage,
editMessage,
}), [chatManager, authenticated, generating, onNewMessage, regenerateMessage, editMessage, currentChat, id, isShare]);
return context;
}
export function useAppContext() {
return React.useContext(AppContext);
}
export function AppContextProvider(props: { children: React.ReactNode }) {
const context = useCreateAppContext();
return <AppContext.Provider value={context}>{props.children}</AppContext.Provider>;
}

View File

@ -0,0 +1,276 @@
import EventEmitter from 'events';
import * as Y from 'yjs';
import { encode, decode } from '@msgpack/msgpack';
import { MessageTree } from './chat/message-tree';
import { Chat } from './chat/types';
import { AsyncLoop } from "./utils/async-loop";
import { ChatManager } from '.';
import { getRateLimitResetTimeFromResponse } from './utils';
import { importChat } from './chat/chat-persistance';
const endpoint = '/chatapi';
export let backend: {
current?: Backend | null
} = {};
export interface User {
id: string;
email?: string;
name?: string;
avatar?: string;
services?: string[];
}
export class Backend extends EventEmitter {
public user: User | null = null;
private checkedSession = false;
private sessionInterval = new AsyncLoop(() => this.getSession(), 1000 * 30);
private syncInterval = new AsyncLoop(() => this.sync(), 1000 * 5);
private pendingYUpdate: Uint8Array | null = null;
private lastFullSyncAt = 0;
private legacySync = false;
private rateLimitedUntil = 0;
public constructor(private context: ChatManager) {
super();
if ((window as any).AUTH_PROVIDER) {
backend.current = this;
this.sessionInterval.start();
this.syncInterval.start();
}
}
public isSynced() {
return (this.checkedSession && !this.isAuthenticated) || this.lastFullSyncAt > 0;
}
public async getSession() {
if (Date.now() < this.rateLimitedUntil) {
console.log(`Waiting another ${this.rateLimitedUntil - Date.now()}ms to check session due to rate limiting.`);
return;
}
const wasAuthenticated = this.isAuthenticated;
const session = await this.get(endpoint + '/session');
if (session?.authProvider) {
(window as any).AUTH_PROVIDER = session.authProvider;
}
if (session?.authenticated) {
this.user = {
id: session.userID,
email: session.email,
name: session.name,
avatar: session.picture,
services: session.services,
};
} else {
this.user = null;
}
this.checkedSession = true;
if (wasAuthenticated !== this.isAuthenticated) {
this.emit('authenticated', this.isAuthenticated);
this.lastFullSyncAt = 0;
}
}
public async sync() {
if (!this.isAuthenticated) {
return;
}
if (Date.now() < this.rateLimitedUntil) {
console.log(`Waiting another ${this.rateLimitedUntil - Date.now()}ms before syncing due to rate limiting.`);
return;
}
const encoding = await import('lib0/encoding');
const decoding = await import('lib0/decoding');
const syncProtocol = await import('y-protocols/sync');
const sinceLastFullSync = Date.now() - this.lastFullSyncAt;
const pendingYUpdate = this.pendingYUpdate;
if (pendingYUpdate && pendingYUpdate.length > 4) {
this.pendingYUpdate = null;
const encoder = encoding.createEncoder();
syncProtocol.writeUpdate(encoder, pendingYUpdate);
const response = await fetch(endpoint + '/y-sync', {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream'
},
body: encoding.toUint8Array(encoder),
});
if (response.status === 429) {
this.rateLimitedUntil = getRateLimitResetTimeFromResponse(response);
}
} else if (sinceLastFullSync > 1000 * 60 * 1) {
this.lastFullSyncAt = Date.now();
const encoder = encoding.createEncoder();
syncProtocol.writeSyncStep1(encoder, this.context.doc.root);
const queue: Uint8Array[] = [
encoding.toUint8Array(encoder),
];
for (let i = 0; i < 4; i++) {
if (!queue.length) {
break;
}
const buffer = queue.shift()!;
const response = await fetch(endpoint + '/y-sync', {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream'
},
body: buffer,
});
if (!response.ok) {
this.rateLimitedUntil = getRateLimitResetTimeFromResponse(response);
throw new Error(response.statusText);
}
const responseBuffer = await response.arrayBuffer();
const responseChunks = decode(responseBuffer) as Uint8Array[];
for (const chunk of responseChunks) {
if (!chunk.byteLength) {
continue;
}
const encoder = encoding.createEncoder();
const decoder = decoding.createDecoder(chunk);
const messageType = decoding.readVarUint(decoder);
decoder.pos = 0;
syncProtocol.readSyncMessage(decoder, encoder, this.context.doc.root, 'sync');
if (encoding.length(encoder)) {
queue.push(encoding.toUint8Array(encoder));
}
}
}
this.context.emit('update');
}
if (!this.legacySync) {
this.legacySync = true;
const chats = await this.get(endpoint + '/legacy-sync');
this.context.doc.transact(() => {
for (const chat of chats) {
try {
importChat(this.context.doc, chat);
} catch (e) {
console.error(e);
}
}
});
}
}
public receiveYUpdate(update: Uint8Array) {
if (!this.pendingYUpdate) {
this.pendingYUpdate = update;
} else {
this.pendingYUpdate = Y.mergeUpdates([this.pendingYUpdate, update]);
}
}
async signIn() {
window.location.href = endpoint + '/login';
}
get isAuthenticated() {
return this.user !== null;
}
async logout() {
window.location.href = endpoint + '/logout';
}
async shareChat(chat: Chat): Promise<string | null> {
try {
const { id } = await this.post(endpoint + '/share', {
...chat,
messages: chat.messages.serialize(),
});
if (typeof id === 'string') {
return id;
}
} catch (e) {
console.error(e);
}
return null;
}
async getSharedChat(id: string): Promise<Chat | null> {
const format = process.env.REACT_APP_SHARE_URL || (endpoint + '/share/:id');
const url = format.replace(':id', id);
try {
const chat = await this.get(url);
if (chat?.messages?.length) {
chat.messages = new MessageTree(chat.messages);
return chat;
}
} catch (e) {
console.error(e);
}
return null;
}
async deleteChat(id: string) {
if (!this.isAuthenticated) {
return;
}
return this.post(endpoint + '/delete', { id });
}
async get(url: string) {
const response = await fetch(url);
if (response.status === 429) {
this.rateLimitedUntil = getRateLimitResetTimeFromResponse(response);
}
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
}
async post(url: string, data: any) {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (response.status === 429) {
this.rateLimitedUntil = getRateLimitResetTimeFromResponse(response);
}
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
}
}

View File

@ -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);
}
}
}

View File

@ -0,0 +1,185 @@
import EventEmitter from "events";
import { createChatCompletion, createStreamingChatCompletion } from "./openai";
import { PluginContext } from "../plugins/plugin-context";
import { pluginRunner } from "../plugins/plugin-runner";
import { Chat, Message, OpenAIMessage, Parameters, getOpenAIMessageFromMessage } from "./types";
import { EventEmitterAsyncIterator } from "../utils/event-emitter-async-iterator";
import { YChat } from "./y-chat";
import { OptionsManager } from "../options";
export class ReplyRequest extends EventEmitter {
private mutatedMessages: OpenAIMessage[];
private mutatedParameters: Parameters;
private lastChunkReceivedAt: number = 0;
private timer: any;
private done: boolean = false;
private content = '';
private cancelSSE: any;
constructor(private chat: Chat,
private yChat: YChat,
private messages: Message[],
private replyID: string,
private requestedParameters: Parameters,
private pluginOptions: OptionsManager) {
super();
this.mutatedMessages = [...messages];
this.mutatedMessages = messages.map(m => getOpenAIMessageFromMessage(m));
this.mutatedParameters = { ...requestedParameters };
delete this.mutatedParameters.apiKey;
}
pluginContext = (pluginID: string) => ({
getOptions: () => {
return this.pluginOptions.getAllOptions(pluginID, this.chat.id);
},
getCurrentChat: () => {
return this.chat;
},
createChatCompletion: async (messages: OpenAIMessage[], _parameters: Parameters) => {
return await createChatCompletion(messages, {
..._parameters,
apiKey: this.requestedParameters.apiKey,
});
},
setChatTitle: async (title: string) => {
this.yChat.title = title;
},
} as PluginContext);
private scheduleTimeout() {
this.lastChunkReceivedAt = Date.now();
clearInterval(this.timer);
this.timer = setInterval(() => {
const sinceLastChunk = Date.now() - this.lastChunkReceivedAt;
if (sinceLastChunk > 30000 && !this.done) {
this.onError('no response from OpenAI in the last 30 seconds');
}
}, 2000);
}
public async execute() {
try {
this.scheduleTimeout();
await pluginRunner("preprocess-model-input", this.pluginContext, async plugin => {
const output = await plugin.preprocessModelInput(this.mutatedMessages, this.mutatedParameters);
this.mutatedMessages = output.messages;
this.mutatedParameters = output.parameters;
this.lastChunkReceivedAt = Date.now();
});
const { emitter, cancel } = await createStreamingChatCompletion(this.mutatedMessages, {
...this.mutatedParameters,
apiKey: this.requestedParameters.apiKey,
});
this.cancelSSE = cancel;
const eventIterator = new EventEmitterAsyncIterator<string>(emitter, ["data", "done", "error"]);
for await (const event of eventIterator) {
const { eventName, value } = event;
switch (eventName) {
case 'data':
await this.onData(value);
break;
case 'done':
await this.onDone();
break;
case 'error':
if (!this.content || !this.done) {
await this.onError(value);
}
break;
}
}
} catch (e: any) {
console.error(e);
this.onError(e.message);
}
}
public async onData(value: any) {
if (this.done) {
return;
}
this.lastChunkReceivedAt = Date.now();
this.content = value;
await pluginRunner("postprocess-model-output", this.pluginContext, async plugin => {
const output = await plugin.postprocessModelOutput({
role: 'assistant',
content: this.content,
}, this.mutatedMessages, this.mutatedParameters, false);
this.content = output.content;
});
this.yChat.setPendingMessageContent(this.replyID, this.content);
}
public async onDone() {
if (this.done) {
return;
}
clearInterval(this.timer);
this.lastChunkReceivedAt = Date.now();
this.done = true;
this.emit('done');
this.yChat.onMessageDone(this.replyID);
await pluginRunner("postprocess-model-output", this.pluginContext, async plugin => {
const output = await plugin.postprocessModelOutput({
role: 'assistant',
content: this.content,
}, this.mutatedMessages, this.mutatedParameters, true);
this.content = output.content;
});
this.yChat.setMessageContent(this.replyID, this.content);
}
public async onError(error: string) {
if (this.done) {
return;
}
this.done = true;
this.emit('done');
clearInterval(this.timer);
this.cancelSSE?.();
this.content += `\n\nI'm sorry, I'm having trouble connecting to OpenAI (${error || 'no response from the API'}). Please make sure you've entered your OpenAI API key correctly and try again.`;
this.content = this.content.trim();
this.yChat.setMessageContent(this.replyID, this.content);
this.yChat.onMessageDone(this.replyID);
}
public onCancel() {
clearInterval(this.timer);
this.done = true;
this.yChat.onMessageDone(this.replyID);
this.cancelSSE?.();
this.emit('done');
}
// private setMessageContent(content: string) {
// const text = this.yChat.content.get(this.replyID);
// if (text && text.toString() !== content) {
// text?.delete(0, text.length);
// text?.insert(0, content);
// }
// }
}

View File

@ -0,0 +1,197 @@
import { Message } from "./types";
/**
* MessageNode interface that extends the Message type and includes parent and replies properties.
* This allows creating a tree structure from messages.
*/
export interface MessageNode extends Message {
parent: MessageNode | null;
replies: Set<MessageNode>;
}
/**
* Function to create a new MessageNode from a given message.
*
* @param {Message} message - The message to be converted to a MessageNode.
* @returns {MessageNode} - The newly created MessageNode.
*/
export function createMessageNode(message: Message): MessageNode {
return {
...message,
parent: null,
replies: new Set(),
};
}
/**
* MessageTree class for representing and managing a tree structure of messages.
* The tree is made up of MessageNode objects, which extend the `Message` type and can have parent and replies relationships.
* The purpose of the tree structure is to represent a hierarchy of messages, where one message might have multiple
* replies, and each reply has a parent message that it is replying to.
*/
export class MessageTree {
public messageNodes: Map<string, MessageNode> = new Map(); // TODO make private
constructor(messages: (Message | MessageNode)[] = []) {
this.addMessages(messages);
}
/**
* Getter method for retrieving root messages (messages without a parent) in the tree.
* @returns {MessageNode[]} - An array of root messages.
*/
public get roots(): MessageNode[] {
return Array.from(this.messageNodes.values())
.filter((messageNode) => messageNode.parent === null);
}
/**
* Getter method for retrieving leaf messages (messages without any replies) in the tree.
* @returns {MessageNode[]} - An array of leaf messages.
*/
public get leafs(): MessageNode[] {
return Array.from(this.messageNodes.values())
.filter((messageNode) => messageNode.replies.size === 0);
}
/**
* Getter method for retrieving the first message in the most recent message chain.
* @returns {MessageNode | null} - The first message in the most recent message chain, or null if the tree is empty.
*/
public get first(): MessageNode | null {
const leaf = this.mostRecentLeaf();
let first: MessageNode | null = leaf;
while (first?.parent) {
first = first.parent;
}
return first;
}
/**
* Method to get a message node from the tree by its ID.
* @param {string} id - The ID of the message node to retrieve.
* @returns {MessageNode | null} - The message node with the given ID, or null if it does not exist in the tree.
*/
public get(id: string): MessageNode | null {
return this.messageNodes.get(id) || null;
}
/**
* Method to add a message to the tree. If a message with the same ID already exists in the tree, this method does nothing.
* @param {Message} message - The message to add to the tree.
*/
public addMessage(inputMessage: Message, content: string | undefined = '', done: boolean | undefined = false): void {
const message = {
...inputMessage,
content: content || inputMessage.content || '',
done: typeof done === 'boolean' ? done : inputMessage.done,
};
if (this.messageNodes.get(message.id)?.content) {
return;
}
const messageNode = createMessageNode(message);
this.messageNodes.set(messageNode.id, messageNode);
if (messageNode.parentID) {
let parent = this.messageNodes.get(messageNode.parentID);
if (!parent) {
parent = createMessageNode({
id: messageNode.parentID,
} as Message);
this.messageNodes.set(parent.id, parent);
}
parent.replies.add(messageNode);
messageNode.parent = parent;
}
for (const other of Array.from(this.messageNodes.values())) {
if (other.parentID === messageNode.id) {
messageNode.replies.add(other);
other.parent = messageNode;
}
}
}
/**
* Method to add multiple messages to the tree.
* @param {Message[]} messages - An array of messages to add to the tree.
*/
public addMessages(messages: Message[]): void {
for (const message of messages) {
try {
this.addMessage(message);
} catch (e) {
console.error(`Error adding message with id: ${message.id}`, e);
}
}
}
/**
* Method to update the content, timestamp, and done status of an existing message in the tree.
* @param {Message} message - The updated message.
*/
public updateMessage(message: Message): void {
const messageNode = this.messageNodes.get(message.id);
if (!messageNode) {
return;
}
messageNode.content = message.content;
messageNode.timestamp = message.timestamp;
messageNode.done = message.done;
}
/**
* Method to get the message chain leading to a specific message by its ID.
* @param {string} messageID - The ID of the target message.
* @returns {MessageNode[]} - An array of message nodes in the chain leading to the target message.
*/
public getMessageChainTo(messageID: string): MessageNode[] {
const message = this.messageNodes.get(messageID);
if (!message) {
return [];
}
const chain = [message];
let current = message;
while (current.parent) {
chain.unshift(current.parent);
current = current.parent;
}
return chain;
}
/**
* Method to serialize the message tree into an array of message nodes, excluding parent and replies properties.
* @returns {Omit<MessageNode, 'parent' | 'replies'>[]} - An array of serialized message nodes.
*/
public serialize(): Omit<MessageNode, 'parent' | 'replies'>[] {
return Array.from(this.messageNodes.values())
.map((messageNode) => {
const n: any = { ...messageNode };
delete n.parent;
delete n.replies;
return n;
});
}
/**
* Method to get the most recent leaf message in the message tree.
* @returns {MessageNode | null} - The most recent leaf message, or null if the tree is empty.
*/
public mostRecentLeaf(): MessageNode | null {
return this.leafs.sort((a, b) => b.timestamp - a.timestamp)[0] || null;
}
}

View File

@ -1,16 +1,23 @@
import EventEmitter from "events"; import EventEmitter from "events";
import { Configuration, OpenAIApi } from "openai"; import { Configuration, OpenAIApi } from "openai";
import SSE from "./sse"; import SSE from "../utils/sse";
import { OpenAIMessage, Parameters } from "./types"; import { OpenAIMessage, Parameters } from "./types";
import { backend } from "../backend";
export const defaultSystemPrompt = `
You are ChatGPT, a large language model trained by OpenAI.
Knowledge cutoff: 2021-09
Current date and time: {{ datetime }}
`.trim();
export const defaultModel = 'gpt-3.5-turbo'; 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 { export interface OpenAIResponseChunk {
id?: string; id?: string;
done: boolean; done: boolean;
@ -44,84 +51,57 @@ function parseResponseChunk(buffer: any): OpenAIResponseChunk {
} }
export async function createChatCompletion(messages: OpenAIMessage[], parameters: Parameters): Promise<string> { export async function createChatCompletion(messages: OpenAIMessage[], parameters: Parameters): Promise<string> {
if (!parameters.apiKey) { const proxied = shouldUseProxy(parameters.apiKey);
const endpoint = getEndpoint(proxied);
if (!proxied && !parameters.apiKey) {
throw new Error('No API key provided'); throw new Error('No API key provided');
} }
const configuration = new Configuration({ const response = await fetch(endpoint + '/v1/chat/completions', {
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', {
method: "POST", method: "POST",
headers: { headers: {
'Accept': 'application/json, text/plain, */*', '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', 'Content-Type': 'application/json',
}, },
payload: JSON.stringify({ payload: JSON.stringify({
"model": parameters.model, "model": parameters.model,
"messages": messagesToSend, "messages": messages,
"temperature": parameters.temperature, "temperature": parameters.temperature,
"stream": true, "stream": true,
}), }),
}) as SSE; }) 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 = ''; let contents = '';
eventSource.addEventListener('error', (event: any) => { eventSource.addEventListener('error', (event: any) => {
@ -135,7 +115,6 @@ export async function createStreamingChatCompletion(messages: OpenAIMessage[], p
}); });
eventSource.addEventListener('message', async (event: any) => { eventSource.addEventListener('message', async (event: any) => {
if (event.data === '[DONE]') { if (event.data === '[DONE]') {
emitter.emit('done'); emitter.emit('done');
return; return;
@ -160,14 +139,7 @@ export async function createStreamingChatCompletion(messages: OpenAIMessage[], p
}; };
} }
async function selectMessagesToSendSafely(messages: OpenAIMessage[], maxTokens: number) { export const maxTokensByModel = {
const { ChatHistoryTrimmer } = await import(/* webpackPreload: true */ './tokenizer/chat-history-trimmer'); "chatgpt-3.5-turbo": 2048,
const compressor = new ChatHistoryTrimmer(messages, { "gpt-4": 8096,
maxTokens, }
preserveFirstUserMessage: true,
preserveSystemPrompt: true,
});
return compressor.process();
}
setTimeout(() => selectMessagesToSendSafely([], 2048), 2000);

View File

@ -41,6 +41,8 @@ export function getOpenAIMessageFromMessage(message: Message): OpenAIMessage {
export interface Chat { export interface Chat {
id: string; id: string;
messages: MessageTree; messages: MessageTree;
metadata?: Record<string, any>;
pluginOptions?: Record<string, any>;
title?: string | null; title?: string | null;
created: number; created: number;
updated: number; updated: number;

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { backend } from "./backend"; import { backend } from "../backend";
import { ChatManager } from "./chat-manager"; import { ChatManager } from "..";
import { Chat, Message } from './types'; import { Chat, Message } from './types';
export interface UseChatResult { export interface UseChatResult {

View File

@ -0,0 +1,307 @@
import * as Y from 'yjs';
import { Chat, Message } from './types';
import EventEmitter from 'events';
import { v4 as uuidv4 } from 'uuid';
import { MessageTree } from './message-tree';
const METADATA_KEY = 'metadata';
const IMPORTED_METADATA_KEY = 'imported-metadata';
const PLUGIN_OPTIONS_KEY = 'plugin-options';
const MESSAGES_KEY = 'messages';
const CONTENT_KEY = 'messages:content';
const DONE_KEY = 'messages:done';
export class YChat {
private callback: any;
private pendingContent = new Map<string, string>();
private prefix = 'chat.' + this.id + '.';
public static from(root: Y.Doc, id: string) {
// const id = data.get('metadata').get('id') as string;
return new YChat(id, root);
}
constructor(public readonly id: string, public root: Y.Doc) {
this.purgeDeletedValues();
}
public observeDeep(callback: any) {
this.callback = callback;
this.metadata?.observeDeep(callback);
this.pluginOptions?.observeDeep(callback);
this.messages?.observeDeep(callback);
this.content?.observeDeep(callback);
this.done?.observeDeep(callback);
}
public get deleted(): boolean {
return this.metadata.get('deleted') || false;
}
public get metadata(): Y.Map<any> {
return this.root.getMap<any>(this.prefix + METADATA_KEY);
}
public get importedMetadata(): Y.Map<any> {
return this.root.getMap<any>(this.prefix + IMPORTED_METADATA_KEY);
}
public get pluginOptions(): Y.Map<any> {
return this.root.getMap<any>(this.prefix + PLUGIN_OPTIONS_KEY);
}
public get messages(): Y.Map<Message> {
return this.root.getMap<Message>(this.prefix + MESSAGES_KEY);
}
public get content(): Y.Map<Y.Text> {
return this.root.getMap<Y.Text>(this.prefix + CONTENT_KEY);
}
public get done(): Y.Map<boolean> {
return this.root.getMap<boolean>(this.prefix + DONE_KEY);
}
public get title() {
return (this.metadata.get('title') as string) || (this.importedMetadata.get('title') as string) || null;
}
public set title(value: string | null) {
if (value) {
this.metadata.set('title', value);
}
}
public setPendingMessageContent(messageID: string, value: string) {
this.pendingContent.set(messageID, value);
this.callback?.();
}
public setMessageContent(messageID: string, value: string) {
this.pendingContent.delete(messageID);
this.content.set(messageID, new Y.Text(value));
}
public getMessageContent(messageID: string) {
return this.pendingContent.get(messageID) || this.content.get(messageID)?.toString() || "";
}
public onMessageDone(messageID: string) {
this.done.set(messageID, true);
}
public getOption(pluginID: string, optionID: string): any {
const key = pluginID + "." + optionID;
return this.pluginOptions?.get(key) || null;
}
public setOption(pluginID: string, optionID: string, value: any) {
const key = pluginID + "." + optionID;
return this.pluginOptions.set(key, value);
}
public hasOption(pluginID: string, optionID: string) {
const key = pluginID + "." + optionID;
return this.pluginOptions.has(key);
}
public delete() {
if (!this.deleted) {
this.metadata.clear();
this.pluginOptions.clear();
this.messages.clear();
this.content.clear();
this.done.clear();
} else {
this.purgeDeletedValues();
}
}
private purgeDeletedValues() {
if (this.deleted) {
if (this.metadata.size > 1) {
for (const key of Array.from(this.metadata.keys())) {
if (key !== 'deleted') {
this.metadata.delete(key);
}
}
}
if (this.pluginOptions.size > 0) {
this.pluginOptions.clear();
}
if (this.messages.size > 0) {
this.messages.clear();
}
if (this.content.size > 0) {
this.content.clear();
}
if (this.done.size > 0) {
this.done.clear();
}
}
}
}
export class YChatDoc extends EventEmitter {
public root = new Y.Doc();
// public chats = this.root.getMap<Y.Map<any>>('chats');
// public deletedChatIDs = this.root.getArray<string>('deletedChatIDs');
public deletedChatIDsSet = new Set<string>();
public options = this.root.getMap<Y.Map<any>>('options');
private yChats = new Map<string, YChat>();
private observed = new Set<string>();
constructor() {
super();
this.root.whenLoaded.then(() => {
const chatIDs = Array.from(this.root.getMap('chats').keys());
for (const id of chatIDs) {
this.observeChat(id);
}
});
}
private observeChat(id: string, yChat = this.getYChat(id)) {
if (!this.observed.has(id)) {
yChat?.observeDeep(() => this.emit('update', id));
this.observed.add(id);
}
}
// public set(id: string, chat: YChat) {
// this.chats.set(id, chat.data);
// if (!this.observed.has(id)) {
// this.getYChat(id)?.observeDeep(() => this.emit('update', id));
// this.observed.add(id);
// }
// }
public get chatIDMap() {
return this.root.getMap('chatIDs');
}
public getYChat(id: string, expectContent = false) {
let yChat = this.yChats.get(id);
if (!yChat) {
yChat = YChat.from(this.root, id);
this.yChats.set(id, yChat);
}
if (expectContent && !this.chatIDMap.has(id)) {
this.chatIDMap.set(id, true);
}
this.observeChat(id, yChat);
return yChat;
}
public delete(id: string) {
this.getYChat(id)?.delete();
}
public has(id: string) {
return this.chatIDMap.has(id) && !YChat.from(this.root, id).deleted;
}
public getChatIDs() {
return Array.from(this.chatIDMap.keys());
}
public getAllYChats() {
return this.getChatIDs().map(id => this.getYChat(id)!);
}
public transact(cb: () => void) {
return this.root.transact(cb);
}
public addMessage(message: Message) {
const chat = this.getYChat(message.chatID, true);
if (!chat) {
throw new Error('Chat not found');
}
this.transact(() => {
chat.messages.set(message.id, {
...message,
content: '',
});
chat.content.set(message.id, new Y.Text(message.content || ''));
if (message.done) {
chat.done.set(message.id, message.done);
}
});
}
public createYChat(id = uuidv4()) {
// return new YChat(id, this.root);
// this.set(id, chat);
return id;
}
public getMessageTree(chatID: string): MessageTree {
const tree = new MessageTree();
const chat = this.getYChat(chatID);
chat?.messages?.forEach(m => {
try {
const content = chat.getMessageContent(m.id);
const done = chat.done.get(m.id) || false;
tree.addMessage(m, content, done);
} catch (e) {
console.warn(`failed to load message ${m.id}`, e);
}
});
return tree;
}
public getMessagesPrecedingMessage(chatID: string, messageID: string) {
const tree = this.getMessageTree(chatID);
const message = tree.get(messageID);
if (!message) {
throw new Error("message not found: " + messageID);
}
const messages: Message[] = message.parentID
? tree.getMessageChainTo(message.parentID)
: [];
return messages;
}
public getChat(id: string): Chat {
const chat = this.getYChat(id);
const tree = this.getMessageTree(id);
return {
id,
messages: tree,
title: chat.title,
metadata: {
...chat.importedMetadata.toJSON(),
...chat.metadata.toJSON(),
},
pluginOptions: chat?.pluginOptions?.toJSON() || {},
deleted: !chat.deleted,
created: tree.first?.timestamp || 0,
updated: tree.mostRecentLeaf()?.timestamp || 0,
}
}
public getOption(pluginID: string, optionID: string): any {
const key = pluginID + "." + optionID;
return this.options.get(key);
}
public setOption(pluginID: string, optionID: string, value: any) {
const key = pluginID + "." + optionID;
return this.options.set(key, value);
}
}

View File

@ -0,0 +1,256 @@
import React, { useState, useRef, useMemo, useEffect, useCallback } from "react";
import { v4 as uuidv4 } from 'uuid';
import { IntlShape, useIntl } from "react-intl";
import { Backend, User } from "./backend";
import { ChatManager } from "./";
import { useAppDispatch } from "../store";
import { openOpenAIApiKeyPanel } from "../store/settings-ui";
import { Message, Parameters } from "./chat/types";
import { useChat, UseChatResult } from "./chat/use-chat";
import { TTSContextProvider } from "./tts/use-tts";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { isProxySupported } from "./chat/openai";
import { audioContext, resetAudioContext } from "./tts/audio-file-player";
export interface Context {
authenticated: boolean;
sessionExpired: boolean;
chat: ChatManager;
user: User | null;
intl: IntlShape;
id: string | undefined | null;
currentChat: UseChatResult;
isHome: boolean;
isShare: boolean;
generating: boolean;
onNewMessage: (message?: string) => Promise<string | false>;
regenerateMessage: (message: Message) => Promise<boolean>;
editMessage: (message: Message, content: string) => Promise<boolean>;
}
const AppContext = React.createContext<Context>({} as any);
const chatManager = new ChatManager();
const backend = new Backend(chatManager);
let intl: IntlShape;
export function useCreateAppContext(): Context {
const { id: _id } = useParams();
const [nextID, setNextID] = useState(uuidv4());
const id = _id ?? nextID;
const dispatch = useAppDispatch();
intl = useIntl();
const { pathname } = useLocation();
const isHome = pathname === '/';
const isShare = pathname.startsWith('/s/');
const currentChat = useChat(chatManager, id, isShare);
const [authenticated, setAuthenticated] = useState(backend?.isAuthenticated || false);
const [wasAuthenticated, setWasAuthenticated] = useState(backend?.isAuthenticated || false);
useEffect(() => {
chatManager.on('y-update', update => backend?.receiveYUpdate(update))
}, []);
const updateAuth = useCallback((authenticated: boolean) => {
setAuthenticated(authenticated);
if (authenticated && backend.user) {
chatManager.login(backend.user.email || backend.user.id);
}
if (authenticated) {
setWasAuthenticated(true);
localStorage.setItem('registered', 'true');
}
}, []);
useEffect(() => {
updateAuth(backend?.isAuthenticated || false);
backend?.on('authenticated', updateAuth);
return () => {
backend?.off('authenticated', updateAuth)
};
}, [updateAuth]);
const onNewMessage = useCallback(async (message?: string) => {
resetAudioContext();
if (isShare) {
return false;
}
if (!message?.trim().length) {
return false;
}
// const openaiApiKey = store.getState().apiKeys.openAIApiKey;
const openaiApiKey = chatManager.options.getOption<string>('openai', 'apiKey');
if (!openaiApiKey && !isProxySupported()) {
dispatch(openOpenAIApiKeyPanel());
return false;
}
const parameters: Parameters = {
model: chatManager.options.getOption<string>('parameters', 'model', id),
temperature: chatManager.options.getOption<number>('parameters', 'temperature', id),
};
if (id === nextID) {
setNextID(uuidv4());
const autoPlay = chatManager.options.getOption<boolean>('tts', 'autoplay');
if (autoPlay) {
const ttsService = chatManager.options.getOption<string>('tts', 'service');
if (ttsService === 'web-speech') {
const utterance = new SpeechSynthesisUtterance('Generating');
utterance.volume = 0;
speechSynthesis.speak(utterance);
}
}
}
// if (chatManager.has(id)) {
// chatManager.sendMessage({
// chatID: id,
// content: message.trim(),
// requestedParameters: {
// ...parameters,
// apiKey: openaiApiKey,
// },
// parentID: currentChat.leaf?.id,
// });
// } else {
// await chatManager.createChat(id);
chatManager.sendMessage({
chatID: id,
content: message.trim(),
requestedParameters: {
...parameters,
apiKey: openaiApiKey,
},
parentID: currentChat.leaf?.id,
});
// }
return id;
}, [dispatch, id, currentChat.leaf, isShare]);
const regenerateMessage = useCallback(async (message: Message) => {
resetAudioContext();
if (isShare) {
return false;
}
// const openaiApiKey = store.getState().apiKeys.openAIApiKey;
const openaiApiKey = chatManager.options.getOption<string>('openai', 'apiKey');
if (!openaiApiKey && !isProxySupported()) {
dispatch(openOpenAIApiKeyPanel());
return false;
}
const parameters: Parameters = {
model: chatManager.options.getOption<string>('parameters', 'model', id),
temperature: chatManager.options.getOption<number>('parameters', 'temperature', id),
};
await chatManager.regenerate(message, {
...parameters,
apiKey: openaiApiKey,
});
return true;
}, [dispatch, isShare]);
const editMessage = useCallback(async (message: Message, content: string) => {
resetAudioContext();
if (isShare) {
return false;
}
if (!content?.trim().length) {
return false;
}
// const openaiApiKey = store.getState().apiKeys.openAIApiKey;
const openaiApiKey = chatManager.options.getOption<string>('openai', 'apiKey');
if (!openaiApiKey && !isProxySupported()) {
dispatch(openOpenAIApiKeyPanel());
return false;
}
const parameters: Parameters = {
model: chatManager.options.getOption<string>('parameters', 'model', id),
temperature: chatManager.options.getOption<number>('parameters', 'temperature', id),
};
if (id && chatManager.has(id)) {
await chatManager.sendMessage({
chatID: id,
content: content.trim(),
requestedParameters: {
...parameters,
apiKey: openaiApiKey,
},
parentID: message.parentID,
});
} else {
const id = await chatManager.createChat();
await chatManager.sendMessage({
chatID: id,
content: content.trim(),
requestedParameters: {
...parameters,
apiKey: openaiApiKey,
},
parentID: message.parentID,
});
}
return true;
}, [dispatch, id, isShare]);
const generating = currentChat?.messagesToDisplay?.length > 0
? !currentChat.messagesToDisplay[currentChat.messagesToDisplay.length - 1].done
: false;
const context = useMemo<Context>(() => ({
authenticated,
sessionExpired: !authenticated && wasAuthenticated,
id,
user: backend.user,
intl,
chat: chatManager,
currentChat,
isHome,
isShare,
generating,
onNewMessage,
regenerateMessage,
editMessage,
}), [authenticated, wasAuthenticated, generating, onNewMessage, regenerateMessage, editMessage, currentChat, id, isHome, isShare, intl]);
return context;
}
export function useAppContext() {
return React.useContext(AppContext);
}
export function AppContextProvider(props: { children: React.ReactNode }) {
const context = useCreateAppContext();
return <AppContext.Provider value={context}>
<TTSContextProvider>
{props.children}
</TTSContextProvider>
</AppContext.Provider>;
}

View File

@ -0,0 +1,254 @@
import { BroadcastChannel } from 'broadcast-channel';
import EventEmitter from 'events';
import { v4 as uuidv4 } from 'uuid';
import { Chat, Message, Parameters, UserSubmittedMessage } from './chat/types';
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';
import { YChatDoc } from './chat/y-chat';
import { loadFromPreviousVersion as loadSavedChatsFromPreviousVersion } from './chat/chat-persistance';
import { Search } from './search';
import { ReplyRequest } from './chat/create-reply';
import { OptionsManager } from './options';
import { Option } from './options/option';
import { pluginMetadata } from './plugins/metadata';
import { pluginRunner } from "./plugins/plugin-runner";
import { createBasicPluginContext } from './plugins/plugin-context';
export const channel = new BroadcastChannel('chats');
export class ChatManager extends EventEmitter {
public doc!: YChatDoc;
private provider!: IndexeddbPersistence;
private search!: Search;
public options!: OptionsManager;
private username: string | null = "anonymous";
private activeReplies = new Map<string, ReplyRequest>();
private changedIDs = new Set<string>();
public lastReplyID: string | null = null;
constructor() {
super();
this.setMaxListeners(1000);
console.log('initializing chat manager');
this.doc = this.attachYDoc('anonymous');
loadSavedChatsFromPreviousVersion(this.doc)
.then(() => this.emit('update'));
setInterval(() => this.emitChanges());
channel.onmessage = message => {
if (message.type === 'y-update') {
this.applyYUpdate(message.data);
}
};
(window as any).chat = this;
}
public login(username: string) {
if (username && this.username !== username) {
this.username = username;
this.attachYDoc(username);
}
}
private attachYDoc(username: string) {
console.log('attaching y-doc for ' + username);
// detach current doc
const doc = this.doc as YChatDoc | undefined;
const provider = this.provider as IndexeddbPersistence | undefined;
doc?.removeAllListeners();
const pluginOptionsManager = this.options as OptionsManager | undefined;
pluginOptionsManager?.destroy();
// attach new doc
this.doc = new YChatDoc();
this.doc.on('update', chatID => this.changedIDs.add(chatID));
this.doc.root.on('update', (update, origin) => {
if (!(origin instanceof IndexeddbPersistence) && origin !== 'sync') {
this.emit('y-update', update);
channel.postMessage({ type: 'y-update', data: update });
} else {
console.log("IDB/sync update");
}
});
this.search = new Search(this);
// connect new doc to persistance, scoped to the current username
this.provider = new IndexeddbPersistence('chats:' + username, this.doc.root);
this.provider.whenSynced.then(() => {
this.doc.getChatIDs().map(id => this.emit(id));
this.emit('update');
});
this.options = new OptionsManager(this.doc, pluginMetadata);
this.options.on('update', (...args) => this.emit('plugin-options-update', ...args));
pluginRunner(
'init',
pluginID => createBasicPluginContext(pluginID, this.options),
plugin => plugin.initialize(),
);
if (username !== 'anonymous') {
// import chats from the anonymous doc after signing in
provider?.whenSynced.then(() => {
if (doc) {
Y.applyUpdate(this.doc.root, Y.encodeStateAsUpdate(doc.root));
setTimeout(() => provider.clearData(), 10 * 1000);
}
});
}
return this.doc;
}
public applyYUpdate(update: Uint8Array) {
Y.applyUpdate(this.doc.root, update);
}
private emitChanges() {
const ids = Array.from(this.changedIDs);
this.changedIDs.clear();
for (const id of ids) {
this.emit(id);
this.search.update(id);
}
if (ids.length) {
this.emit('update');
}
}
public async sendMessage(userSubmittedMessage: UserSubmittedMessage) {
const chat = this.doc.getYChat(userSubmittedMessage.chatID);
if (!chat) {
throw new Error('Chat not found');
}
const message: Message = {
id: uuidv4(),
parentID: userSubmittedMessage.parentID,
chatID: userSubmittedMessage.chatID,
timestamp: Date.now(),
role: 'user',
content: userSubmittedMessage.content,
done: true,
};
this.doc.addMessage(message);
const messages: Message[] = this.doc.getMessagesPrecedingMessage(message.chatID, message.id);
messages.push(message);
await this.getReply(messages, userSubmittedMessage.requestedParameters);
}
public async regenerate(message: Message, requestedParameters: Parameters) {
const messages = this.doc.getMessagesPrecedingMessage(message.chatID, message.id);
await this.getReply(messages, requestedParameters);
}
private async getReply(messages: Message[], requestedParameters: Parameters) {
const latestMessage = messages[messages.length - 1];
const chatID = latestMessage.chatID;
const parentID = latestMessage.id;
const chat = this.doc.getYChat(latestMessage.chatID);
if (!chat) {
throw new Error('Chat not found');
}
const message: Message = {
id: uuidv4(),
parentID,
chatID,
timestamp: Date.now(),
role: 'assistant',
model: requestedParameters.model,
content: '',
};
this.lastReplyID = message.id;
this.doc.addMessage(message);
const request = new ReplyRequest(this.get(chatID), chat, messages, message.id, requestedParameters, this.options);
request.on('done', () => this.activeReplies.delete(message.id));
request.execute();
this.activeReplies.set(message.id, request);
}
public cancelReply(chatID: string | undefined, id: string) {
this.activeReplies.get(id)?.onCancel();
this.activeReplies.delete(id);
}
public async createChat(id?: string): Promise<string> {
return this.doc.createYChat(id);
}
public get(id: string): Chat {
return this.doc.getChat(id);
}
public has(id: string) {
return this.doc.has(id);
}
public all(): Chat[] {
return this.doc.getChatIDs().map(id => this.get(id));
}
public deleteChat(id: string, broadcast = true) {
this.doc.delete(id);
this.search.delete(id);
}
public searchChats(query: string) {
return this.search.query(query);
}
public getPluginOptions(chatID?: string) {
const pluginOptions: Record<string, Record<string, any>> = {};
for (const description of pluginMetadata) {
pluginOptions[description.id] = this.options.getAllOptions(description.id, chatID);
}
return pluginOptions;
}
public setPluginOption(pluginID: string, optionID: string, value: any, chatID?: string) {
this.options.setOption(pluginID, optionID, value, chatID);
}
public resetPluginOptions(pluginID: string, chatID?: string | null) {
this.options.resetOptions(pluginID, chatID);
}
public getQuickSettings(): Array<{ groupID: string, option: Option }> {
const options = this.options.getAllOptions('quick-settings');
return Object.keys(options)
.filter(key => options[key])
.map(key => {
const groupID = key.split('--')[0];
const optionID = key.split('--')[1];
return {
groupID,
option: this.options.findOption(groupID, optionID)!,
};
})
.filter(o => !!o.option);
}
}

View File

@ -0,0 +1,209 @@
import { EventEmitter } from "events";
import { PluginDescription } from "../plugins/plugin-description";
import { Option } from "./option";
import { YChat, YChatDoc } from "../chat/y-chat";
import { globalOptions } from "../../global-options";
import { OptionGroup } from "./option-group";
import { BroadcastChannel } from "broadcast-channel";
export const broadcastChannel = new BroadcastChannel("options");
function cacheKey(groupID: string, optionID: string, chatID?: string | null) {
return chatID ? `${chatID}.${groupID}.${optionID}` : `${groupID}.${optionID}`;
}
export class OptionsManager extends EventEmitter {
private optionGroups: OptionGroup[];
private optionsCache: Map<string, any> = new Map();
constructor(private yDoc: YChatDoc, private pluginMetadata: PluginDescription[]) {
super();
this.optionGroups = [...globalOptions, ...this.pluginMetadata];
// Load options from localStorage and YChats
this.loadOptions();
// Listen for update events on the broadcast channel
broadcastChannel.onmessage = (event: MessageEvent) => {
this.loadOptions();
if (event.data?.groupID) {
this.emit('update', event.data.groupID);
}
};
}
private loadOption(groupID: string, option: Option, yChat?: YChat) {
if (option.scope === "chat") {
const key: string = cacheKey(groupID, option.id, yChat?.id);
let value: string | undefined | null;
if (yChat) {
value = yChat.getOption(groupID, option.id);
}
// Fallback to localStorage if value is not found in YChat
if (typeof value === 'undefined' || value === null) {
const fallbackKey = cacheKey(groupID, option.id);
const raw = localStorage.getItem(fallbackKey);
value = raw ? JSON.parse(raw) : option.defaultValue;
}
this.optionsCache.set(key, value);
} else if (option.scope === "user") {
const key = cacheKey(groupID, option.id);
const value = this.yDoc.getOption(groupID, option.id) || option.defaultValue;
this.optionsCache.set(key, value);
} else {
const key = cacheKey(groupID, option.id);
const raw = localStorage.getItem(key);
const value = raw ? JSON.parse(raw) : option.defaultValue;
this.optionsCache.set(key, value);
}
}
private loadOptions() {
// Load browser and user-scoped options
this.optionGroups.forEach(group => {
group.options.forEach(option => {
this.loadOption(group.id, option);
});
});
// Load chat-scoped options from YChats
this.yDoc.getChatIDs().forEach(chatID => {
const yChat = this.yDoc.getYChat(chatID)!;
this.optionGroups.forEach(group => {
group.options.forEach(option => {
if (option.scope === "chat") {
this.loadOption(group.id, option, yChat);
}
});
});
});
(window as any).options = this;
this.emit("update");
}
public resetOptions(groupID: string, chatID?: string | null) {
console.log(`resetting ${groupID} options with chatID = ${chatID}`);
const group = this.optionGroups.find(group => group.id === groupID);
group?.options.forEach(option => {
if (option.resettable) {
this.setOption(group.id, option.id, option.defaultValue, option.scope === 'chat' ? chatID : null);
}
});
}
public getAllOptions(groupID: string, chatID?: string | null): Record<string, any> {
const options: Record<string, any> = {};
const group = this.optionGroups.find(group => group.id === groupID);
group?.options.forEach(option => {
options[option.id] = this.getOption(groupID, option.id, chatID);
});
return options;
}
public getOption<T=any>(groupID: string, optionID: string, chatID?: string | null, validate = false): T {
const option = this.findOption(groupID, optionID);
if (!option) {
throw new Error(`option not found (group = ${groupID}), option = ${optionID}`);
}
const key = cacheKey(groupID, optionID, option.scope === 'chat' ? chatID : null);
let value = this.optionsCache.get(key);
if (typeof value !== 'undefined' && value !== null) {
if (validate) {
const valid = !option.validate || option.validate(value, this);
if (valid) {
return value;
}
} else {
return value;
}
}
const fallbackKey = cacheKey(groupID, optionID);
value = this.optionsCache.get(fallbackKey);
if (typeof value !== 'undefined' && value !== null) {
if (validate) {
const valid = !option.validate || option.validate(value, this);
if (valid) {
return value;
}
} else {
return value;
}
}
return option.defaultValue;
}
public getValidatedOption(groupID: string, optionID: string, chatID?: string | null): any {
return this.getOption(groupID, optionID, chatID, true);
}
public setOption(groupID: string, optionID: string, value: any, chatID?: string | null) {
const option = this.findOption(groupID, optionID);
if (!option) {
console.warn(`option not found (group = ${groupID}), option = ${optionID}`);
return;
}
const key = cacheKey(groupID, optionID, option.scope === 'chat' ? chatID : null);
value = value ?? null;
if (option.scope === "chat") {
if (!chatID) {
console.warn(`cannot set option for chat without chatID (group = ${groupID}), option = ${optionID}, chatID = ${chatID}`);
return;
}
const yChat = this.yDoc.getYChat(chatID);
yChat?.setOption(groupID, optionID, value);
const fallbackKey = cacheKey(groupID, optionID);
localStorage.setItem(fallbackKey, JSON.stringify(value));
} else if (option.scope === 'user') {
this.yDoc.setOption(groupID, optionID, value);
} else {
localStorage.setItem(key, JSON.stringify(value));
}
console.log(`setting ${groupID}.${optionID} = ${value} (${typeof value})`)
// Update cache and emit update event
this.optionsCache.set(key, value);
this.emit("update", groupID, optionID);
// Notify other tabs through the broadcast channel
broadcastChannel.postMessage({ groupID, optionID });
}
public findOption(groupID: string, optionID: string): Option | undefined {
const group = this.optionGroups.find(group => group.id === groupID);
const option = group?.options.find(option => option.id === optionID);
if (option) {
return option;
}
console.warn("couldn't find option " + groupID + "." + optionID);
return undefined;
}
public destroy() {
this.removeAllListeners();
broadcastChannel.onmessage = null;
}
}

View File

@ -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[];
}

View File

@ -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);
};
}

View File

@ -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);

View File

@ -0,0 +1,45 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Context, useAppContext } from "../context";
import { RenderProps } from "./render-props";
export function useOption<T=any>(groupID: string, optionID: string, chatID?: string): [T, (value: T) => void, RenderProps, number] {
const context = useAppContext();
const [value, setValue] = useState(context.chat.options.getValidatedOption(groupID, optionID, chatID));
const [version, setVersion] = useState(0);
const timer = useRef<any>();
const onUpdate = useCallback((updatedGroupID: string) => {
if (groupID === updatedGroupID) {
setValue(context.chat.options.getValidatedOption(groupID, optionID, chatID));
setVersion(v => v + 1);
} else {
clearTimeout(timer.current);
timer.current = setTimeout(() => {
setValue(context.chat.options.getValidatedOption(groupID, optionID, chatID));
setVersion(v => v + 1);
}, 500);
}
}, [groupID, optionID, chatID]);
useEffect(() => {
context.chat.on('plugin-options-update', onUpdate);
return () => {
context.chat.off('plugin-options-update', onUpdate);
};
}, [chatID, onUpdate]);
const setOptionValue = useCallback((value: any) => {
context.chat.options.setOption(groupID, optionID, value, chatID);
}, [groupID, optionID, chatID]);
const option = context.chat.options.findOption(groupID, optionID)!;
return [
value,
setOptionValue,
typeof option.renderProps === 'function' ? option.renderProps(value, context.chat.options, context) : option.renderProps,
version,
];
}

View File

@ -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;
}

View File

@ -0,0 +1,30 @@
import { OpenAIMessage, Parameters } from "../chat/types";
import { PluginContext } from "./plugin-context";
import { PluginDescription } from "./plugin-description";
export default class Plugin<T=any> {
constructor(public context?: PluginContext) {
}
async initialize() {
}
describe(): PluginDescription {
throw new Error('not implemented');
}
get options(): T | undefined {
return this.context?.getOptions();
}
async preprocessModelInput(messages: OpenAIMessage[], parameters: Parameters): Promise<{
messages: OpenAIMessage[],
parameters: Parameters,
}> {
return { messages, parameters };
}
async postprocessModelOutput(message: OpenAIMessage, context: OpenAIMessage[], parameters: Parameters, done: boolean): Promise<OpenAIMessage> {
return message;
}
}

View File

@ -0,0 +1,15 @@
import type { PluginDescription } from "./plugin-description";
import TTSPlugin from "../tts/tts-plugin";
import { registeredPlugins } from "../../plugins";
export const pluginMetadata: Array<PluginDescription> = registeredPlugins.map(p => new p().describe());
export const pluginIDs: string[] = pluginMetadata.map(d => d.id);
export const ttsPlugins = registeredPlugins.filter(p => {
const instance = new p();
return instance instanceof TTSPlugin;
});
export function getPluginByName(name: string) {
return registeredPlugins.find(p => new p().describe().name === name);
}

View File

@ -0,0 +1,18 @@
import { Chat, OpenAIMessage, Parameters } from "../chat/types";
import { OptionsManager } from "../options";
export interface PluginContext {
getOptions(): any;
getCurrentChat(): Chat;
createChatCompletion(messages: OpenAIMessage[], parameters: Parameters): Promise<string>;
setChatTitle(title: string): Promise<void>;
}
export function createBasicPluginContext(pluginID: string, pluginOptions: OptionsManager, chatID?: string | null, chat?: Chat | null) {
return {
getOptions: (_pluginID = pluginID) => pluginOptions.getAllOptions(_pluginID, chatID),
getCurrentChat: () => chat,
createChatCompletion: async () => '',
setChatTitle: async (title: string) => { },
} as PluginContext;
}

View File

@ -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";
}

View File

@ -0,0 +1,24 @@
import type { PluginContext } from "./plugin-context";
import type Plugin from ".";
import { pluginMetadata } from "./metadata";
import { registeredPlugins } from "../../plugins";
export async function pluginRunner(name: string, pluginContext: (pluginID: string) => PluginContext, callback: (p: Plugin<any>) => Promise<any>) {
const startTime = Date.now();
for (let i = 0; i < registeredPlugins.length; i++) {
const description = pluginMetadata[i];
const impl = registeredPlugins[i];
const plugin = new impl(pluginContext(description.id));
try {
await callback(plugin);
} catch (e) {
console.warn(`[plugins:${name}] error in ` + description.name, e);
}
}
const runtime = Date.now() - startTime;
// console.log(`[plugins:${name}] ran all plugins in ${runtime.toFixed(1)} ms`);
}

View File

@ -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;
}
}

View File

@ -1,4 +1,4 @@
import { OpenAIMessage } from '../types'; import { OpenAIMessage } from '../chat/types';
import * as tokenizer from '.'; import * as tokenizer from '.';
export interface ChatHistoryTrimmerOptions { export interface ChatHistoryTrimmerOptions {

View File

@ -1,4 +1,4 @@
import { OpenAIMessage } from "../types"; import { OpenAIMessage } from "../chat/types";
import { CoreBPE, RankMap } from "./bpe"; import { CoreBPE, RankMap } from "./bpe";
import ranks from './cl100k_base.json'; 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 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); const tokenizer = new CoreBPE(RankMap.from(ranks), special_tokens_map, pattern);
(window as any).tokenizer = tokenizer;
const overheadTokens = { const overheadTokens = {
perMessage: 5, perMessage: 5,

View File

@ -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);
}

View File

@ -0,0 +1,28 @@
import { OpenAIMessage } from "../chat/types";
import type { ChatHistoryTrimmerOptions } from "./chat-history-trimmer";
// @ts-ignore
import tokenizer from 'workerize-loader!./worker';
let worker: any;
async function getWorker() {
if (!worker) {
worker = await tokenizer();
}
return worker;
}
export async function runChatTrimmer(messages: OpenAIMessage[], options: ChatHistoryTrimmerOptions): Promise<OpenAIMessage[]> {
const worker = await getWorker();
return worker.runChatTrimmer(messages, options);
}
export async function countTokens(messages: OpenAIMessage[]) {
const worker = await getWorker();
return await worker.countTokensForMessages(messages);
}
// preload the worker
getWorker().then(w => {
(window as any).worker = w;
})

View File

@ -0,0 +1,286 @@
import { AbstractTTSPlayer, TTSPlayerState } from './types';
import { cloneArrayBuffer, md5, sleep } from '../utils';
import { AsyncLoop } from "../utils/async-loop";
import * as idb from '../utils/idb';
import TTSPlugin from './tts-plugin';
export let audioContext = new AudioContext();
export let audioContextInUse = false;
export function resetAudioContext() {
if (audioContextInUse) {
const previousAudioContext = audioContext;
audioContext = new AudioContext();
audioContextInUse = false;
setTimeout(() => previousAudioContext.close(), 0);
setTimeout(() => audioContext.suspend(), 100);
}
audioContext.resume();
}
const cache = new Map<string, ArrayBuffer>();
async function getAudioFile(plugin: TTSPlugin<any>, text: string) {
const voice = await plugin.getCurrentVoice();
const hash = await md5(text);
const cacheKey = `audio:${voice?.service}:${voice?.id}:${hash}`;
let buffer = cache.get(cacheKey);
if (!buffer) {
buffer = await idb.get(cacheKey);
}
if (!buffer) {
try {
const result = await plugin.speakToBuffer(text);
if (result) {
buffer = result;
cache.set(cacheKey, cloneArrayBuffer(buffer));
idb.set(cacheKey, cloneArrayBuffer(buffer));
return buffer;
}
} catch (e) {
console.error(e);
}
}
return buffer || null;
}
export default class ExternalTTSAudioFilePlayer extends AbstractTTSPlayer {
private playing = true;
private ended = false;
private requestedSentenceIndex = 0; // sentence index requested by user
private currentSentenceIndex = 0;
private startTime = 0;
private audioArrayBuffers: ArrayBuffer[] = [];
private downloadLoop: AsyncLoop;
private schedulerLoop: AsyncLoop;
private sourceNodes: AudioBufferSourceNode[] = [];
private durations: number[] = [];
private duration = 0;
private destroyed = false;
constructor(private plugin: TTSPlugin) {
super();
this.downloadLoop = new AsyncLoop(this.download, 1000);
this.schedulerLoop = new AsyncLoop(this.schedule, 100);
audioContext.resume();
requestAnimationFrame(async () => {
audioContext.suspend();
this.downloadLoop.start();
this.schedulerLoop.start();
});
(window as any).player = this;
}
private download = async () => {
const sentences = [...this.sentences];
if (!this.complete) {
sentences.pop();
}
const maxSentencesToDownload = this.sourceNodes[this.currentSentenceIndex] ? 2 : 1;
const sentencesToDownload: number[] = [];
for (let i = 0; i < sentences.length; i++) {
if (sentencesToDownload.length >= maxSentencesToDownload) {
break;
}
if (!this.audioArrayBuffers[i]) {
sentencesToDownload.push(i);
}
}
const files = await Promise.all(sentencesToDownload.map(async sentenceIndex => {
try {
const text = sentences[sentenceIndex];
return await getAudioFile(this.plugin, text);
} catch (e) {
console.warn('error downloading tts audio', e);
}
}));
for (let i = 0; i < sentencesToDownload.length; i++) {
const sentenceIndex = sentencesToDownload[i];
const file = files[i];
if (file) {
this.audioArrayBuffers[sentenceIndex] = file;
} else {
await sleep(5000); // back off
}
}
this.emit('state', this.getState());
}
private schedule = async () => {
let time = this.startTime;
if (this.playing && this.sourceNodes[this.currentSentenceIndex] && audioContext.state === 'suspended') {
try {
await this.resumeAudioContext();
} catch (e: any) {
console.error(e);
}
}
try {
for (let i = this.requestedSentenceIndex; i < this.sentences.length; i++) {
if (this.destroyed) {
return;
}
const audioArrayBuffer = this.audioArrayBuffers[i];
if (!audioArrayBuffer) {
break;
}
if (!this.sourceNodes[i]) {
const audioBuffer = await audioContext.decodeAudioData(cloneArrayBuffer(audioArrayBuffer));
this.durations[i] = audioBuffer.duration;
const sourceNode = audioContext.createBufferSource();
sourceNode.buffer = audioBuffer;
if (i === this.requestedSentenceIndex) {
this.startTime = audioContext.currentTime;
time = this.startTime;
}
sourceNode.start(time);
this.duration = time + this.durations[i] - this.startTime;
audioContextInUse = true;
this.sourceNodes[i] = sourceNode;
sourceNode.connect(audioContext.destination);
if (this.playing) {
await this.resumeAudioContext();
}
sourceNode.onended = async () => {
if (this.destroyed) {
return;
}
this.currentSentenceIndex = i + 1;
this.ended = this.complete && this.currentSentenceIndex === this.sentences.length;
const isBuffering = !this.ended && !this.sourceNodes[this.currentSentenceIndex];
if (this.ended || isBuffering) {
await this.suspendAudioContext();
}
if (this.ended) {
this.playing = false;
}
this.emit('state', this.getState());
};
this.emit('state', this.getState());
}
time += this.durations[i] + 0.25;
}
} catch (e: any) {
console.error(e);
}
}
private async resumeAudioContext() {
try {
audioContext.resume();
await sleep(10);
} catch (e) {
console.warn('error resuming audio context', e);
}
}
private async suspendAudioContext() {
try {
await audioContext.suspend();
} catch (e) {
console.warn('error suspending audio context', e);
}
}
public getState(): TTSPlayerState {
return {
playing: this.playing,
ended: this.ended,
buffering: this.playing && !this.ended && !this.sourceNodes[this.currentSentenceIndex],
duration: this.duration,
length: this.sentences.length,
ready: this.audioArrayBuffers.filter(Boolean).length,
index: this.currentSentenceIndex,
downloadable: this.complete && this.sourceNodes.length === this.sentences.length,
} as any;
}
public async pause() {
this.playing = false;
await this.suspendAudioContext();
this.emit('state', this.getState());
}
public async play(index?: number) {
this.playing = true;
if (typeof index === 'number') {
this.requestedSentenceIndex = index;
this.currentSentenceIndex = index;
resetAudioContext();
if (this.sourceNodes.length) {
resetAudioContext();
this.sourceNodes = [];
this.durations = [];
this.duration = 0;
this.ended = false;
}
} else if (this.ended) {
await this.play(0);
} else if (audioContext.currentTime < this.duration) {
await this.resumeAudioContext();
} else {
await this.play(Math.max(0, this.sourceNodes.length - 1));
}
this.emit('state', this.getState());
}
public destroy() {
this.playing = false;
this.destroyed = true;
this.downloadLoop.cancelled = true;
this.schedulerLoop.cancelled = true;
resetAudioContext();
this.sourceNodes = [];
this.durations = [];
this.duration = 0;
this.removeAllListeners();
}
}

View File

@ -0,0 +1,120 @@
import DirectTTSPlugin from "./direct-tts-plugin";
import { AsyncLoop } from "../utils/async-loop";
import { AbstractTTSPlayer } from "./types";
import WebSpeechPlugin from "../../tts-plugins/web-speech";
export default class DirectTTSPlayer extends AbstractTTSPlayer {
playing = false;
ended = false;
private loop: AsyncLoop;
private currentIndex = 0;
private currentPlaybackIndex = 0;
private promises: any[] = [];
constructor(private plugin: WebSpeechPlugin) {
super();
console.log('tts init, directttsplayer');
this.emit('state', this.getState());
this.loop = new AsyncLoop(() => this.tick(), 100);
this.loop.start();
}
private async tick() {
if (!this.playing) {
return;
}
const sentences = [...this.sentences];
if (!this.complete) {
sentences.pop();
}
if (this.currentPlaybackIndex >= sentences.length) {
if (this.complete) {
console.log(`tts finished 1, current index: ${this.currentPlaybackIndex}, sentences length: ${sentences.length}`);
try {
await Promise.all(this.promises);
} catch (e) {
console.error('an error occured while reading text aloud', e);
}
console.log(`tts finished 2, current index: ${this.currentPlaybackIndex}, sentences length: ${sentences.length}`);
this.playing = false;
this.ended = true;
this.currentIndex = 0;
this.currentPlaybackIndex = 0;
this.promises = [];
this.emit('state', this.getState());
return;
}
}
if (this.currentIndex >= sentences.length) {
return;
}
this.ended = false;
try {
this.emit('state', this.getState());
const text = sentences[this.currentIndex];
console.log(`tts speaking`, text);
const p = this.plugin.speak(text);
p.then(() => {
this.currentPlaybackIndex = this.currentIndex + 1;
});
this.promises.push(p);
this.currentIndex += 1;
} catch (e) {
console.error('an error occured while reading text aloud', e);
}
}
async play(index?: number): Promise<any> {
if (this.playing) {
await this.plugin.stop();
this.promises = [];
}
this.playing = true;
this.ended = false;
if (typeof index === 'number') {
this.currentIndex = index;
this.currentPlaybackIndex = index;
}
await this.plugin.resume();
this.emit('state', this.getState());
}
async pause(): Promise<any> {
await this.plugin.pause();
this.playing = false;
this.emit('state', this.getState());
}
getState() {
return {
playing: this.playing,
ended: this.ended,
buffering: this.playing && !this.plugin.isSpeaking(),
index: this.currentPlaybackIndex,
length: this.sentences.length,
downloadable: false,
}
}
async destroy() {
if (this.playing) {
this.plugin.stop();
}
this.loop.cancelled = true;
this.playing = false;
this.removeAllListeners();
}
}

View File

@ -0,0 +1,10 @@
import { Voice } from "./types";
import TTSPlugin from "./tts-plugin";
export default class DirectTTSPlugin<T=any> extends TTSPlugin<T> {
async speak(text: string, voice?: Voice) {
}
async stop() {
}
}

View File

@ -0,0 +1,16 @@
import Plugin from "../plugins";
import { Voice } from "../tts/types";
export default class TTSPlugin<T=any> extends Plugin<T> {
async getVoices(): Promise<Voice[]> {
return [];
}
async getCurrentVoice(): Promise<Voice> {
throw new Error("not implemented");
}
async speakToBuffer(text: string, voice?: Voice): Promise<ArrayBuffer | null | undefined> {
throw new Error("not implemented");
}
}

View File

@ -0,0 +1,48 @@
import EventEmitter from "events";
import { split } from "sentence-splitter";
export interface TTSPlayerState {
playing: boolean;
ended: boolean;
buffering: boolean;
duration?: number;
index: number;
length: number;
ready?: number;
downloadable: boolean;
}
export abstract class AbstractTTSPlayer extends EventEmitter {
private lines: string[] = [];
protected sentences: string[] = [];
protected complete = false;
abstract play(index?: number): Promise<any>;
abstract pause(): Promise<any>;
abstract getState(): TTSPlayerState;
abstract destroy(): any;
public setText(lines: string[], complete: boolean) {
this.lines = lines;
this.complete = complete;
this.updateSentences();
}
private updateSentences() {
const output: string[] = [];
for (const line of this.lines) {
const sentences = split(line);
for (const sentence of sentences) {
output.push(sentence.raw.trim());
}
}
this.sentences = output.filter(s => s.length > 0);
}
}
export interface Voice {
service: string;
id: string;
name?: string;
sampleAudioURL?: string;
}

View File

@ -0,0 +1,162 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useAppContext } from "../context";
import { ttsPlugins } from "../plugins/metadata";
import Plugin from "../plugins";
import { AbstractTTSPlayer, TTSPlayerState, Voice } from "./types";
import { createBasicPluginContext } from "../plugins/plugin-context";
import DirectTTSPlayer from "./direct-tts-player";
import DirectTTSPlugin from "./direct-tts-plugin";
import TTSPlugin from "./tts-plugin";
import ExternalTTSAudioFilePlayer from "./audio-file-player";
import { split } from "sentence-splitter";
import { useOption } from "../options/use-option";
function extractTextSegments(element: HTMLElement) {
const selector = 'p, li, th, td, blockquote, pre code, h1, h2, h3, h3, h5, h6';
const nodes = Array.from(element.querySelectorAll(selector) || []);
const lines: string[] = [];
const blocks = nodes.filter(node => !node.parentElement?.closest(selector) && node.textContent);
for (const block of blocks) {
const tagName = block.tagName.toLowerCase();
if (tagName === 'p' || tagName === 'li' || tagName === 'blockquote') {
const sentences = split(block.textContent!);
for (const sentence of sentences) {
lines.push(sentence.raw.trim());
}
} else {
lines.push(block.textContent!.trim());
}
}
return lines.filter(line => line.length);
}
interface ITTSContext {
key: string | null;
voice: Voice | null;
autoplayEnabled: boolean;
state?: TTSPlayerState;
play(index?: number): void;
pause(): void;
cancel(): void;
setSourceElement(key: string, element: HTMLElement | null): void;
setComplete(complete: boolean): void;
}
export function useTTSPlayerState(): ITTSContext {
const context = useAppContext();
const [ttsPluginID] = useOption<string>('tts', 'service');
const [autoplayEnabled] = useOption<boolean>('tts', 'autoplay');
const [voiceID] = useOption<string>(ttsPluginID, 'voice');
const voice = useMemo(() => ({
service: ttsPluginID,
id: voiceID,
}), [ttsPluginID, voiceID]);
const ttsPluginImpl = useMemo(() => {
const ttsPluginIndex = ttsPlugins.findIndex(p => new p().describe().id === ttsPluginID) || 0;
return ttsPlugins[ttsPluginIndex];
}, [ttsPluginID]);
const plugin = useRef<Plugin|null>(null);
const player = useRef<AbstractTTSPlayer|null>(null);
const elementRef = useRef<HTMLElement|null>(null);
const [key, setKey] = useState<string|null>(null);
const [state, setState] = useState(() => player.current?.getState());
const [complete, setComplete] = useState(false);
const timer = useRef<any>();
const setSourceElement = useCallback((newKey: string | null, element: HTMLElement | null) => {
elementRef.current = element;
if (key !== newKey || !element) {
plugin.current = null;
player.current?.destroy();
player.current = null;
}
setKey(newKey);
if (element) {
if (!plugin.current) {
const pluginContext = createBasicPluginContext(ttsPluginID, context.chat.options, context.id, context.currentChat.chat)
plugin.current = new ttsPluginImpl(pluginContext);
}
if (!player.current) {
if (plugin.current instanceof DirectTTSPlugin) {
player.current = new DirectTTSPlayer(plugin.current as any);
} else if (plugin.current instanceof TTSPlugin) {
player.current = new ExternalTTSAudioFilePlayer(plugin.current);
}
player.current!.on('state', setState);
}
} else {
setState(undefined);
}
}, [ttsPluginID, context, complete, key]);
useEffect(() => {
setSourceElement(null, null);
}, [ttsPluginID, voiceID]);
useEffect(() => {
clearInterval(timer.current);
const update = () => {
if (!player.current || !elementRef.current) {
return;
}
player.current.setText(extractTextSegments(elementRef.current), complete);
};
update();
if (!complete) {
timer.current = setInterval(update, 1000);
}
}, [key, complete]);
return {
key,
voice: voiceID ? voice : null,
autoplayEnabled,
state: !state?.ended ? state : undefined,
play(index?: number) {
player.current?.play(index);
},
pause() {
player.current?.pause();
},
cancel() {
setSourceElement(null, null);
},
setSourceElement,
setComplete,
}
}
const TTSContext = createContext<ITTSContext>({
key: null,
voice: null,
autoplayEnabled: false,
play() {},
pause() {},
cancel() {},
setSourceElement() {},
setComplete() {},
});
export function useTTS() {
return useContext(TTSContext);
}
export function TTSContextProvider(props: { children: React.ReactNode }) {
const context = useTTSPlayerState();
return <TTSContext.Provider value={context}>{props.children}</TTSContext.Provider>;
}

View File

@ -0,0 +1,46 @@
import { sleep } from '.';
/**
* AsyncLoop class provides a mechanism to execute a given function
* asynchronously in a loop with a specified delay between each execution.
* Unlike setInterval, it ensures that each iteration finishes before
* starting the next one.
*/
export class AsyncLoop {
public cancelled = false;
/**
* Creates a new instance of the AsyncLoop class.
* @param {Function} handler - The function to be executed in the loop.
* @param {number} pauseBetween - The delay (in milliseconds) between each execution of the handler. Default is 1000 ms.
*/
constructor(private handler: any, private pauseBetween: number = 1000) {
}
/**
* Starts the asynchronous loop by calling the loop() method.
*/
public start() {
this.loop().then(() => { });
}
/**
* The main loop function that executes the given handler function
* while the loop is not cancelled. It catches any errors thrown by
* the handler function and logs them to the console.
* @private
* @returns {Promise<void>} A Promise that resolves when the loop is cancelled.
*/
private async loop() {
while (!this.cancelled) {
try {
await this.handler();
} catch (e) {
console.error(e);
}
await sleep(this.pauseBetween);
}
}
}

View File

@ -0,0 +1,91 @@
import EventEmitter from 'events';
export interface EventEmitterAsyncIteratorOutput<T> {
eventName: string;
value: T;
}
/**
* The EventEmitterAsyncIterator class provides a way to create an async iterator
* that listens to multiple events from an EventEmitter instance, and yields
* the emitted event name and value as an EventEmitterAsyncIteratorOutput object.
*
* This class implements the AsyncIterableIterator interface, which allows it
* to be used in for-await-of loops and other asynchronous iteration contexts.
*
* @typeparam T - The type of values emitted by the events.
*
* @example
* const eventEmitter = new EventEmitter();
* const asyncIterator = new EventEmitterAsyncIterator(eventEmitter, ['event1', 'event2']);
*
* for await (const event of asyncIterator) {
* console.log(`Received event: ${event.eventName} with value: ${event.value}`);
* }
*/
export class EventEmitterAsyncIterator<T> implements AsyncIterableIterator<EventEmitterAsyncIteratorOutput<T>> {
private eventQueue: EventEmitterAsyncIteratorOutput<T>[] = [];
private resolveQueue: ((value: IteratorResult<EventEmitterAsyncIteratorOutput<T>>) => void)[] = [];
/**
* Constructor takes an EventEmitter instance and an array of event names to listen to.
* For each event name, it binds the pushEvent method with the eventName, which
* will be called when the event is emitted.
*
* @param eventEmitter - The EventEmitter instance to listen to events from.
* @param eventNames - An array of event names to listen to.
*/
constructor(private eventEmitter: EventEmitter, eventNames: string[]) {
for (const eventName of eventNames) {
this.eventEmitter.on(eventName, this.pushEvent.bind(this, eventName));
}
}
/**
* The next method is called when the iterator is requested to return the next value.
* If there is an event in the eventQueue, it will return the next event from the queue.
* If the eventQueue is empty, it will return a Promise that resolves when a new event is received.
*
* @returns A Promise that resolves with the next event or waits for a new event if the queue is empty.
*/
async next(): Promise<IteratorResult<EventEmitterAsyncIteratorOutput<T>>> {
if (this.eventQueue.length > 0) {
const value = this.eventQueue.shift();
return { value: value as EventEmitterAsyncIteratorOutput<T>, done: false };
} else {
return new Promise<IteratorResult<EventEmitterAsyncIteratorOutput<T>>>(resolve => {
this.resolveQueue.push(value => {
resolve(value);
});
});
}
}
/**
* The pushEvent method is called when an event is emitted from the EventEmitter.
* If there is a pending Promise in the resolveQueue, it resolves the Promise with the new event.
* If there is no pending Promise, it adds the event to the eventQueue.
*
* @param eventName - The name of the emitted event.
* @param value - The value emitted with the event.
*/
private pushEvent(eventName: string, value: T): void {
const output: EventEmitterAsyncIteratorOutput<T> = {
eventName,
value,
};
if (this.resolveQueue.length > 0) {
const resolve = this.resolveQueue.shift();
if (resolve) {
resolve({ value: output, done: false });
}
} else {
this.eventQueue.push(output);
}
}
[Symbol.asyncIterator](): AsyncIterableIterator<EventEmitterAsyncIteratorOutput<T>> {
return this;
}
}

View File

@ -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'; import * as idb from 'idb-keyval';
let supported = true; let supported = true;

View File

@ -0,0 +1,101 @@
import * as hashes from 'jshashes';
/**
* Pauses the execution of the function for a specified duration.
*
* @export
* @param {number} ms - The duration (in milliseconds) to pause the execution.
* @returns {Promise} A Promise that resolves after the specified duration.
*/
export function sleep(ms: number): Promise<any> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Truncates a given string to a specified length and appends ellipsis (...) if needed.
*
* @export
* @param {string} text - The input string to be ellipsized.
* @param {number} maxLength - The maximum length of the output string (including the ellipsis).
* @returns {string} The ellipsized string.
*/
export function ellipsize(text: string, maxLength: number): string {
if (text.length > maxLength) {
return text.substring(0, maxLength) + '...';
}
return text;
}
/**
* Creates a deep clone of the given ArrayBuffer.
*
* @export
* @param {ArrayBuffer} buffer - The ArrayBuffer to clone.
* @returns {ArrayBuffer} A new ArrayBuffer containing the same binary data as the input buffer.
*/
export function cloneArrayBuffer(buffer: ArrayBuffer): ArrayBuffer {
const newBuffer = new ArrayBuffer(buffer.byteLength);
new Uint8Array(newBuffer).set(new Uint8Array(buffer));
return newBuffer;
}
/**
* Shares the specified text using the Web Share API if available in the user's browser.
*
* @function
* @async
* @param {string} text - The text to be shared.
* @example
* share("Hello, World!");
*/
export async function share(text: string) {
if (navigator.share) {
await navigator.share({
text,
});
}
}
/*
Hashing
*/
const hasher = new hashes.MD5();
const hashCache = new Map<string, string>();
export async function md5(data: string): Promise<string> {
if (!hashCache.has(data)) {
const hashHex = hasher.hex(data);
hashCache.set(data, hashHex);
}
return hashCache.get(data)!;
}
/*
Rate limiting
*/
export function getRateLimitResetTimeFromResponse(response: Response): number {
const now = Date.now();
const fallbackValue = now + 20*1000;
const maxValue = now + 2*60*1000;
const rateLimitReset = response.headers.get("x-ratelimit-reset");
if (!rateLimitReset) {
return fallbackValue;
}
let resetTime = parseInt(rateLimitReset, 10);
if (isNaN(resetTime)) {
return fallbackValue;
}
resetTime *= 1000;
if (resetTime > fallbackValue) {
return maxValue;
}
return resetTime;
}

View File

@ -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 <maxime.petazzoni@bulix.org>. * Copyright (C) 2016 Maxime Petazzoni <maxime.petazzoni@bulix.org>.
* All rights reserved. * All rights reserved.
*/ */
export default class SSE { export default class SSE {
// Constants representing the ready state of the SSE connection
public INITIALIZING = -1; public INITIALIZING = -1;
public CONNECTING = 0; public CONNECTING = 0;
public OPEN = 1; public OPEN = 1;
public CLOSED = 2; public CLOSED = 2;
public headers = this.options.headers || {}; // Connection settings
public payload = this.options.payload !== undefined ? this.options.payload : ''; private headers = this.options.headers || {};
public method = this.options.method ? this.options.method : (this.payload ? 'POST' : 'GET'); private payload = this.options.payload !== undefined ? this.options.payload : '';
public withCredentials = !!this.options.withCredentials; private method = this.options.method ? this.options.method : (this.payload ? 'POST' : 'GET');
private withCredentials = !!this.options.withCredentials;
public FIELD_SEPARATOR = ':'; // Internal properties
public listeners: any = {}; private FIELD_SEPARATOR = ':';
private listeners: any = {};
public xhr: any = null; private xhr: any = null;
public readyState = this.INITIALIZING; private readyState = this.INITIALIZING;
public progress = 0; private progress = 0;
public chunk = ''; 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) => { public addEventListener = (type: string, listener: any) => {
if (this.listeners[type] === undefined) { if (this.listeners[type] === undefined) {
this.listeners[type] = []; 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) => { public removeEventListener = (type: string, listener: any) => {
if (this.listeners[type] === undefined) { if (this.listeners[type] === undefined) {
return; return;
} }
var filtered: any[] = []; const filtered: any[] = [];
this.listeners[type].forEach((element: any) => { this.listeners[type].forEach((element: any) => {
if (element !== listener) { if (element !== listener) {
filtered.push(element); 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) { if (!e) {
return true; return true;
} }
e.source = this; e.source = this;
var onHandler = 'on' + e.type; const onHandler = 'on' + e.type;
if (this.hasOwnProperty(onHandler)) { if (this.hasOwnProperty(onHandler)) {
// @ts-ignore // @ts-ignore
this[onHandler].call(this, e); this[onHandler].call(this, e);
@ -78,137 +210,46 @@ export default class SSE {
return true; return true;
}; };
public _setReadyState = (state: number) => { /**
var event = new CustomEvent<any>('readystatechange'); * Sets the ready state of the SSE connection and dispatches a 'readystatechange' event.
*/
private setReadyState = (state: number) => {
const event = new CustomEvent<any>('readystatechange');
// @ts-ignore // @ts-ignore
event.readyState = state; event.readyState = state;
this.readyState = state; this.readyState = state;
this.dispatchEvent(event); 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 // @ts-ignore
event.data = e.currentTarget.response; event.data = e.currentTarget.response;
this.dispatchEvent(event); this.dispatchEvent(event);
this.close(); 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.dispatchEvent(new CustomEvent('abort'));
this.close(); 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) => { private checkStreamClosed = () => {
if (!chunk || chunk.length === 0) {
return null;
}
var e: any = { 'id': null, 'retry': null, 'data': '', 'event': 'message' };
chunk.split(/\n|\r\n|\r/).forEach((line: string) => {
line = line.trimRight();
var index = line.indexOf(this.FIELD_SEPARATOR);
if (index <= 0) {
// Line was either empty, or started with a separator and is a comment.
// Either way, ignore.
return;
}
var field = line.substring(0, index);
if (!(field in e)) {
return;
}
var value = line.substring(index + 1).trimLeft();
if (field === 'data') {
e[field] += value;
} else {
e[field] = value;
}
});
var event: any = new CustomEvent(e.event);
event.data = e.data;
event.id = e.id;
return event;
};
public _checkStreamClosed = () => {
if (!this.xhr) { if (!this.xhr) {
return; return;
} }
if (this.xhr.readyState === XMLHttpRequest.DONE) { if (this.xhr.readyState === XMLHttpRequest.DONE) {
this._setReadyState(this.CLOSED); 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);
} }
}; };
}; };

View File

@ -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);

View File

@ -0,0 +1,32 @@
import { FormattedMessage } from "react-intl";
import { OptionGroup } from "../core/options/option-group";
export const openAIOptions: OptionGroup = {
id: 'openai',
options: [
{
id: 'apiKey',
defaultValue: "",
displayOnSettingsScreen: "user",
displayAsSeparateSection: true,
renderProps: () => ({
type: "password",
label: "Your OpenAI API Key",
placeholder: "sk-************************************************",
description: <>
<p>
<a href="https://platform.openai.com/account/api-keys" target="_blank" rel="noreferrer">
<FormattedMessage defaultMessage="Find your API key here." description="Label for the link that takes the user to the page on the OpenAI website where they can find their API key." />
</a>
</p>
<p>
<FormattedMessage defaultMessage="Your API key is stored only on this device and never transmitted to anyone except OpenAI." />
</p>
<p>
<FormattedMessage defaultMessage="OpenAI API key usage is billed at a pay-as-you-go rate, separate from your ChatGPT subscription." />
</p>
</>,
}),
},
],
}

View File

@ -0,0 +1,64 @@
import { defaultModel } from "../core/chat/openai";
import { OptionGroup } from "../core/options/option-group";
export const parameterOptions: OptionGroup = {
id: 'parameters',
options: [
{
id: "model",
defaultValue: defaultModel,
resettable: false,
scope: "user",
displayOnSettingsScreen: "chat",
displayAsSeparateSection: true,
displayInQuickSettings: {
name: "Model",
displayByDefault: true,
label: (value) => value,
},
renderProps: (value, options, context) => ({
type: "select",
label: "Model",
description: value === 'gpt-4' && context.intl.formatMessage(
{
defaultMessage: "Note: GPT-4 will only work if your OpenAI account has been granted access to the new model. <a>Request access here.</a>",
},
{
a: (text: string) => <a href="https://openai.com/waitlist/gpt-4-api" target="_blank" rel="noreferer">{text}</a>
} as any,
),
options: [
{
label: "GPT 3.5 Turbo (default)",
value: "gpt-3.5-turbo",
},
{
label: "GPT 4 (requires invite)",
value: "gpt-4",
},
],
}),
},
{
id: "temperature",
defaultValue: 0.5,
resettable: true,
scope: "chat",
displayOnSettingsScreen: "chat",
displayAsSeparateSection: true,
displayInQuickSettings: {
name: "Temperature",
displayByDefault: false,
label: (value) => "Temperature: " + value.toFixed(1),
},
renderProps: (value, options, context) => ({
type: "slider",
label: "Temperature: " + value.toFixed(1),
min: 0,
max: 1,
step: 0.1,
description: context.intl.formatMessage({ defaultMessage: "The temperature parameter controls the randomness of the AI's responses. Lower values will make the AI more predictable, while higher values will make it more creative." }),
})
}
]
};

View File

@ -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,
})),
},
},
],
}

View File

@ -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",
},
},
],
}

View File

@ -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",
},
},
],
}

View File

@ -6,13 +6,12 @@ import { IntlProvider } from 'react-intl';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { createBrowserRouter, RouterProvider } from "react-router-dom"; import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { PersistGate } from 'redux-persist/integration/react'; import { PersistGate } from 'redux-persist/integration/react';
import { AppContextProvider } from './context'; import { AppContextProvider } from './core/context';
import store, { persistor } from './store'; import store, { persistor } from './store';
import ChatPage from './components/pages/chat'; import ChatPage from './components/pages/chat';
import LandingPage from './components/pages/landing'; import LandingPage from './components/pages/landing';
import './backend';
import './index.scss'; import './index.scss';
const router = createBrowserRouter([ const router = createBrowserRouter([

View File

@ -1,132 +0,0 @@
import { Message } from "./types";
export interface Node extends Message {
parent: Node | null;
children: Set<Node>;
}
export function createNode(message: Message): Node {
return {
...message,
parent: null,
children: new Set(),
};
}
export class MessageTree {
public nodes: Map<string, Node> = new Map();
constructor(messages: (Message | Node)[] = []) {
this.addMessages(messages);
}
public get roots(): Node[] {
return Array.from(this.nodes.values())
.filter((node) => node.parent === null);
}
public get leafs(): Node[] {
return Array.from(this.nodes.values())
.filter((node) => node.children.size === 0);
}
public get first(): Node | null {
const leaf = this.mostRecentLeaf();
let first: Node | null = leaf;
while (first?.parent) {
first = first.parent;
}
return first;
}
public get(id: string) {
return this.nodes.get(id);
}
public addMessage(message: Message) {
if (this.nodes.get(message.id)?.content) {
return;
}
const node = createNode(message);
this.nodes.set(node.id, node);
if (node.parentID) {
let parent = this.nodes.get(node.parentID);
if (!parent) {
parent = createNode({
id: node.parentID,
} as Message);
this.nodes.set(parent.id, parent);
}
parent.children.add(node);
node.parent = parent;
}
for (const other of Array.from(this.nodes.values())) {
if (other.parentID === node.id) {
node.children.add(other);
other.parent = node;
}
}
}
public addMessages(messages: Message[]) {
for (const message of messages) {
try {
this.addMessage(message);
} catch (e) {
console.error(e);
}
}
}
public updateMessage(message: Message) {
const node = this.nodes.get(message.id);
if (!node) {
return;
}
node.content = message.content;
node.timestamp = message.timestamp;
node.done = message.done;
}
public getMessageChainTo(messageID: string) {
const message = this.nodes.get(messageID);
if (!message) {
return [];
}
const chain = [message];
let current = message;
while (current.parent) {
chain.unshift(current.parent);
current = current.parent;
}
return chain;
}
public serialize() {
return Array.from(this.nodes.values())
.map((node) => {
const n: any = { ...node };
delete n.parent;
delete n.children;
return n;
});
}
public mostRecentLeaf() {
return this.leafs.sort((a, b) => b.timestamp - a.timestamp)[0];
}
}

View File

@ -0,0 +1,16 @@
import Plugin from "../core/plugins";
import { SystemPromptPlugin } from "./system-prompt";
import { TitlePlugin } from "./titles";
import { ContextTrimmerPlugin } from "./trimmer";
import ElevenLabsPlugin from "../tts-plugins/elevenlabs";
import WebSpeechPlugin from "../tts-plugins/web-speech";
export const registeredPlugins: Array<typeof Plugin<any>> = [
SystemPromptPlugin,
ContextTrimmerPlugin,
TitlePlugin,
WebSpeechPlugin,
ElevenLabsPlugin,
];

View File

@ -0,0 +1,60 @@
import { FormattedMessage } from "react-intl";
import Plugin from "../core/plugins";
import { PluginDescription } from "../core/plugins/plugin-description";
import { OpenAIMessage, Parameters } from "../core/chat/types";
export const defaultSystemPrompt = `
You are ChatGPT, a large language model trained by OpenAI.
Knowledge cutoff: 2021-09
Current date and time: {{ datetime }}
`.trim();
export interface SystemPromptPluginOptions {
systemPrompt: string;
}
export class SystemPromptPlugin extends Plugin<SystemPromptPluginOptions> {
describe(): PluginDescription {
return {
id: "system-prompt",
name: "System Prompt",
options: [
{
id: "systemPrompt",
defaultValue: defaultSystemPrompt,
displayOnSettingsScreen: "chat",
resettable: true,
scope: "chat",
renderProps: {
type: "textarea",
description: <p>
<FormattedMessage defaultMessage={"The System Prompt is an invisible message inserted at the start of the chat and can be used to give ChatGPT information about itself and general guidelines for how it should respond. The <code>'{{ datetime }}'</code> tag is automatically replaced by the current date and time (use this to give the AI access to the time)."}
values={{ code: v => <code>{v}</code> }} />
</p>,
},
displayInQuickSettings: {
name: "System Prompt",
displayByDefault: true,
label: "Customize system prompt",
},
},
],
};
}
async preprocessModelInput(messages: OpenAIMessage[], parameters: Parameters): Promise<{ messages: OpenAIMessage[]; parameters: Parameters; }> {
const output = [
{
role: 'system',
content: (this.options?.systemPrompt || defaultSystemPrompt)
.replace('{{ datetime }}', new Date().toLocaleString()),
},
...messages,
];
return {
messages: output,
parameters,
};
}
}

View File

@ -0,0 +1,75 @@
import Plugin from "../core/plugins";
import { PluginDescription } from "../core/plugins/plugin-description";
import { OpenAIMessage, Parameters } from "../core/chat/types";
import { countTokens, runChatTrimmer } from "../core/tokenizer/wrapper";
import { defaultModel } from "../core/chat/openai";
export const systemPrompt = `
Please read the following exchange and write a short, concise title describing the topic (in the user's language).
If there is no clear topic for the exchange, respond with: N/A
`.trim();
export const systemPromptForLongExchanges = `
Please read the following exchange and write a short, concise title describing the topic (in the user's language).
`.trim();
export interface TitlePluginOptions {
}
const userPrompt = (messages: OpenAIMessage[]) => {
return messages.map(m => `${m.role.toLocaleUpperCase()}:\n${m.content}`)
.join("\n===\n")
+ "\n===\nTitle:";
}
export class TitlePlugin extends Plugin<TitlePluginOptions> {
describe(): PluginDescription {
return {
id: "titles",
name: "Title Generator",
options: [],
};
}
async postprocessModelOutput(message: OpenAIMessage, contextMessages: OpenAIMessage[], parameters: Parameters, done: boolean): Promise<OpenAIMessage> {
if (done && !this.context?.getCurrentChat().title) {
(async () => {
let messages = [
...contextMessages.filter(m => m.role === 'user' || m.role === 'assistant'),
message,
];
const tokens = await countTokens(messages);
messages = await runChatTrimmer(messages, {
maxTokens: 1024,
preserveFirstUserMessage: true,
preserveSystemPrompt: false,
});
messages = [
{
role: 'system',
content: tokens.length > 512 ? systemPromptForLongExchanges : systemPrompt,
},
{
role: 'user',
content: userPrompt(messages),
},
]
const output = await this.context?.createChatCompletion(messages, {
model: defaultModel,
temperature: 0,
});
if (!output || output === 'N/A') {
return;
}
this.context?.setChatTitle(output);
})();
}
return message;
}
}

View File

@ -0,0 +1,106 @@
import Plugin from "../core/plugins";
import { PluginDescription } from "../core/plugins/plugin-description";
import { OpenAIMessage, Parameters } from "../core/chat/types";
import { maxTokensByModel } from "../core/chat/openai";
import { countTokens, runChatTrimmer } from "../core/tokenizer/wrapper";
export interface ContextTrimmerPluginOptions {
maxTokens: number;
maxMessages: number | null;
preserveSystemPrompt: boolean;
preserveFirstUserMessage: boolean;
}
export class ContextTrimmerPlugin extends Plugin<ContextTrimmerPluginOptions> {
describe(): PluginDescription {
return {
id: "context-trimmer",
name: "Message Context",
options: [
{
id: 'maxTokens',
displayOnSettingsScreen: "chat",
defaultValue: 2048,
scope: "chat",
renderProps: (value, options) => ({
label: `Include a maximum of ${value} tokens`,
type: "slider",
min: 512,
max: maxTokensByModel[options.getOption('parameters', 'model')] || 2048,
step: 512,
}),
validate: (value, options) => {
const max = maxTokensByModel[options.getOption('parameters', 'model')] || 2048;
return value < max;
},
displayInQuickSettings: {
name: "Max Tokens",
displayByDefault: false,
label: value => `Max tokens: ${value}`,
},
},
// {
// id: 'maxMessages',
// displayOnSettingsScreen: "chat",
// defaultValue: null,
// scope: "chat",
// renderProps: (value) => ({
// label: `Include only the last ${value || 'N'} messages (leave blank for all)`,
// type: "number",
// min: 1,
// max: 10,
// step: 1,
// }),
// displayInQuickSettings: {
// name: "Max Messages",
// displayByDefault: false,
// label: value => `Include ${value ?? 'all'} messages`,
// },
// },
{
id: 'preserveSystemPrompt',
displayOnSettingsScreen: "chat",
defaultValue: true,
scope: "chat",
renderProps: {
label: "Try to always include the System Prompt",
type: "checkbox",
},
},
{
id: 'preserveFirstUserMessage',
displayOnSettingsScreen: "chat",
defaultValue: true,
scope: "chat",
renderProps: {
label: "Try to always include your first message",
type: "checkbox",
},
},
],
};
}
async preprocessModelInput(messages: OpenAIMessage[], parameters: Parameters): Promise<{ messages: OpenAIMessage[]; parameters: Parameters; }> {
const before = await countTokens(messages);
const options = this.options;
const trimmed = await runChatTrimmer(messages, {
maxTokens: options?.maxTokens ?? 2048,
nMostRecentMessages: options?.maxMessages ?? undefined,
preserveFirstUserMessage: options?.preserveFirstUserMessage || true,
preserveSystemPrompt: options?.preserveSystemPrompt || true,
});
const after = await countTokens(trimmed);
const diff = after - before;
console.log(`[context trimmer] trimmed ${diff} tokens from context`);
return {
messages: trimmed,
parameters,
};
}
}

View File

@ -1,34 +1,38 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAppContext } from "./context"; import { useAppContext } from "./core/context";
export function useChatSpotlightProps() { export function useChatSpotlightProps() {
const navigate = useNavigate(); const navigate = useNavigate();
const context = useAppContext(); const { chat } = useAppContext();
const intl = useIntl(); const intl = useIntl();
const [version, setVersion] = useState(0); const [version, setVersion] = useState(0);
useEffect(() => { useEffect(() => {
context.chat.on('update', () => setVersion(v => v + 1)); const handleUpdate = () => setVersion(v => v + 1);
}, [context.chat]); chat.on('update', handleUpdate);
return () => {
chat.off('update', handleUpdate);
};
}, [chat]);
const search = useCallback((query: string) => { const search = useCallback((query) => {
return context.chat.search.query(query) return chat.searchChats(query)
.map((result: any) => ({ .map((result) => ({
...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(() => ({ const props = useMemo(() => ({
shortcut: ['mod + P'], shortcut: ['/'],
overlayColor: '#000000', overlayColor: '#000000',
searchPlaceholder: intl.formatMessage({ defaultMessage: 'Search your chats' }), searchPlaceholder: intl.formatMessage({ defaultMessage: 'Search your chats' }),
searchIcon: <i className="fa fa-search" />, searchIcon: <i className="fa fa-search" />,
actions: search, actions: search,
filter: (query: string, items: any) => items, filter: (query, items) => items,
}), [search]); }), [search]);
return props; return props;

View File

@ -1,42 +0,0 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { RootState } from '.';
const initialState: {
openAIApiKey?: string | null | undefined;
useOpenAIWhisper: boolean;
elevenLabsApiKey?: string | null | undefined;
} = {
openAIApiKey: localStorage.getItem('openai-api-key'),
useOpenAIWhisper: false,
elevenLabsApiKey: localStorage.getItem('elevenlabs-api-key'),
};
export const apiKeysSlice = createSlice({
name: 'apiKeys',
initialState,
reducers: {
setOpenAIApiKey: (state, action: PayloadAction<string>) => {
state.openAIApiKey = action.payload;
},
setElevenLabsApiKey: (state, action: PayloadAction<string>) => {
state.elevenLabsApiKey = action.payload;
},
setUseOpenAIWhisper: (state, action: PayloadAction<boolean>) => {
state.useOpenAIWhisper = action.payload;
}
},
})
export const { setOpenAIApiKey, setElevenLabsApiKey } = apiKeysSlice.actions;
export const setOpenAIApiKeyFromEvent = (event: React.ChangeEvent<HTMLInputElement>) => apiKeysSlice.actions.setOpenAIApiKey(event.target.value);
export const setElevenLabsApiKeyFromEvent = (event: React.ChangeEvent<HTMLInputElement>) => apiKeysSlice.actions.setElevenLabsApiKey(event.target.value);
export const setUseOpenAIWhisperFromEvent = (event: React.ChangeEvent<HTMLInputElement>) => apiKeysSlice.actions.setUseOpenAIWhisper(event.target.checked);
export const selectOpenAIApiKey = (state: RootState) => state.apiKeys.openAIApiKey;
export const selectElevenLabsApiKey = (state: RootState) => state.apiKeys.elevenLabsApiKey;
export const selectUseOpenAIWhisper = (state: RootState) => state.apiKeys.useOpenAIWhisper;
export default apiKeysSlice.reducer;

View File

@ -3,11 +3,8 @@ import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import storage from 'redux-persist/lib/storage'; import storage from 'redux-persist/lib/storage';
import { persistReducer, persistStore } from 'redux-persist'; import { persistReducer, persistStore } from 'redux-persist';
import messageReducer from './message'; 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 uiReducer from './ui';
import settingsUIReducer from './settings-ui';
import sidebarReducer from './sidebar'; import sidebarReducer from './sidebar';
const persistConfig = { const persistConfig = {
@ -29,13 +26,9 @@ const persistMessageConfig = {
const store = configureStore({ const store = configureStore({
reducer: { reducer: {
// auth: authReducer,
apiKeys: persistReducer(persistConfig, apiKeysReducer),
settingsUI: settingsUIReducer,
voices: persistReducer(persistConfig, voiceReducer),
parameters: persistReducer(persistConfig, parametersReducer),
message: persistReducer(persistMessageConfig, messageReducer), message: persistReducer(persistMessageConfig, messageReducer),
ui: uiReducer, ui: uiReducer,
settingsUI: settingsUIReducer,
sidebar: persistReducer(persistSidebarConfig, sidebarReducer), sidebar: persistReducer(persistSidebarConfig, sidebarReducer),
}, },
}) })

View File

@ -1,37 +0,0 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import type { RootState } from '.';
import { defaultSystemPrompt, defaultModel } from '../openai';
import { defaultParameters } from '../parameters';
import { Parameters } from '../types';
const initialState: Parameters = defaultParameters;
export const parametersSlice = createSlice({
name: 'parameters',
initialState,
reducers: {
setSystemPrompt: (state, action: PayloadAction<string>) => {
state.initialSystemPrompt = action.payload;
},
resetSystemPrompt: (state) => {
state.initialSystemPrompt = defaultSystemPrompt;
},
setModel: (state, action: PayloadAction<string>) => {
state.model = action.payload;
},
resetModel: (state) => {
state.model = defaultModel;
},
setTemperature: (state, action: PayloadAction<number>) => {
state.temperature = action.payload;
},
},
})
export const { setSystemPrompt, setModel, setTemperature, resetSystemPrompt, resetModel } = parametersSlice.actions;
export const selectSystemPrompt = (state: RootState) => state.parameters.initialSystemPrompt;
export const selectModel = (state: RootState) => state.parameters.model;
export const selectTemperature = (state: RootState) => state.parameters.temperature;
export default parametersSlice.reducer;

View File

@ -30,9 +30,9 @@ export const closeSettingsUI = () => settingsUISlice.actions.setTabAndOption({ t
export const selectSettingsTab = (state: RootState) => state.settingsUI.tab; export const selectSettingsTab = (state: RootState) => state.settingsUI.tab;
export const selectSettingsOption = (state: RootState) => state.settingsUI.option; 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 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 const openTemperaturePanel = () => settingsUISlice.actions.setTabAndOption({ tab: 'options', option: 'temperature' });
export default settingsUISlice.reducer; export default settingsUISlice.reducer;

View File

@ -1,23 +0,0 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { RootState } from '.';
import { defaultElevenLabsVoiceID } from '../tts/defaults';
const initialState = {
voice: defaultElevenLabsVoiceID,
};
export const voicesSlice = createSlice({
name: 'voices',
initialState,
reducers: {
setVoice: (state, action: PayloadAction<string|null>) => {
state.voice = action.payload || '';
},
},
})
export const { setVoice } = voicesSlice.actions;
export const selectVoice = (state: RootState) => state.voices.voice;
export default voicesSlice.reducer;

1
app/src/stub.js 100644
View File

@ -0,0 +1 @@
module.exports = function() {};

View File

@ -1,61 +0,0 @@
import { createChatCompletion, defaultModel } from "./openai";
import { OpenAIMessage, Chat } from "./types";
const systemPrompt = `
Please read the following exchange and write a short, concise title describing the topic (in the user's language).
`.trim();
const userPrompt = (user: string, assistant: string) => `
Message: ${user}
Response: ${assistant}
Title:
`.trim();
export async function createTitle(chat: Chat, apiKey: string | undefined | null, attempt = 0): Promise<string|null> {
if (!apiKey) {
return null;
}
const nodes = Array.from(chat.messages.nodes.values());
const firstUserMessage = nodes.find(m => m.role === 'user');
const firstAssistantMessage = nodes.find(m => m.role === 'assistant');
if (!firstUserMessage || !firstAssistantMessage) {
return null;
}
const messages: OpenAIMessage[] = [
{
role: 'system',
content: systemPrompt,
},
{
role: 'user',
content: userPrompt(firstUserMessage!.content, firstAssistantMessage!.content),
},
];
let title = await createChatCompletion(messages as any, { temperature: 0.5, model: defaultModel, apiKey });
if (!title?.length) {
if (firstUserMessage.content.trim().length > 2 && firstUserMessage.content.trim().length < 250) {
return firstUserMessage.content.trim();
}
if (attempt === 0) {
return createTitle(chat, apiKey, 1);
}
}
// remove periods at the end of the title
title = title.replace(/(\w)\.$/g, '$1');
if (title.length > 250) {
title = title.substring(0, 250) + '...';
}
return title;
}

View File

@ -0,0 +1,234 @@
import { FormattedMessage } from "react-intl";
import { PluginDescription } from "../core/plugins/plugin-description";
import TTSPlugin from "../core/tts/tts-plugin";
import { Voice } from "../core/tts/types";
import { defaultElevenLabsVoiceID, defaultVoiceList } from "./elevenlabs-defaults";
import { backend } from "../core/backend";
function isProxySupported() {
return !!backend.current?.user?.services?.includes('elevenlabs');
}
function shouldUseProxy(apiKey: string | undefined | null) {
return !apiKey && isProxySupported();
}
function getEndpoint(proxied = false) {
return proxied ? '/chatapi/proxies/elevenlabs' : 'https://api.elevenlabs.io';
}
function getVoiceFromElevenlabsVoiceObject(v: any) {
return {
service: "elevenlabs",
id: v.voice_id,
name: v.name,
sampleAudioURL: v.preview_url,
};
}
export interface ElevenLabsPluginOptions {
apiKey: string | null;
voice: string;
customVoiceID: string | null;
}
/**
* Plugin for integrating with ElevenLabs Text-to-Speech service.
*
* If you want to add a plugin to support another cloud-based TTS service, this is a good example
* to use as a reference.
*/
export default class ElevenLabsPlugin extends TTSPlugin<ElevenLabsPluginOptions> {
static voices: Voice[] = defaultVoiceList.map(getVoiceFromElevenlabsVoiceObject);
private proxied = shouldUseProxy(this.options?.apiKey);
private endpoint = getEndpoint(this.proxied);
/**
* The `describe` function is responsible for providing a description of the ElevenLabsPlugin class,
* including its ID, name, and options.
*
* This information is used to configure the plugin and display its settings on the user interface.
*
* In this specific implementation, the `describe` function returns an object containing the plugin's
* ID ("elevenlabs"), name ("ElevenLabs Text-to-Speech"), and an array of options that can be
* configured by the user. These options include the API key, voice selection, and custom voice ID.
*
* Each option has its own set of properties, such as default values, display settings, and validation
* rules, which are used to render the plugin's settings on the user interface and ensure proper
* configuration.
*/
describe(): PluginDescription {
return {
id: "elevenlabs",
name: "ElevenLabs Text-to-Speech",
options: [
{
id: "apiKey",
defaultValue: null,
displayOnSettingsScreen: "speech",
displayAsSeparateSection: true,
resettable: false,
renderProps: (value, options, context) => ({
type: "password",
label: context.intl.formatMessage({ defaultMessage: "Your ElevenLabs API Key" }),
placeholder: context.intl.formatMessage({ defaultMessage: "Paste your API key here" }),
description: <>
<p>
<FormattedMessage
defaultMessage="Give ChatGPT a realisic human voice by connecting your ElevenLabs account (preview the available voices below). <a>Click here to sign up.</a>"
values={{
a: (chunks: any) => <a href="https://beta.elevenlabs.io" target="_blank" rel="noreferrer">{chunks}</a>
}} />
</p>
<p>
<FormattedMessage defaultMessage="You can find your API key by clicking your avatar or initials in the top right of the ElevenLabs website, then clicking Profile. Your API key is stored only on this device and never transmitted to anyone except ElevenLabs." />
</p>
</>,
hidden: options.getOption('tts', 'service') !== 'elevenlabs',
}),
},
{
id: "voice",
defaultValue: defaultElevenLabsVoiceID,
displayOnSettingsScreen: "speech",
displayAsSeparateSection: true,
renderProps: (value, options, context) => {
return {
type: "select",
label: "Voice",
disabled: !options.getOption('elevenlabs', 'apiKey') && !isProxySupported(),
hidden: options.getOption('tts', 'service') !== 'elevenlabs',
options: [
...ElevenLabsPlugin.voices.map(v => ({
label: v.name!,
value: v.id,
})),
{
label: context.intl.formatMessage({ defaultMessage: "Custom Voice ID" }),
value: 'custom',
}
],
};
},
},
{
id: "customVoiceID",
defaultValue: null,
displayOnSettingsScreen: "speech",
renderProps: (value, options, context) => {
return {
type: "text",
label: context.intl.formatMessage({ defaultMessage: "Custom Voice ID" }),
// hide when custom voice is not selected:
disabled: options.getOption('elevenlabs', 'voice') !== 'custom',
hidden: options.getOption('elevenlabs', 'voice') !== 'custom' || options.getOption('tts', 'service') !== 'elevenlabs',
};
},
validate: (value, options) => options.getOption('elevenlabs', 'voice') !== 'custom',
},
],
}
}
/**
* Initializes the plugin by fetching available voices.
*/
async initialize() {
await this.getVoices();
}
/**
* Fetches and returns the available voices from ElevenLabs API.
* This function stores the list of voices in a static variable, which is used elsewhere.
* @returns {Promise<Voice[]>} A promise that resolves to an array of Voice objects.
*/
async getVoices(): Promise<Voice[]> {
const response = await fetch(`${this.endpoint}/v1/voices`, {
headers: this.createHeaders(),
});
const json = await response.json();
if (json?.voices?.length) {
ElevenLabsPlugin.voices = json.voices.map(getVoiceFromElevenlabsVoiceObject);
}
return ElevenLabsPlugin.voices;
}
/**
* Returns the current voice based on the plugin options.
* @returns {Promise<Voice>} A promise that resolves to a Voice object.
*/
async getCurrentVoice(): Promise<Voice> {
let voiceID = this.options?.voice;
// If using a custom voice ID, construct a voice object with the provided voice ID
if (voiceID === 'custom' && this.options?.customVoiceID) {
return {
service: 'elevenlabs',
id: this.options.customVoiceID,
name: 'Custom Voice',
};
}
// Search for a matching voice object
const voice = ElevenLabsPlugin.voices.find(v => v.id === voiceID);
if (voice) {
return voice;
}
// If no matching voice is found, return a default Voice object
// with the defaultElevenLabsVoiceID and 'elevenlabs' as the service
return {
service: 'elevenlabs',
id: defaultElevenLabsVoiceID,
};
}
/**
* Converts the given text into speech using the specified voice and returns an audio file as a buffer.
* @param {string} text The text to be converted to speech.
* @param {Voice} [voice] The voice to be used for text-to-speech conversion. If not provided, the current voice will be used.
* @returns {Promise<ArrayBuffer | null>} A promise that resolves to an ArrayBuffer containing the audio data, or null if the conversion fails.
*/
async speakToBuffer(text: string, voice?: Voice): Promise<ArrayBuffer | null> {
if (!voice) {
voice = await this.getCurrentVoice();
}
const url = this.endpoint + '/v1/text-to-speech/' + voice.id;
const response = await fetch(url, {
headers: this.createHeaders(),
method: 'POST',
body: JSON.stringify({
text,
}),
});
if (response.ok) {
return await response.arrayBuffer();
} else {
return null;
}
}
/**
* Creates and returns the headers required for ElevenLabs API requests.
*/
private createHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (!this.proxied && this.options?.apiKey) {
headers['xi-api-key'] = this.options.apiKey;
}
return headers;
}
}

View File

@ -0,0 +1,123 @@
import { Voice } from "../core/tts/types";
import DirectTTSPlugin from "../core/tts/direct-tts-plugin";
import { PluginDescription } from "../core/plugins/plugin-description";
export interface WebSpeechPluginOptions {
voice: string | null;
}
/**
* Plugin for integrating with the built-in Text-to-Speech service on the user's device via
* the Web Speech Synthesis API.
*
* If you want to add a plugin to support a cloud-based TTS service, this class is probably
* not relevant. Consider using ElevenLabsPlugin as an example instead.
*/
export default class WebSpeechPlugin extends DirectTTSPlugin<WebSpeechPluginOptions> {
static voices: Voice[] = [];
private rejections: any[] = [];
private speaking = 0;
async initialize() {
await this.getVoices();
speechSynthesis.onvoiceschanged = () => this.getVoices();
}
describe(): PluginDescription {
const id = "web-speech";
return {
id,
name: "Your Browser's Built-In Text-to-Speech",
options: [
{
id: "voice",
defaultValue: null,
displayOnSettingsScreen: "speech",
displayAsSeparateSection: true,
renderProps: (value, options) => ({
type: "select",
label: "Voice",
options: WebSpeechPlugin.voices.map(v => ({
label: v.name!,
value: v.id,
})),
hidden: options.getOption('tts', 'service') !== id,
}),
},
],
}
}
async getVoices() {
WebSpeechPlugin.voices = window.speechSynthesis.getVoices().map(v => ({
service: 'web-speech',
id: v.name,
name: v.name,
}));
return WebSpeechPlugin.voices;
}
async getCurrentVoice(): Promise<Voice> {
let voiceID = this.options?.voice;
const voice = WebSpeechPlugin.voices.find(v => v.id === voiceID);
if (voice) {
return voice;
}
return WebSpeechPlugin.voices[0];
}
speak(text: string, voice?: Voice) {
return new Promise<void>(async (resolve, reject) => {
// this.stop();
this.rejections.push(reject);
if (!voice) {
voice = await this.getCurrentVoice();
}
const utterance = new SpeechSynthesisUtterance(text);
utterance.voice = window.speechSynthesis.getVoices().find(v => v.name === voice!.id)!;
utterance.onstart = () => {
this.speaking++;
};
utterance.onend = () => {
this.speaking--;
resolve();
}
speechSynthesis.speak(utterance);
});
}
async pause() {
if (!speechSynthesis.paused) {
speechSynthesis.pause();
}
}
async resume() {
if (speechSynthesis.paused) {
speechSynthesis.resume();
}
}
async stop() {
speechSynthesis.cancel();
this.speaking = 0;
for (const reject of this.rejections) {
reject('cancelled');
}
this.rejections = [];
}
async isSpeaking() {
return this.speaking > 0;
}
}

View File

@ -1,285 +0,0 @@
import { Button } from "@mantine/core";
import EventEmitter from "events";
import { useCallback, useEffect, useRef, useState } from "react";
import { split } from 'sentence-splitter';
import { cloneArrayBuffer, md5, sleep } from "../utils";
import * as idb from '../idb';
import { useAppDispatch, useAppSelector } from "../store";
import { selectElevenLabsApiKey } from "../store/api-keys";
import { selectVoice } from "../store/voices";
import { openElevenLabsApiKeyPanel } from "../store/settings-ui";
import { defaultElevenLabsVoiceID } from "./defaults";
import { FormattedMessage, useIntl } from "react-intl";
const endpoint = 'https://api.elevenlabs.io';
let currentReader: ElevenLabsReader | null = null;
const cache = new Map<string, ArrayBuffer>();
export function createHeaders(apiKey = localStorage.getItem('elevenlabs-api-key') || '') {
return {
'xi-api-key': apiKey,
'content-type': 'application/json',
};
}
export async function getVoices() {
const response = await fetch(`${endpoint}/v1/voices`, {
headers: createHeaders(),
});
const json = await response.json();
return json;
}
const audioContext = new AudioContext();
export default class ElevenLabsReader extends EventEmitter {
private apiKey: string;
private initialized = false;
private cancelled = false;
private textSegments: string[] = [];
private currentTrack: number = -1;
private nextTrack: number = 0;
private audios: (AudioBuffer | null)[] = [];
private element: HTMLElement | undefined | null;
private voiceID = defaultElevenLabsVoiceID;
currentSource: AudioBufferSourceNode | undefined;
constructor() {
super();
this.apiKey = localStorage.getItem('elevenlabs-api-key') || '';
}
private async createAudio() {
if (this.initialized) {
return;
}
this.initialized = true;
const chunkSize = 3;
for (let i = 0; i < this.textSegments.length && !this.cancelled; i += chunkSize) {
const chunk = this.textSegments.slice(i, i + chunkSize);
await Promise.all(chunk.map((_, index) => this.createAudioForTextSegment(i + index)));
}
}
private async createAudioForTextSegment(index: number) {
if (this.audios[index] || this.cancelled) {
return;
}
const hash = await md5(this.textSegments[index]);
const cacheKey = `audio:${this.voiceID}:${hash}`;
let buffer = cache.get(cacheKey);
if (!buffer) {
buffer = await idb.get(cacheKey);
}
if (!buffer) {
const url = endpoint + '/v1/text-to-speech/' + this.voiceID;
const maxAttempts = 3;
for (let i = 0; i < maxAttempts && !this.cancelled; i++) {
try {
const response = await fetch(url, {
headers: createHeaders(this.apiKey),
method: 'POST',
body: JSON.stringify({
text: this.textSegments[index],
}),
});
if (response.ok) {
buffer = await response.arrayBuffer();
cache.set(cacheKey, cloneArrayBuffer(buffer));
idb.set(cacheKey, cloneArrayBuffer(buffer));
break;
}
} catch (e) {
console.error(e);
}
await sleep(2000 + i * 5000); // increasing backoff time
}
}
if (buffer) {
const data = await audioContext.decodeAudioData(buffer);
this.audios[index] = data;
}
}
private async waitForAudio(index: number, timeoutSeconds = 30) {
if (!this.initialized) {
this.createAudio().then(() => { });
}
const timeoutAt = Date.now() + timeoutSeconds * 1000;
while (Date.now() < timeoutAt && !this.cancelled) {
if (this.audios[index]) {
return;
}
this.emit('buffering');
await sleep(100);
}
this.cancelled = true;
this.emit('error', new Error('Timed out waiting for audio'));
}
public async play(element: HTMLElement, voiceID: string = defaultElevenLabsVoiceID, apiKey = this.apiKey) {
this.element = element;
this.voiceID = voiceID;
this.apiKey = apiKey;
if (!this.element || !this.voiceID) {
return;
}
this.emit('init');
if (currentReader != null) {
await currentReader.stop();
}
currentReader = this;
this.cancelled = false;
if (!this.textSegments?.length) {
this.textSegments = this.extractTextSegments();
}
await this.next(true);
}
private async next(play = false) {
if (this.cancelled) {
return;
}
if (!play && this.nextTrack === 0) {
this.emit('done');
return;
}
const currentTrack = this.nextTrack;
this.currentTrack = currentTrack;
const nextTrack = (this.nextTrack + 1) % this.textSegments.length;
this.nextTrack = nextTrack;
await this.waitForAudio(currentTrack);
if (this.cancelled) {
return;
}
this.emit('playing');
try {
this.currentSource = audioContext.createBufferSource();
this.currentSource.buffer = this.audios[currentTrack];
this.currentSource.connect(audioContext.destination);
this.currentSource.onended = () => {
this.next();
};
this.currentSource.start();
} catch (e) {
console.error('failed to play', e);
this.emit('done');
}
}
public stop() {
if (this.currentSource) {
this.currentSource.stop();
}
this.audios = [];
this.textSegments = [];
this.nextTrack = 0;
this.cancelled = true;
this.initialized = false;
this.emit('done');
}
private extractTextSegments() {
const selector = 'p, li, th, td, blockquote, pre code, h1, h2, h3, h3, h5, h6';
const nodes = Array.from(this.element?.querySelectorAll(selector) || []);
const lines: string[] = [];
const blocks = nodes.filter(node => !node.parentElement?.closest(selector) && node.textContent);
for (const block of blocks) {
const tagName = block.tagName.toLowerCase();
if (tagName === 'p' || tagName === 'li' || tagName === 'blockquote') {
const sentences = split(block.textContent!);
for (const sentence of sentences) {
lines.push(sentence.raw.trim());
}
} else {
lines.push(block.textContent!.trim());
}
}
return lines.filter(line => line.length);
}
}
export function ElevenLabsReaderButton(props: { selector: string }) {
const elevenLabsApiKey = useAppSelector(selectElevenLabsApiKey);
const dispatch = useAppDispatch();
const intl = useIntl();
const voice = useAppSelector(selectVoice);
const [status, setStatus] = useState<'idle' | 'init' | 'playing' | 'buffering'>('idle');
// const [error, setError] = useState(false);
const reader = useRef(new ElevenLabsReader());
useEffect(() => {
const currentReader = reader.current;
currentReader.on('init', () => setStatus('init'));
currentReader.on('playing', () => setStatus('playing'));
currentReader.on('buffering', () => setStatus('buffering'));
currentReader.on('error', () => {
setStatus('idle');
// setError(true);
});
currentReader.on('done', () => setStatus('idle'));
return () => {
currentReader.removeAllListeners();
currentReader.stop();
};
}, [props.selector]);
const onClick = useCallback(() => {
if (status === 'idle') {
if (!elevenLabsApiKey?.length) {
dispatch(openElevenLabsApiKeyPanel());
return;
}
audioContext.resume();
reader.current.play(document.querySelector(props.selector)!, voice, elevenLabsApiKey);
} else {
reader.current.stop();
}
}, [dispatch, status, props.selector, elevenLabsApiKey, voice]);
return (
<Button variant="subtle" size="sm" compact onClickCapture={onClick} loading={status === 'init'}>
{status !== 'init' && <i className="fa fa-headphones" />}
{status === 'idle' && <span>
<FormattedMessage defaultMessage="Play" description="Label for the button that starts text-to-speech playback" />
</span>}
{status === 'buffering' && <span>
<FormattedMessage defaultMessage="Loading audio..." description="Message indicating that text-to-speech audio is buffering" />
</span>}
{status !== 'idle' && status !== 'buffering' && <span>
<FormattedMessage defaultMessage="Stop" description="Label for the button that stops text-to-speech playback" />
</span>}
</Button>
);
}

View File

@ -1,61 +0,0 @@
import * as hashes from 'jshashes';
const hasher = new hashes.MD5();
const hashCache = new Map<string, string>();
export async function md5(data: string): Promise<string> {
if (!hashCache.has(data)) {
const hashHex = hasher.hex(data);
hashCache.set(data, hashHex);
}
return hashCache.get(data)!;
}
export function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export async function share(text: string) {
if (navigator.share) {
await navigator.share({
text,
});
}
}
export function ellipsize(text: string, maxLength: number) {
if (text.length > maxLength) {
return text.substring(0, maxLength) + '...';
}
return text;
}
export function cloneArrayBuffer(buffer) {
const newBuffer = new ArrayBuffer(buffer.byteLength);
new Uint8Array(newBuffer).set(new Uint8Array(buffer));
return newBuffer;
}
export class AsyncLoop {
public cancelled = false;
constructor(private handler: any, private pauseBetween: number = 1000) {
}
public async start() {
this.loop().then(() => {});
}
private async loop() {
while (!this.cancelled) {
try {
await this.handler();
} catch (e) {
console.error(e);
}
await sleep(this.pauseBetween);
}
}
}

View File

@ -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."

View File

@ -1,6 +1,6 @@
{ {
"name": "chat-with-gpt", "name": "chat-with-gpt",
"version": "0.2.1", "version": "0.2.3",
"description": "An open-source ChatGPT app with a voice", "description": "An open-source ChatGPT app with a voice",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@ -10,6 +10,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.282.0", "@aws-sdk/client-s3": "^3.282.0",
"@msgpack/msgpack": "^3.0.0-beta2",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/compression": "^1.7.2", "@types/compression": "^1.7.2",
"@types/connect-sqlite3": "^0.9.2", "@types/connect-sqlite3": "^0.9.2",
@ -42,7 +43,9 @@
"idb-keyval": "^6.2.0", "idb-keyval": "^6.2.0",
"jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.0",
"jwks-rsa": "^3.0.1", "jwks-rsa": "^3.0.1",
"knex": "^2.4.2",
"launchdarkly-eventsource": "^1.4.4", "launchdarkly-eventsource": "^1.4.4",
"lib0": "^0.2.73",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"match-sorter": "^6.3.1", "match-sorter": "^6.3.1",
"nanoid": "^4.0.1", "nanoid": "^4.0.1",
@ -50,10 +53,12 @@
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"pg": "^8.9.0", "pg": "^8.9.0",
"react-router-dom": "^6.8.2",
"sort-by": "^0.0.2", "sort-by": "^0.0.2",
"sqlite3": "^5.1.4", "sqlite3": "^5.1.4",
"ts-node": "^10.9.1", "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"
} }
} }

View File

@ -1,16 +1,14 @@
import crypto from 'crypto';
import { auth, ConfigParams } from 'express-openid-connect'; import { auth, ConfigParams } from 'express-openid-connect';
import ChatServer from './index'; import ChatServer from './index';
import { config } from './config';
const secret = process.env.AUTH_SECRET || crypto.randomBytes(32).toString('hex'); const auth0Config: ConfigParams = {
const config: ConfigParams = {
authRequired: false, authRequired: false,
auth0Logout: false, auth0Logout: false,
secret, secret: config.authSecret,
baseURL: process.env.PUBLIC_URL, baseURL: config.publicSiteURL,
clientID: process.env.AUTH0_CLIENT_ID, clientID: config.auth0?.clientID,
issuerBaseURL: process.env.AUTH0_ISSUER, issuerBaseURL: config.auth0?.issuer,
routes: { routes: {
login: false, login: false,
logout: false, logout: false,
@ -18,26 +16,36 @@ const config: ConfigParams = {
}; };
export function configureAuth0(context: ChatServer) { 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) => { context.app.get('/chatapi/login', (req, res) => {
res.oidc.login({ res.oidc.login({
returnTo: process.env.PUBLIC_URL, returnTo: config.publicSiteURL,
authorizationParams: { authorizationParams: {
redirect_uri: process.env.PUBLIC_URL + '/chatapi/login-callback', redirect_uri: config.publicSiteURL + '/chatapi/login-callback',
}, },
}); });
}); });
context.app.get('/chatapi/logout', (req, res) => { context.app.get('/chatapi/logout', (req, res) => {
res.oidc.logout({ res.oidc.logout({
returnTo: process.env.PUBLIC_URL, returnTo: config.publicSiteURL,
}); });
}); });
context.app.all('/chatapi/login-callback', (req, res) => { context.app.all('/chatapi/login-callback', (req, res) => {
res.oidc.callback({ res.oidc.callback({
redirectUri: process.env.PUBLIC_URL!, redirectUri: config.publicSiteURL!,
}) });
}); });
} }

View File

@ -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
};

View File

@ -1,3 +1,11 @@
import ExpiryMap from "expiry-map";
// @ts-ignore
import type { Doc } from "yjs";
// const documents = new ExpiryMap<string, Doc>(60 * 60 * 1000);
const documents = new ExpiryMap<string, Doc>(48 * 60 * 60 * 1000);
export default abstract class Database { export default abstract class Database {
public async initialize() {} public async initialize() {}
public abstract createUser(email: string, passwordHash: Buffer): Promise<void>; public abstract createUser(email: string, passwordHash: Buffer): Promise<void>;
@ -14,4 +22,17 @@ export default abstract class Database {
public abstract setTitle(userID: string, chatID: string, title: string): Promise<void>; public abstract setTitle(userID: string, chatID: string, title: string): Promise<void>;
public abstract deleteChat(userID: string, chatID: string): Promise<any>; public abstract deleteChat(userID: string, chatID: string): Promise<any>;
public abstract getDeletedChatIDs(userID: string): Promise<string[]>; public abstract getDeletedChatIDs(userID: string): Promise<string[]>;
protected abstract loadYDoc(userID: string): Promise<Doc>;
public abstract saveYUpdate(userID: string, update: Uint8Array): Promise<void>;
public async getYDoc(userID: string): Promise<Doc> {
const doc = documents.get(userID);
if (doc) {
return doc;
}
const newDoc = await this.loadYDoc(userID);
documents.set(userID, newDoc);
return newDoc;
}
} }

View File

@ -0,0 +1,204 @@
import { validate as validateEmailAddress } from 'email-validator';
import { Knex, knex as KnexClient } from 'knex';
import Database from "./index";
import { config } from '../config';
const tableNames = {
authentication: 'authentication',
chats: 'chats',
deletedChats: 'deleted_chats',
messages: 'messages',
shares: 'shares',
yjsUpdates: 'updates',
};
export default class KnexDatabaseAdapter extends Database {
private knex = KnexClient(this.knexConfig);
constructor(private knexConfig: Knex.Config = config.database) {
super();
}
public async initialize() {
await this.createTables();
}
private async createTables() {
await this.createTableIfNotExists(tableNames.authentication, (table) => {
table.text('id').primary();
table.text('email');
table.binary('password_hash');
table.binary('salt');
});
await this.createTableIfNotExists(tableNames.chats, (table) => {
table.text('id').primary();
table.text('user_id');
table.text('title');
});
await this.createTableIfNotExists(tableNames.deletedChats, (table) => {
table.text('id').primary();
table.text('user_id');
table.dateTime('deleted_at');
});
await this.createTableIfNotExists(tableNames.messages, (table) => {
table.text('id').primary();
table.text('user_id');
table.text('chat_id');
table.text('data');
});
await this.createTableIfNotExists(tableNames.shares, (table) => {
table.text('id').primary();
table.text('user_id');
table.dateTime('created_at');
});
await this.createTableIfNotExists(tableNames.yjsUpdates, (table) => {
table.increments('id').primary();
table.text('user_id');
table.binary('update');
table.index('user_id');
});
}
private async createTableIfNotExists(tableName: string, tableBuilderCallback: (tableBuilder: Knex.CreateTableBuilder) => any) {
const exists = await this.knex.schema.hasTable(tableName);
if (!exists) {
await this.knex.schema.createTable(tableName, tableBuilderCallback);
}
}
public async createUser(email: string, passwordHash: Buffer): Promise<void> {
if (!validateEmailAddress(email)) {
throw new Error('invalid email address');
}
await this.knex(tableNames.authentication).insert({
id: email,
email,
password_hash: passwordHash,
});
}
public async getUser(email: string): Promise<any> {
const row = await this.knex(tableNames.authentication)
.where('email', email)
.first();
if (!row) {
return null;
}
return {
...row,
passwordHash: Buffer.from(row.password_hash),
salt: row.salt ? Buffer.from(row.salt) : null,
};
}
public async getChats(userID: string): Promise<any[]> {
return await this.knex(tableNames.chats)
.where('user_id', userID).select();
}
public async getMessages(userID: string): Promise<any[]> {
const rows = await this.knex(tableNames.messages)
.where('user_id', userID).select();
return rows.map((row: any) => {
// row.data = JSON.parse(row.data);
return row;
});
}
public async insertMessages(userID: string, messages: any[]): Promise<void> {
// deprecated
}
public async createShare(userID: string | null, id: string): Promise<boolean> {
await this.knex(tableNames.shares)
.insert({
id,
user_id: userID,
created_at: new Date(),
});
return true;
}
public async setTitle(userID: string, chatID: string, title: string): Promise<void> {
// deprecated
}
public async deleteChat(userID: string, chatID: string): Promise<any> {
await this.knex.transaction(async (trx) => {
await trx(tableNames.chats).where({ id: chatID, user_id: userID }).delete();
await trx(tableNames.messages).where({ chat_id: chatID, user_id: userID }).delete();
await trx(tableNames.deletedChats)
.insert({ id: chatID, user_id: userID, deleted_at: new Date() });
});
}
public async getDeletedChatIDs(userID: string): Promise<string[]> {
const rows = await this.knex(tableNames.deletedChats)
.where('user_id', userID)
.select();
return rows.map((row: any) => row.id);
}
protected async loadYDoc(userID: string) {
const Y = await import('yjs');
const ydoc = new Y.Doc();
const updates = await this.knex(tableNames.yjsUpdates)
.where('user_id', userID)
.select();
updates.forEach((updateRow: any) => {
try {
const update = new Uint8Array(updateRow.update);
if (update.byteLength > 4) {
Y.applyUpdate(ydoc, update);
}
} catch (e) {
console.error('failed to apply update', updateRow, e);
}
});
const merged = Y.encodeStateAsUpdate(ydoc);
if (updates.length) {
// In a transaction, insert the merged update, then delete all previous updates (lower ID).
// This needs to be done together in a transaction to avoid consistency errors or data loss!
await this.knex.transaction(async (trx) => {
await trx(tableNames.yjsUpdates)
.insert({
user_id: userID,
update: Buffer.from(merged),
});
await trx(tableNames.yjsUpdates)
.where('user_id', userID)
.where('id', '<', updates[updates.length - 1].id)
.delete();
});
}
return ydoc;
}
public async saveYUpdate(userID: string, update: Uint8Array): Promise<void> {
if (update.byteLength <= 4) {
return;
}
await this.knex(tableNames.yjsUpdates)
.insert({
user_id: userID,
update: Buffer.from(update),
});
}
}

View File

@ -1,202 +0,0 @@
import fs from 'fs';
import { verbose } from "sqlite3";
import { validate as validateEmailAddress } from 'email-validator';
import Database from "./index";
const sqlite3 = verbose();
if (!fs.existsSync('./data')) {
fs.mkdirSync('./data');
}
const db = new sqlite3.Database('./data/chat.sqlite');
// interface ChatRow {
// id: string;
// user_id: string;
// title: string;
// }
// interface MessageRow {
// id: string;
// user_id: string;
// chat_id: string;
// data: any;
// }
// interface ShareRow {
// id: string;
// user_id: string;
// created_at: Date;
// }
export class SQLiteAdapter extends Database {
public async initialize() {
db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS authentication (
id TEXT PRIMARY KEY,
email TEXT,
password_hash BLOB,
salt BLOB
)`);
db.run(`CREATE TABLE IF NOT EXISTS chats (
id TEXT PRIMARY KEY,
user_id TEXT,
title TEXT
)`);
db.run(`CREATE TABLE IF NOT EXISTS deleted_chats (
id TEXT PRIMARY KEY,
user_id TEXT,
deleted_at DATETIME
)`);
db.run(`CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
user_id TEXT,
chat_id TEXT,
data TEXT
)`);
db.run(`CREATE TABLE IF NOT EXISTS shares (
id TEXT PRIMARY KEY,
user_id TEXT,
created_at DATETIME
)`);
});
}
public createUser(email: string, passwordHash: Buffer): Promise<void> {
return new Promise((resolve, reject) => {
if (!validateEmailAddress(email)) {
reject(new Error('invalid email address'));
return;
}
db.run(`INSERT INTO authentication (id, email, password_hash) VALUES (?, ?, ?)`, [email, email, passwordHash], (err) => {
if (err) {
reject(err);
console.log(`[database:sqlite] failed to create user ${email}`);
} else {
resolve();
console.log(`[database:sqlite] created user ${email}`);
}
});
});
}
public async getUser(email: string): Promise<any> {
return new Promise((resolve, reject) => {
db.get(`SELECT * FROM authentication WHERE email = ?`, [email], (err: any, row: any) => {
if (err) {
reject(err);
console.log(`[database:sqlite] failed to get user ${email}`);
} else if (!row) {
resolve(null);
} else {
resolve({
...row,
passwordHash: Buffer.from(row.password_hash),
salt: row.salt ? Buffer.from(row.salt) : null,
});
console.log(`[database:sqlite] retrieved user ${email}`);
}
});
});
}
public async getChats(userID: string): Promise<any[]> {
return new Promise((resolve, reject) => {
db.all(`SELECT * FROM chats WHERE user_id = ?`, [userID], (err: any, rows: any) => {
if (err) {
reject(err);
console.log(`[database:sqlite] failed to get chats for user ${userID}`);
} else {
resolve(rows);
console.log(`[database:sqlite] retrieved ${rows.length} chats for user ${userID}`);
}
});
});
}
public async getMessages(userID: string): Promise<any[]> {
return new Promise((resolve, reject) => {
db.all(`SELECT * FROM messages WHERE user_id = ?`, [userID], (err: any, rows: any) => {
if (err) {
reject(err);
console.log(`[database:sqlite] failed to get messages for user ${userID}`);
} else {
resolve(rows.map((row: any) => {
row.data = JSON.parse(row.data);
return row;
}));
console.log(`[database:sqlite] retrieved ${rows.length} messages for user ${userID}`);
}
});
});
}
public async insertMessages(userID: string, messages: any[]): Promise<void> {
return new Promise((resolve, reject) => {
db.serialize(() => {
const stmt = db.prepare(`INSERT OR IGNORE INTO messages (id, user_id, chat_id, data) VALUES (?, ?, ?, ?)`);
messages.forEach((message) => {
stmt.run(message.id, userID, message.chatID, JSON.stringify(message));
});
stmt.finalize();
console.log(`[database:sqlite] inserted ${messages.length} messages`);
resolve();
});
});
}
public async createShare(userID: string|null, id: string): Promise<boolean> {
return new Promise((resolve, reject) => {
db.run(`INSERT INTO shares (id, user_id, created_at) VALUES (?, ?, ?)`, [id, userID, new Date()], (err) => {
if (err) {
reject(err);
console.log(`[database:sqlite] failed to create share ${id}`);
} else {
resolve(true);
console.log(`[database:sqlite] created share ${id}`)
}
});
});
}
public async setTitle(userID: string, chatID: string, title: string): Promise<void> {
return new Promise((resolve, reject) => {
db.run(`INSERT OR IGNORE INTO chats (id, user_id, title) VALUES (?, ?, ?)`, [chatID, userID, title], (err) => {
if (err) {
reject(err);
console.log(`[database:sqlite] failed to set title for chat ${chatID}`);
} else {
resolve();
console.log(`[database:sqlite] set title for chat ${chatID}`)
}
});
});
}
public async deleteChat(userID: string, chatID: string): Promise<any> {
db.serialize(() => {
db.run(`DELETE FROM chats WHERE id = ? AND user_id = ?`, [chatID, userID]);
db.run(`DELETE FROM messages WHERE chat_id = ? AND user_id = ?`, [chatID, userID]);
db.run(`INSERT INTO deleted_chats (id, user_id, deleted_at) VALUES (?, ?, ?)`, [chatID, userID, new Date()]);
console.log(`[database:sqlite] deleted chat ${chatID}`);
});
}
public async getDeletedChatIDs(userID: string): Promise<string[]> {
return new Promise((resolve, reject) => {
db.all(`SELECT * FROM deleted_chats WHERE user_id = ?`, [userID], (err: any, rows: any) => {
if (err) {
reject(err);
} else {
resolve(rows.map((row: any) => row.id));
}
});
});
}
}

Some files were not shown because too many files have changed in this diff Show More