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'; import { selectMessagesToSendSafely } from './tokenizer'; export const channel = new BroadcastChannel('chats'); export class ChatManager extends EventEmitter { public chats = new Map(); public search = new Search(this.chats); private loaded = false; private changed = false; private activeReplies = new Map(); constructor() { super(); this.load(); this.on('update', () => { this.changed = true; }); channel.onmessage = (message: { type: 'chat-update', data: string, }) => { const chat = deserializeChat(message.data); const id = chat.id; this.chats.set(id, chat); this.emit(id); }; (async () => { while (true) { await sleep(100); if (this.loaded && this.changed) { this.changed = false; await this.save(); } } })(); } public async createChat(): Promise { const id = uuidv4(); const chat: Chat = { id, messages: new MessageTree(), created: Date.now(), updated: Date.now(), }; this.chats.set(id, chat); this.search.update(chat); channel.postMessage({ type: 'chat-update', data: serializeChat(chat) }); return id; } public async sendMessage(message: UserSubmittedMessage) { const chat = this.chats.get(message.chatID); if (!chat) { throw new Error('Chat not found'); } const newMessage: Message = { id: uuidv4(), parentID: message.parentID, chatID: chat.id, timestamp: Date.now(), role: 'user', content: message.content, done: true, }; 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) { 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) { throw new Error('Chat not found'); } const reply: Message = { id: uuidv4(), parentID: latestMessage.id, chatID: latestMessage.chatID, timestamp: Date.now(), role: 'assistant', 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 = selectMessagesToSendSafely(messages.map(getOpenAIMessageFromMessage)); const { emitter, cancel } = await createStreamingChatCompletion(messagesToSend, requestedParameters); let lastChunkReceivedAt = Date.now(); const onError = () => { if (reply.done) { return; } clearInterval(timer); cancel(); reply.content += "\n\nI'm sorry, I'm having trouble connecting to OpenAI. Please make sure you've entered your OpenAI API key correctly and try again."; reply.content = reply.content.trim(); reply.done = true; 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 > 10000 && !reply.done) { onError(); } }, 2000); emitter.on('error', () => { if (!reply.content && !reply.done) { lastChunkReceivedAt = Date.now(); onError(); } }); emitter.on('data', (data: string) => { if (reply.done) { cancel(); 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) { const messages = new MessageTree(); for (const m of chat.messages) { messages.addMessage(m); } chat.messages = messages; this.loadChat(chat); } this.emit('update'); } this.loaded = true; } public loadChat(chat: Chat) { if (!chat?.id) { return; } const existing = this.chats.get(chat.id); 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); } } export class Search { private index = new MiniSearch({ fields: ['value'], storeFields: ['id', 'value'], }); constructor(private chats: Map) { } public update(chat: Chat) { const messages = chat.messages.serialize() .map((m: Message) => m.content) .join('\n\n'); const doc = { id: chat.id, value: chat.title + '\n\n' + messages, }; if (!this.index.has(chat.id)) { this.index.add(doc); } else { this.index.replace(doc); } } public query(query: string) { if (!query?.trim().length) { const searchResults = Array.from(this.chats.values()) .sort((a, b) => b.updated - a.updated) .slice(0, 10); const results = this.processSearchResults(searchResults); return results; } let searchResults = this.index.search(query, { fuzzy: 0.2 }); let output = this.processSearchResults(searchResults); if (!output.length) { searchResults = this.index.search(query, { prefix: true }); output = this.processSearchResults(searchResults); } return output; } private processSearchResults(searchResults: SearchResult[] | Chat[]) { const output: any[] = []; for (const item of searchResults) { const chatID = item.id; 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;