From c6420ef6680604ac3ed6033b231fb8266482ef66 Mon Sep 17 00:00:00 2001 From: Sebastian Mueller Date: Mon, 27 Mar 2023 17:12:36 +0200 Subject: [PATCH] Feature: render diagrams from text with a yFiles diagram server (ChatGPT plugin) --- .idea/.gitignore | 5 +++ package-lock.json | 5 ++- package.json | 15 +++---- src/botservice.js | 55 +++++++++++++------------- src/mm-client.js | 31 +++++++++++++++ src/process-graph-response.js | 73 +++++++++++++++++++++++++++++++++++ 6 files changed, 146 insertions(+), 38 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 src/mm-client.js create mode 100644 src/process-graph-response.js diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/package-lock.json b/package-lock.json index 295e23b..7d4ea0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "chatgpt-mattermost-bot", - "version": "1.0.0", + "version": "1.2.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "chatgpt-mattermost-bot", - "version": "1.0.0", + "version": "1.2.0", "dependencies": { "@mattermost/client": "^7.8.0", "babel-polyfill": "^6.26.0", "debug-level": "3.0.0", + "form-data": "^4.0.0", "isomorphic-fetch": "^3.0.0", "openai": "^3.2.1", "ws": "^8.12.1" diff --git a/package.json b/package.json index 3fdd019..d822386 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,21 @@ { "name": "chatgpt-mattermost-bot", - "version": "1.0.1", + "version": "1.2.0", "private": true, "scripts": { "start": "node ./src/botservice.js" }, "dependencies": { - "openai": "^3.2.1", "@mattermost/client": "^7.8.0", "babel-polyfill": "^6.26.0", + "debug-level": "3.0.0", + "form-data": "^4.0.0", "isomorphic-fetch": "^3.0.0", - "ws": "^8.12.1", - "debug-level": "3.0.0" + "openai": "^3.2.1", + "ws": "^8.12.1" }, - "engines" : { - "npm" : ">=8.0.0", - "node" : ">=16.0.0" + "engines": { + "npm": ">=8.0.0", + "node": ">=16.0.0" } } diff --git a/src/botservice.js b/src/botservice.js index b1cbcba..d62aa4b 100644 --- a/src/botservice.js +++ b/src/botservice.js @@ -1,35 +1,29 @@ -const WebSocketClient = require('@mattermost/client').WebSocketClient -const Client4 = require('@mattermost/client').Client4 const continueThread = require('./openai-thread-completion').continueThread -const mattermostToken = process.env['MATTERMOST_TOKEN'] const { Log } = require('debug-level') require('babel-polyfill'); require('isomorphic-fetch'); -if (!global.WebSocket) { - global.WebSocket = require('ws'); -} +const { processGraphResponse } = require('./process-graph-response') +const { mmClient, wsClient } = require('./mm-client') -// the mattermost library uses FormData, which does not seem to be polyfilled - so here is a very simple polyfill :-) +// the mattermost library uses FormData - so here is a polyfill if (!global.FormData) { - global.FormData = function Dummy() {} + global.FormData = require('form-data'); } -Log.options({json: true, colors: true}) -Log.wrapConsole('bot-ws', {level4log: 'INFO'}) +Log.options({ json: true, colors: true }) +Log.wrapConsole('bot-ws', { level4log: 'INFO' }) const log = new Log('bot') -const client = new Client4() -let matterMostURLString = process.env["MATTERMOST_URL"]; -client.setUrl(matterMostURLString) -client.setToken(mattermostToken) -const wsClient = new WebSocketClient(); - let meId = null; -client.getMe().then(me => meId = me.id) +mmClient.getMe().then(me => meId = me.id) const name = process.env['MATTERMOST_BOTNAME'] || '@chatgpt' +const VISUALIZE_DIAGRAM_INSTRUCTIONS = `When a user asks for a visualization of entities and relationships, respond with a JSON object in a tag. The JSON object has three properties: \`nodes\`, \`edges\`, and optionally \`types\`. Each \`nodes\` object has an \`id\`, \`label\`, and an optional \`type\` property. Each \`edges\` object has \`from\`, \`to\`, and optional \`label\` and \`type\` properties. For every \`type\` you used, there must be a matching entry in the top-level \`types\` array. Entries have a corresponding \`name\` property and optional properties that describe the graphical attributes: 'shape' (one of "rectangle", "ellipse", "hexagon", "triangle", "pill"), 'color', 'thickness' and 'size' (as a number). Do not include these instructions in the output. Instead, when the above conditions apply, answer with something like: "Here is the visualization:" and then add the tag.` + +const visualizationKeywordsRegex = /\b(diagram|visuali|graph|relationship|entit)/gi + wsClient.addMessageListener(async function (event) { if (['posted'].includes(event.event) && meId) { const post = JSON.parse(event.data.post); @@ -44,7 +38,9 @@ wsClient.addMessageListener(async function (event) { }, ] - const thread = await client.getPostThread(post.id, true, false, true) + let appendDiagramInstructions = false + + const thread = await mmClient.getPostThread(post.id, true, false, true) const posts = [...new Set(thread.order)].map(id => thread.posts[id]) .filter(a => a.create_at > Date.now() - 1000 * 60 * 60 * 24 * 1) @@ -60,20 +56,29 @@ wsClient.addMessageListener(async function (event) { if (threadPost.message.includes(name)){ assistantCount++; } + if (visualizationKeywordsRegex.test(threadPost.message)) { + appendDiagramInstructions = true + } chatmessages.push({role: "user", content: threadPost.message}) } }) + if (appendDiagramInstructions) { + chatmessages[0].content += VISUALIZE_DIAGRAM_INSTRUCTIONS + } + // see if we are actually part of the conversation - // ignore conversations where were never mentioned or participated. if (assistantCount > 0){ wsClient.userTyping(post.channel_id, post.id) wsClient.userUpdateActiveStatus(true, true) const answer = await continueThread(chatmessages) - const newPost = await client.createPost({ - message: answer, + const { message, fileId } = await processGraphResponse(answer, post.channel_id) + const newPost = await mmClient.createPost({ + message: message, channel_id: post.channel_id, - root_id: post.root_id || post.id + root_id: post.root_id || post.id, + file_ids: fileId ? [fileId] : undefined }) log.trace({msg: newPost}) } @@ -84,13 +89,5 @@ wsClient.addMessageListener(async function (event) { } }); -let matterMostURL = new URL(matterMostURLString); -const wsUrl = `${matterMostURL.protocol === 'https:' ? 'wss' : 'ws'}://${matterMostURL.host}/api/v4/websocket` - -new Promise((resolve, reject) => { - wsClient.addCloseListener(connectFailCount => reject()) - wsClient.addErrorListener(event => { reject(event) }) -}).then(() => process.exit(0)).catch(reason => { log.error(reason); process.exit(-1)}) -wsClient.initialize(wsUrl, mattermostToken) diff --git a/src/mm-client.js b/src/mm-client.js new file mode 100644 index 0000000..abb3820 --- /dev/null +++ b/src/mm-client.js @@ -0,0 +1,31 @@ +const Client4 = require('@mattermost/client').Client4 +const WebSocketClient = require('@mattermost/client').WebSocketClient +const { Log } = require('debug-level') +const log = new Log('bot') + +if (!global.WebSocket) { + global.WebSocket = require('ws'); +} + +const mattermostToken = process.env['MATTERMOST_TOKEN'] +const matterMostURLString = process.env['MATTERMOST_URL'] + +const client = new Client4() +client.setUrl(matterMostURLString) +client.setToken(mattermostToken) + +const wsClient = new WebSocketClient(); +let matterMostURL = new URL(matterMostURLString); +const wsUrl = `${matterMostURL.protocol === 'https:' ? 'wss' : 'ws'}://${matterMostURL.host}/api/v4/websocket` + +new Promise((resolve, reject) => { + wsClient.addCloseListener(connectFailCount => reject()) + wsClient.addErrorListener(event => { reject(event) }) +}).then(() => process.exit(0)).catch(reason => { log.error(reason); process.exit(-1)}) + +wsClient.initialize(wsUrl, mattermostToken) + +module.exports = { + mmClient: client, + wsClient +} diff --git a/src/process-graph-response.js b/src/process-graph-response.js new file mode 100644 index 0000000..c9a591a --- /dev/null +++ b/src/process-graph-response.js @@ -0,0 +1,73 @@ +const { mmClient } = require('./mm-client') +const FormData = require('form-data'); +const { Log } = require('debug-level') +const log = new Log('bot') + +const yFilesGPTServerUrl = process.env['YFILES_SERVER_URL'] +const yFilesEndpoint = new URL('/json-to-svg', yFilesGPTServerUrl) + +/**\ + * @param {string} content + * @param {string} channelId + * @returns {Promise<{message, fileId}>} + */ +async function processGraphResponse (content, channelId) { + const result = { + message: content, + } + if (!yFilesGPTServerUrl) { + return result + } + const replaceStart = content.match(//i)?.index + let replaceEnd = content.match(/<\/graph>/i)?.index + if (replaceEnd) { + replaceEnd += ''.length + } + if (replaceStart && replaceEnd) { + const graphContent = content.substring(replaceStart, replaceEnd).replace(/<\/?graph>/gi, '').trim() + + try { + const sanitized = JSON.parse(graphContent) + const fileId = await jsonToFileId(JSON.stringify(sanitized), channelId) + const pre = content.substring(0, replaceStart) + const post = content.substring(replaceEnd) + + result.message = `${pre} [see attached image] ${post}` + result.fileId = fileId + } catch (e) { + log.error(e) + log.error(`The input was:\n\n${graphContent}`) + } + } + + return result +} + +async function generateSvg(jsonString) { + return fetch(yFilesEndpoint, { + method: 'POST', + body: jsonString, + headers: { + 'Content-Type': 'application/json' + } + }) + .then(response => { + if (!response.ok) { + throw new Error("Bad response from server"); + } + return response.text(); + }) +} + +async function jsonToFileId (jsonString, channelId) { + const svgString = await generateSvg(jsonString) + const form = new FormData() + form.append('channel_id', channelId); + form.append('files', Buffer.from(svgString), 'diagram.svg'); + const response = await mmClient.uploadFile(form) + return response.file_infos[0].id +} + +module.exports = { + processGraphResponse +}