Skip to content

Commit

Permalink
Feature: render diagrams from text with a yFiles diagram server (Chat…
Browse files Browse the repository at this point in the history
…GPT plugin)
  • Loading branch information
yGuy committed Mar 27, 2023
1 parent 568ca25 commit c6420ef
Show file tree
Hide file tree
Showing 6 changed files with 146 additions and 38 deletions.
5 changes: 5 additions & 0 deletions .idea/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 8 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
55 changes: 26 additions & 29 deletions src/botservice.js
Original file line number Diff line number Diff line change
@@ -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 <GRAPH> 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);
Expand All @@ -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)
Expand All @@ -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})
}
Expand All @@ -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)

31 changes: 31 additions & 0 deletions src/mm-client.js
Original file line number Diff line number Diff line change
@@ -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
}
73 changes: 73 additions & 0 deletions src/process-graph-response.js
Original file line number Diff line number Diff line change
@@ -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(/<graph>/i)?.index
let replaceEnd = content.match(/<\/graph>/i)?.index
if (replaceEnd) {
replaceEnd += '</graph>'.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
}

0 comments on commit c6420ef

Please sign in to comment.