diff --git a/config.example.json b/config.example.json index 196cea9..2646613 100644 --- a/config.example.json +++ b/config.example.json @@ -3,5 +3,7 @@ "port": 6697, "nick": "chatgpt", "channels": ["#chatgpt"], - "openaiApiKey": "[redacted]" + "openaiApiKey": "[redacted]", + "initialSystemMessage": "You are a helpful assistant.", + "alwaysRemember": false } \ No newline at end of file diff --git a/index.js b/index.js index 44fc318..d211187 100644 --- a/index.js +++ b/index.js @@ -4,12 +4,17 @@ const irc = require('irc'); const axios = require('axios'); const config = require('./config.json'); +const seed_messages = [{role: 'system', content: config.initialSystemMessage}]; + // context is a list of strings that are used to seed the chatgpt api and it's responses class Context { - messages = []; - currentLine = ''; + currentResponse = ''; + constructor(messages = seed_messages) { + this.messages = messages; + } + add_user_prompt(message) { this.messages.push({ role: 'user', content: message }); } @@ -18,41 +23,41 @@ class Context { this.messages.push({ role: 'assistant', content: message }); } - append_to_line(message) { - this.currentLine += message; - } - - end_line() { - const the_line = this.currentLine; - this.currentResponse += `${the_line}\n\n`; - this.currentLine = ''; - return the_line; + end_line(line) { + this.currentResponse += `${line}\n\n`; + return line; } finish_current_response() { this.add_assistant_message(this.currentResponse); - const theLine = this.currentLine; + const theLine = this.currentLine; this.currentResponse = ''; - this.currentLine = ''; + this.save_history(); return theLine; } - is_response_in_progress() { - return this.currentResponse !== '' || this.currentLine !== ''; + save_history() { + const prettyData = JSON.stringify(this.messages, null, 2); + fs.writeFileSync('./messages.json', prettyData); } - peek_line() { - return this.currentLine; + is_response_in_progress() { + return this.currentResponse !== ''; } clear() { this.messages = []; this.currentResponse = ''; - this.currentLine = ''; } } -const context = new Context(); +let context = new Context(savedMessages); +if (fs.existsSync('./messages.json')) { + savedMessages = require('./messages.json'); + context = new Context(savedMessages); +} else { + context = new Context(seed_messages); +} const client = new irc.Client(config.server, config.nick, { channels: config.channels, @@ -60,101 +65,114 @@ const client = new irc.Client(config.server, config.nick, { // listen for messages that start with !chat and call the chatgpt api with a callback that prints the response line by line client.addListener('message', async (from, to, message) => { - is_chat_cmd = message.startsWith('!chat'); - is_cont_cmd = message.startsWith('!cont'); + let is_chat_cmd = message.startsWith('!chat'); + let is_cont_cmd = message.startsWith('!cont'); + if (is_chat_cmd || is_cont_cmd) { - if(context.is_response_in_progress()) { return; } - if(is_chat_cmd) { + if (context.is_response_in_progress()) { + message(`(chat from ${from} ignored, response in progress)`) + return; + } + if(is_chat_cmd && !config.alwaysRemember) { context.clear(); } + context.add_user_prompt(query); const query = message.slice(6); - chatgpt(query, (line) => { - client.say(to, line); - }); + try { + await chatgpt(query, context.messages, handleChatGPTResponseLine); + context.finish_current_response(); + } catch (e) { + console.log(e); + client.say(to, 'Error: ' + e); + } + } + + function handleChatGPTResponseLine(line) { + context.end_line(line); + client.say(to, line); } }); // function that calls the chatgpt streaming api (with server send events) and calls the callback function for each line -async function chatgpt(query, callback) { - // a very primitive mutex to prevent multiple calls to the api at once - if(context.is_response_in_progress()) { return; } - context.add_user_prompt(query); +function chatgpt(query, messages, callback) { const apiUrl = 'https://api.openai.com/v1/chat/completions'; - let response = null; - try { - response = await axios.post(apiUrl, { - messages: context.messages, - model: 'gpt-3.5-turbo', - stream: true, - }, { - headers: { - Authorization: `Bearer ${config.openaiApiKey}`, - 'Content-Type': 'application/json', - }, - responseType: 'stream', - }); - } catch(error) { - if (error.response) { - // The request was made and the server responded with a status code - // that falls out of the range of 2xx - console.log(error.toJSON()); - } else if (error.request) { - // The request was made but no response was received - // `error.request` is an instance of XMLHttpRequest in the browser and an instance of - // http.ClientRequest in node.js - console.log(error.request); - } else { - // Something happened in setting up the request that triggered an Error - console.log('Error', error.message); - } - return; - } + let currentLine = ''; - response.data.on('data', (event) => { - let data = event.toString(); - let parts = data.split('\n'); - // parse if starts with data: - for(part of parts) { - console.log(part); - if(part === 'data: [DONE]') { - callback(context.finish_current_response()); - } else if(part.startsWith('data: ')) { - let jsonString = part.slice(part.indexOf('{'), part.lastIndexOf('}') + 1); - try { - let json = JSON.parse(jsonString); - let chunk = json.choices[0].delta.content; - if (!chunk) { - continue; - } - //split the chunk into lines leaving the delimiter in the array - const lines = chunk.split(/\r?\n/); // split by new lines - - let hasStartNewline = chunk.startsWith("\n"); - let hasEndNewline = chunk.endsWith("\n"); - - if(hasStartNewline) { - callback(context.end_line()) - } - - for (let i = 0; i < lines.length - 1; i++) { - context.append_to_line(lines[i]); - callback(context.end_line()); - } - - context.append_to_line(lines[lines.length - 1]); - - if(hasEndNewline) { - callback(context.end_line()); - } - - if (context.peek_line().length > 400) { - callback(context.end_line()); - } - } catch (e) { - console.log(e); + return new Promise((resolve, reject) => { + axios.post(apiUrl, { + messages: messages, + model: 'gpt-3.5-turbo', + stream: true, + }, { + headers: { + Authorization: `Bearer ${config.openaiApiKey}`, + 'Content-Type': 'application/json', + }, + responseType: 'stream', + }).then(response => { + response.data.on('data', (event) => { + let data = event.toString(); + let parts = data.split('\n'); + // parse if starts with data: + for (let part of parts) { console.log(part); + + if (part === 'data: [DONE]') { + callback(currentLine); + resolve(); + } else if (part.startsWith('data: ')) { + let jsonString = part.slice(part.indexOf('{'), part.lastIndexOf('}') + 1); + + try { + let json = JSON.parse(jsonString); + let chunk = json.choices[0].delta.content; + + if (!chunk) { + continue; + } + + const lines = chunk.split(/\r?\n/); + let hasStartNewline = chunk.startsWith("\n"); + let hasEndNewline = chunk.endsWith("\n"); + + if (hasStartNewline) { + callback(currentLine); + currentLine = ''; + } + + for (let i = 0; i < lines.length - 1; i++) { + currentLine += lines[i]; + callback(currentLine); + currentLine = ''; + } + + currentLine += lines[lines.length - 1]; + + if (hasEndNewline) { + callback(currentLine); + currentLine = ''; + } + + if (currentLine.length > 400) { + callback(currentLine); + currentLine = ''; + } + } catch (e) { + console.log(e); + console.log(part); + } + } } + }); + }).catch(error => { + if (error.response) { + console.log(error.toJSON()); + } else if (error.request) { + console.log(error.request); + } else { + console.log('Error', error.message); } - } + reject(error); + }); }); -} +} \ No newline at end of file