diff --git a/.env.example b/.env.example index 5f175ff..056affb 100644 --- a/.env.example +++ b/.env.example @@ -4,9 +4,12 @@ TOKEN= # What language model to use, orca is one of the lower-end models that doesn't require as much computer power as llama2 MODEL=orca -# Ollama URL (if you want to use multiple, separate them by commas) +# Ollama URL OLLAMA=http://localhost:11434 +# Stable diffusion URL +STABLE_DIFFUSION=http://localhost:7860 + # What Discord channels to enable it in (by ID) CHANNELS=123456789,987654321 diff --git a/.eslintrc.json b/.eslintrc.json index 12dcd8e..c652c3b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -25,7 +25,6 @@ "no-empty": "warn", "no-empty-function": "error", "no-floating-decimal": "error", - "no-inline-comments": "error", "no-lonely-if": "error", "no-multi-spaces": "error", "no-multiple-empty-lines": ["error", { "max": 2, "maxEOF": 1, "maxBOF": 0 }], diff --git a/.gitignore b/.gitignore index 553aeb5..0d51cca 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ pnpm-lock.yaml pnpm-lock.yml node_modules/ .env +.vscode/ diff --git a/README.md b/README.md index f742f03..1050bd5 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,46 @@
-

Discord AI Bot

-

Discord bot to interact with Ollama as a chatbot

-

Stars

-

Discord chat with the bot

+

Discord AI Bot

+

Repository is now in maintanance mode - rewriting project to Typescript on typescript branch

+

Discord bot to interact with Ollama and AUTOMATIC1111 Stable Diffusion as a chatbot

+

Stars

+

Discord chat with the bot

+The project started thanks to [mekb](https://github.com/mekb-turtle). + ### Set-up instructions + 1. Install [Node.js](https://nodejs.org) (if you have a package manager, use that instead to install this) - - Make sure to install at least v14 of Node.js + - Make sure to install at least v14 of Node.js 2. Install [Ollama](https://github.com/jmorganca/ollama) (ditto) 3. Pull (download) a model, e.g `ollama pull orca` or `ollama pull llama2` 4. Start Ollama by running `ollama serve` 5. [Create a Discord bot](https://discord.com/developers/applications) - - Under Application » Bot - - Enable Message Content Intent - - Enable Server Members Intent (for replacing user mentions with the username) + - Under Application » Bot + - Enable Message Content Intent + - Enable Server Members Intent (for replacing user mentions with the username) 6. Invite the bot to a server - 1. Go to Application » OAuth2 » URL Generator - 2. Enable `bot` - 3. Enable Send Messages, Read Messages/View Channels, and Read Message History - 4. Under Generated URL, click Copy and paste the URL in your browser + 1. Go to Application » OAuth2 » URL Generator + 2. Enable `bot` + 3. Enable Send Messages, Read Messages/View Channels, and Read Message History + 4. Under Generated URL, click Copy and paste the URL in your browser 7. Rename `.env.example` to `.env` and edit the `.env` file - - You can get the token from Application » Bot » Token, **never share this with anyone** - - Make sure to change the model if you aren't using `orca` - - Ollama URL can be kept the same unless you have changed the port - - You can use multiple Ollama servers at the same time by separating the URLs with commas - - Set the channels to the channel ID, comma separated - 1. In Discord, go to User Settings » Advanced, and enable Developer Mode - 2. Right click on a channel you want to use, and click Copy Channel ID - - You can edit the system message the bot uses, or disable it entirely -8. Start the bot with `npm start` -9. You can interact with the bot by @mentioning it with your message + - You can get the token from Application » Bot » Token, **never share this with anyone** + - Make sure to change the model if you aren't using `orca` + - Ollama URL can be kept the same unless you have changed the port + - You can use multiple Ollama servers at the same time by separating the URLs with commas + - Set the channels to the channel ID, comma separated + 1. In Discord, go to User Settings » Advanced, and enable Developer Mode + 2. Right click on a channel you want to use, and click Copy Channel ID + - You can edit the system message the bot uses, or disable it entirely +8. Install the required dependencies with `npm i` +9. Start the bot with `npm start` +10. You can interact with the bot by @mentioning it with your message +11. Install Stable Diffusion +12. Run the script `./webui.sh --api --listen` ### Set-up instructions with Docker + 1. Install [Docker](https://docs.docker.com/get-docker/) - Should be atleast compatible with version 3 of compose (docker engine 1.13.0+) 2. Repeat steps 2—7 from the other setup instructions diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..6ca2cbc --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,99 @@ +import globals from "globals"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import js from "@eslint/js"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all +}); + +export default [...compat.extends("eslint:recommended"), { + languageOptions: { + globals: { + ...globals.node + }, + + ecmaVersion: 2021, + sourceType: "module" + }, + + rules: { + "arrow-spacing": ["warn", { + before: true, + after: true + }], + + "brace-style": ["error", "1tbs", { + allowSingleLine: true + }], + + "comma-dangle": ["error", "never"], + "comma-spacing": "error", + "comma-style": "error", + curly: ["error", "multi-line", "consistent"], + "dot-location": ["error", "property"], + "handle-callback-err": "off", + + indent: ["error", "tab", { + SwitchCase: 1 + }], + + "keyword-spacing": "error", + + "max-nested-callbacks": ["error", { + max: 4 + }], + + "max-statements-per-line": ["error", { + max: 2 + }], + + "no-console": "off", + "no-empty": "warn", + "no-empty-function": "error", + "no-floating-decimal": "error", + "no-lonely-if": "error", + "no-multi-spaces": "error", + + "no-multiple-empty-lines": ["error", { + max: 2, + maxEOF: 1, + maxBOF: 0 + }], + + "no-shadow": ["error", { + allow: ["err", "resolve", "reject"] + }], + + "no-trailing-spaces": ["error"], + "no-var": "error", + "object-curly-spacing": ["error", "always"], + "prefer-const": "error", + quotes: ["error", "double"], + semi: ["error", "always"], + "space-before-blocks": "error", + + "space-before-function-paren": ["error", { + anonymous: "never", + named: "never", + asyncArrow: "always" + }], + + "space-in-parens": "error", + "space-infix-ops": "error", + "space-unary-ops": "error", + "spaced-comment": "error", + yoda: "error", + "default-case-last": "error", + + "switch-colon-spacing": ["error", { + after: true, + before: false + }] + } +}]; \ No newline at end of file diff --git a/package.json b/package.json index d2d148e..8b679e5 100644 --- a/package.json +++ b/package.json @@ -10,5 +10,11 @@ "dotenv": "^16.3.1", "meklog": "^1.0.2" }, - "type": "module" + "type": "module", + "devDependencies": { + "@eslint/js": "^9.7.0", + "@types/eslint__js": "^8.42.3", + "eslint": "^9.7.0", + "globals": "^15.8.0" + } } diff --git a/src/bot.js b/src/bot.js index 7752304..8575812 100644 --- a/src/bot.js +++ b/src/bot.js @@ -1,12 +1,22 @@ -import { Client, Events, GatewayIntentBits, MessageType, Partials } from "discord.js"; +import { + Client, + Events, + GatewayIntentBits, + MessageType, + Partials, + REST, + Routes +} from "discord.js"; import { Logger, LogLevel } from "meklog"; import dotenv from "dotenv"; import axios from "axios"; +import commands from "./commands/commands.js"; dotenv.config(); const model = process.env.MODEL; const servers = process.env.OLLAMA.split(",").map(url => ({ url: new URL(url), available: true })); +const stableDiffusionServers = process.env.STABLE_DIFFUSION.split(",").map(url => ({ url: new URL(url), available: true })); const channels = process.env.CHANNELS.split(","); if (servers.length == 0) { @@ -82,6 +92,48 @@ async function makeRequest(path, method, data) { throw error; } +async function makeStableDiffusionRequest(path, method, data) { + while (stableDiffusionServers.filter(server => server.available).length == 0) { + // wait until a server is available + await new Promise(res => setTimeout(res, 1000)); + } + + let error = null; + // randomly loop through the servers available, don't shuffle the actual array because we want to be notified of any updates + let order = new Array(stableDiffusionServers.length).fill().map((_, i) => i); + if (randomServer) order = shuffleArray(order); + for (const j in order) { + if (!order.hasOwnProperty(j)) continue; + const i = order[j]; + // try one until it succeeds + try { + // make a request to stable diffusion + if (!stableDiffusionServers[i].available) continue; + const url = new URL(stableDiffusionServers[i].url); // don't modify the original URL + + stableDiffusionServers[i].available = false; + + if (path.startsWith("/")) path = path.substring(1); + if (!url.pathname.endsWith("/")) url.pathname += "/"; // safety + url.pathname += path; + log(LogLevel.Debug, `Making stable diffusion request to ${url}`); + const result = await axios({ + method, url, data + }); + stableDiffusionServers[i].available = true; + return result.data; + } catch (err) { + stableDiffusionServers[i].available = true; + error = err; + logError(error); + } + } + if (!error) { + throw new Error("No servers available"); + } + throw error; +} + const client = new Client({ intents: [ GatewayIntentBits.Guilds, @@ -96,9 +148,16 @@ const client = new Client({ ] }); +const rest = new REST({ version: "10" }).setToken(process.env.TOKEN); + client.once(Events.ClientReady, async () => { await client.guilds.fetch(); client.user.setPresence({ activities: [], status: "online" }); + await rest.put(Routes.applicationCommands(client.user.id), { + body: commands + }); + + log(LogLevel.Info, "Successfully reloaded application slash (/) commands."); }); const messages = {}; @@ -160,7 +219,7 @@ function getBoolean(str) { } function parseJSONMessage(str) { - return str.split(/[\r\n]+/g).map(function(line) { + return str.split(/[\r\n]+/g).map(line => { const result = JSON.parse(`"${line}"`); if (typeof result !== "string") throw new "Invalid syntax in .env file"; return result; @@ -184,7 +243,7 @@ const useInitialPrompt = getBoolean(process.env.USE_INITIAL_PROMPT) && !!initial const requiresMention = getBoolean(process.env.REQUIRES_MENTION); async function replySplitMessage(replyMessage, content) { - const responseMessages = splitText(content, 2000).map(content => ({ content })); + const responseMessages = splitText(content, 2000).map(text => ({ content: text })); const replyMessages = []; for (let i = 0; i < responseMessages.length; ++i) { @@ -252,7 +311,7 @@ client.on(Events.MessageCreate, async message => { // deal with commands first before passing to LLM let userInput = message.content - .replace(new RegExp("^\s*" + myMention.source, ""), "").trim(); + .replace(new RegExp("^s*" + myMention.source, ""), "").trim(); // may change this to slash commands in the future // i'm using regular text commands currently because the bot interacts with text content anyway @@ -291,11 +350,16 @@ client.on(Events.MessageCreate, async message => { break; case "ping": // get ms difference - const beforeTime = Date.now(); - const reply = await message.reply({ content: "Ping" }); - const afterTime = Date.now(); - const difference = afterTime - beforeTime; - await reply.edit({ content: `Ping: ${difference}ms` }); + try { + const beforeTime = Date.now(); + const reply = await message.reply({ content: "Ping" }); + const afterTime = Date.now(); + const difference = afterTime - beforeTime; + await reply.edit({ content: `Ping: ${difference}ms` }); + } catch (error) { + logError(error); + await message.reply({ content: "Error, please check the console" }); + } break; case "": break; @@ -337,6 +401,23 @@ client.on(Events.MessageCreate, async message => { if (userInput.length == 0) return; + // Process text files if attached + if (message.attachments.size > 0) { + const textAttachments = Array.from(message.attachments, ([, value]) => value).filter(att => att.contentType.startsWith("text")); + if (textAttachments.length > 0) { + try { + await Promise.all(textAttachments.map(async (att, i) => { + const response = await axios.get(att.url); + userInput += `\n${i + 1}. File - ${att.name}:\n${response.data}`; + })); + } catch (error) { + log(LogLevel.Error, `Failed to download text files: ${error}`); + await message.reply({ content: "Failed to download text files" }); + return; // Stop processing if file download fails + } + } + } + // create conversation if (messages[channelID] == null) { messages[channelID] = { amount: 0, last: null }; @@ -352,6 +433,7 @@ client.on(Events.MessageCreate, async message => { try { await message.channel.sendTyping(); } catch (error) { + logError(error); if (typingInterval != null) { clearInterval(typingInterval); } @@ -425,10 +507,60 @@ client.on(Events.MessageCreate, async message => { try { // return error await message.reply({ content: "Error, please check the console" }); - } catch (ignored) {} + } catch (ignored) { + logError(ignored); + } } logError(error); } }); +client.on(Events.InteractionCreate, async (interaction) => { + if (!interaction.isCommand()) return; + + const { commandName, options } = interaction; + + switch (commandName) { + case "text2img": + try { + const prompt = options.getString("prompt"); + const width = options.getNumber("width") || 256; + const height = options.getNumber("height") || 256; + const steps = options.getNumber("steps") || 10; + const batch_count = options.getNumber("batch_count") || 1; + const batch_size = options.getNumber("batch_size") || 1; + const enhance_prompt = (options.getBoolean("enhance_prompt") && true) ? "yes" : "no"; + + await interaction.deferReply(); + const stableDiffusionResponse = await makeStableDiffusionRequest( + "/sdapi/v1/txt2img", + "post", + { + prompt, + width, + height, + steps, + num_inference_steps: steps, + batch_count, + batch_size, + enhance_prompt + } + ); + const images = stableDiffusionResponse.images.map((image) => + Buffer.from(image, "base64") + ); + await interaction.editReply({ + content: `Here are images from prompt \`${prompt}\``, + files: images + }); + } catch (error) { + logError(error); + await interaction.editReply({ + content: "Error, please check the console" + }); + } + break; + } +}); + client.login(process.env.TOKEN); diff --git a/src/commands/commands.js b/src/commands/commands.js new file mode 100644 index 0000000..01edf15 --- /dev/null +++ b/src/commands/commands.js @@ -0,0 +1,3 @@ +import text2img from "./text2img.js"; + +export default [text2img]; diff --git a/src/commands/text2img.js b/src/commands/text2img.js new file mode 100644 index 0000000..c8f6097 --- /dev/null +++ b/src/commands/text2img.js @@ -0,0 +1,56 @@ +import { SlashCommandBuilder } from "discord.js"; + +const text2img = new SlashCommandBuilder() + .setName("text2img") + .setDescription("Convert text to image") + .addStringOption((option) => + option.setName("prompt").setDescription("Text to convert").setRequired(true) + ) + .addNumberOption((option) => + option + .setName("width") + .setDescription("Width of the image") + .setRequired(false) + .setMinValue(128) + .setMaxValue(1024) + ) + .addNumberOption((option) => + option + .setName("height") + .setDescription("Height of the image") + .setRequired(false) + .setMinValue(128) + .setMaxValue(1024) + ) + .addNumberOption((option) => + option + .setName("steps") + .setDescription("Number of steps") + .setRequired(false) + .setMinValue(5) + .setMaxValue(20) + ) + .addNumberOption((option) => + option + .setName("batch_count") + .setDescription("Batch count") + .setRequired(false) + .setMinValue(1) + .setMaxValue(4) + ) + .addNumberOption((option) => + option + .setName("batch_size") + .setDescription("Batch size") + .setRequired(false) + .setMinValue(1) + .setMaxValue(5) + ) + .addBooleanOption((option) => + option + .setName("enhance_prompt") + .setDescription("Enhance prompt") + .setRequired(false) + ); + +export default text2img;