chat-with-gpt/app/src/core/backend.ts

279 lines
8.3 KiB
TypeScript

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;
public services: string[] = [];
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,
};
this.services = session.services || [];
} else {
this.user = null;
this.services = session?.services || [];
}
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();
}
}