@flowfuse/nr-assistant 0.6.1-f938e50-202512021006.0 → 0.7.1-83a01bc-202512091639.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +4 -27
- package/completions.html +1 -1
- package/index.html +166 -84
- package/index.js +19 -11
- package/lib/assistant.js +83 -43
- package/lib/auth/index.js +277 -0
- package/lib/auth/store.js +33 -0
- package/lib/settings.js +57 -20
- package/locales/en-US/index.json +14 -9
- package/package.json +4 -3
package/lib/assistant.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
'use strict'
|
|
3
3
|
|
|
4
4
|
const { z } = require('zod')
|
|
5
|
+
const auth = require('./auth')
|
|
5
6
|
const { getLongestUpstreamPath } = require('./flowGraph')
|
|
6
7
|
const { hasProperty } = require('./utils')
|
|
7
8
|
const semver = require('semver')
|
|
@@ -23,6 +24,7 @@ class Assistant {
|
|
|
23
24
|
this.options = null
|
|
24
25
|
this._loading = false // Flag to indicate if the Assistant is currently loading
|
|
25
26
|
this._enabled = false // Flag to indicate if the Assistant is enabled
|
|
27
|
+
this._adminRoutesInitialized = false
|
|
26
28
|
|
|
27
29
|
// MCP Client and Server and associated properties
|
|
28
30
|
/** @type {import('@modelcontextprotocol/sdk/client/index.js').Client} */
|
|
@@ -54,38 +56,60 @@ class Assistant {
|
|
|
54
56
|
*/
|
|
55
57
|
async init (RED, options = {}) {
|
|
56
58
|
if (this._loading) {
|
|
57
|
-
RED.log.debug('FlowFuse
|
|
59
|
+
this.RED.log.debug('FlowFuse Expert is busy loading')
|
|
58
60
|
return
|
|
59
61
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
const wasStandaloneEnabled = !!this.options?.standalone && this.options?.enabled
|
|
63
|
+
await this.dispose() // Dispose of any existing instance before initializing a new one
|
|
64
|
+
this.RED = RED
|
|
65
|
+
this.options = options || {}
|
|
66
|
+
this.got = this.options.got || require('got') // got can be passed in for testing purposes
|
|
67
|
+
|
|
68
|
+
if (this.options.standalone) {
|
|
69
|
+
if (wasStandaloneEnabled && !this.options.enabled) {
|
|
70
|
+
// The assistant was previously enabled in standalone mode, but is now disabled (auth issue).
|
|
71
|
+
this.RED.log.info('FlowFuse Expert Plugin has been disabled')
|
|
72
|
+
RED.comms.publish('nr-assistant/mcp/ready', { enabled: false }, true /* retain */)
|
|
73
|
+
RED.comms.publish('nr-assistant/completions/ready', { enabled: false }, true /* retain */)
|
|
74
|
+
} else {
|
|
75
|
+
this.RED.log.info('FlowFuse Expert Plugin is running in standalone mode')
|
|
76
|
+
}
|
|
77
|
+
this.initAdminAuthEndpoints(RED)
|
|
78
|
+
} else {
|
|
67
79
|
if (!this.options.enabled) {
|
|
68
|
-
RED.log.info('FlowFuse
|
|
80
|
+
RED.log.info('FlowFuse Expert Plugin is not enabled')
|
|
69
81
|
return
|
|
70
82
|
}
|
|
71
83
|
if (!this.options.url || !this.options.token) {
|
|
72
|
-
RED.log.warn('FlowFuse
|
|
84
|
+
RED.log.warn('FlowFuse Expert Plugin configuration is missing required options')
|
|
73
85
|
throw new Error('Plugin configuration is missing required options')
|
|
74
86
|
}
|
|
87
|
+
}
|
|
88
|
+
const clientSettings = {
|
|
89
|
+
enabled: this.options.enabled !== false && !!this.options.url,
|
|
90
|
+
tablesEnabled: this.options.tables?.enabled === true,
|
|
91
|
+
inlineCompletionsEnabled: this.options.completions?.inlineEnabled === true,
|
|
92
|
+
requestTimeout: this.options.requestTimeout || 60000
|
|
93
|
+
}
|
|
94
|
+
if (this.options.standalone) {
|
|
95
|
+
clientSettings.standalone = true
|
|
96
|
+
}
|
|
97
|
+
RED.comms.publish('nr-assistant/initialise', clientSettings, true /* retain */)
|
|
75
98
|
|
|
99
|
+
if (this.options.enabled) {
|
|
100
|
+
await this.completeInitialization(clientSettings)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async completeInitialization (clientSettings) {
|
|
105
|
+
const RED = this.RED
|
|
106
|
+
try {
|
|
107
|
+
this._loading = true // Set loading to true when initializing
|
|
76
108
|
const nrVersion = this.RED.version()
|
|
77
109
|
const nrMajorVersion = semver.major(nrVersion)
|
|
78
110
|
const nrMinorVersion = semver.minor(nrVersion)
|
|
79
111
|
const nodeMajorVersion = semver.major(process.versions.node)
|
|
80
112
|
|
|
81
|
-
const clientSettings = {
|
|
82
|
-
enabled: this.options.enabled !== false && !!this.options.url,
|
|
83
|
-
tablesEnabled: this.options.tables?.enabled === true,
|
|
84
|
-
inlineCompletionsEnabled: this.options.completions?.inlineEnabled === true,
|
|
85
|
-
requestTimeout: this.options.requestTimeout || 60000
|
|
86
|
-
}
|
|
87
|
-
RED.comms.publish('nr-assistant/initialise', clientSettings, true /* retain */)
|
|
88
|
-
|
|
89
113
|
// ### Initialise Model Context Protocol (MCP)
|
|
90
114
|
// TODO: If "feature" is disabled, skip loading MCP. See issue #57
|
|
91
115
|
this.options.mcp = this.options.mcp || { enabled: true }
|
|
@@ -99,19 +123,19 @@ class Assistant {
|
|
|
99
123
|
this.mcpReady = true
|
|
100
124
|
// tell frontend that the MCP client is ready so it can add the action(s) to the Action List
|
|
101
125
|
RED.comms.publish('nr-assistant/mcp/ready', clientSettings, true /* retain */)
|
|
102
|
-
RED.log.info('FlowFuse
|
|
126
|
+
RED.log.info('FlowFuse Expert Model Context Protocol (MCP) loaded')
|
|
103
127
|
} catch (error) {
|
|
104
128
|
this.mcpReady = false
|
|
105
129
|
// ESM Support in Node 20 is much better than versions v18-, so lets include a node version
|
|
106
130
|
// Write a warning to log as a hint/prompt
|
|
107
131
|
// NOTE: Node 18 is EOL as of writing this
|
|
108
|
-
RED.log.warn('FlowFuse
|
|
132
|
+
RED.log.warn('FlowFuse Expert MCP could not be loaded. Expert features that require MCP will not be available')
|
|
109
133
|
if (nodeMajorVersion < 20) {
|
|
110
134
|
RED.log.debug(`Node.js version ${nodeMajorVersion} may not be supported by MCP Client / Server.`)
|
|
111
135
|
}
|
|
112
136
|
}
|
|
113
137
|
} else if (!mcpFeatureEnabled) {
|
|
114
|
-
RED.log.info('FlowFuse
|
|
138
|
+
RED.log.info('FlowFuse Expert MCP is disabled')
|
|
115
139
|
}
|
|
116
140
|
|
|
117
141
|
// ### Initialise completions (depends on MCP so checks the mcpReady flag)
|
|
@@ -122,9 +146,9 @@ class Assistant {
|
|
|
122
146
|
const completionsEnabled = completionsFeatureEnabled && this.isInitialized && this.isEnabled
|
|
123
147
|
|
|
124
148
|
if (!completionsSupported) {
|
|
125
|
-
RED.log.warn('FlowFuse
|
|
149
|
+
RED.log.warn('FlowFuse Expert Completions require Node-RED 4.1 or greater')
|
|
126
150
|
} else if (!completionsFeatureEnabled) {
|
|
127
|
-
RED.log.info('FlowFuse
|
|
151
|
+
RED.log.info('FlowFuse Expert Completions are disabled')
|
|
128
152
|
} else if (this.mcpReady && completionsEnabled && completionsSupported) {
|
|
129
153
|
// if modelUrl is not set, use the default model URL from this.options.url + 'assets/completions/model.onnx'
|
|
130
154
|
this.options.completions.modelUrl = this.options.completions.modelUrl || new URL('assets/completions/model.onnx', this.options.url).href
|
|
@@ -134,21 +158,21 @@ class Assistant {
|
|
|
134
158
|
// RED.events.once('comms:message:nr-assistant/completions/load', async (opts) => {
|
|
135
159
|
RED.events.once('comms:message:nr-assistant/completions/load', async (opts) => {
|
|
136
160
|
try {
|
|
137
|
-
RED.log.info('FlowFuse
|
|
161
|
+
RED.log.info('FlowFuse Expert is Loading Advanced Completions...')
|
|
138
162
|
await this.loadCompletions()
|
|
139
163
|
RED.comms.publish('nr-assistant/completions/ready', { enabled: true }, true /* retain */)
|
|
140
|
-
RED.log.info('FlowFuse
|
|
164
|
+
RED.log.info('FlowFuse Expert Completions Loaded')
|
|
141
165
|
this.completionsReady = true
|
|
142
166
|
} catch (error) {
|
|
143
167
|
this.completionsReady = false
|
|
144
|
-
RED.log.warn('FlowFuse
|
|
168
|
+
RED.log.warn('FlowFuse Expert Advanced Completions could not be loaded.') // degraded functionality
|
|
145
169
|
RED.log.debug(`Completions loading error: ${error.message}`)
|
|
146
170
|
}
|
|
147
171
|
})
|
|
148
172
|
}
|
|
149
|
-
this.initAdminEndpoints(RED
|
|
173
|
+
this.initAdminEndpoints(RED) // Initialize the admin endpoints for the Assistant
|
|
150
174
|
const degraded = (mcpEnabled && !this.mcpReady)
|
|
151
|
-
RED.log.info('FlowFuse
|
|
175
|
+
RED.log.info('FlowFuse Expert Plugin loaded' + (degraded ? ' (reduced functionality)' : ''))
|
|
152
176
|
} finally {
|
|
153
177
|
this._loading = false // Set loading to false when initialization is complete
|
|
154
178
|
}
|
|
@@ -190,7 +214,14 @@ class Assistant {
|
|
|
190
214
|
if (!this.options) {
|
|
191
215
|
return false
|
|
192
216
|
}
|
|
193
|
-
return !!(
|
|
217
|
+
return !!(
|
|
218
|
+
this.options.enabled &&
|
|
219
|
+
this.options.url &&
|
|
220
|
+
(
|
|
221
|
+
(!this.options.standalone && this.options.token) ||
|
|
222
|
+
(this.options.standalone && auth.getUserToken())
|
|
223
|
+
)
|
|
224
|
+
)
|
|
194
225
|
}
|
|
195
226
|
|
|
196
227
|
async loadCompletions () {
|
|
@@ -209,7 +240,7 @@ class Assistant {
|
|
|
209
240
|
const response = await this.got(url, {
|
|
210
241
|
responseType: 'json',
|
|
211
242
|
headers: {
|
|
212
|
-
Authorization: `Bearer ${
|
|
243
|
+
Authorization: `Bearer ${auth.getUserToken()}`,
|
|
213
244
|
'User-Agent': FF_ASSISTANT_USER_AGENT
|
|
214
245
|
}
|
|
215
246
|
})
|
|
@@ -247,7 +278,7 @@ class Assistant {
|
|
|
247
278
|
try {
|
|
248
279
|
const response = await this.got(url, {
|
|
249
280
|
headers: {
|
|
250
|
-
Authorization: `Bearer ${
|
|
281
|
+
Authorization: `Bearer ${auth.getUserToken()}`,
|
|
251
282
|
'User-Agent': FF_ASSISTANT_USER_AGENT
|
|
252
283
|
},
|
|
253
284
|
responseType: 'buffer' // Ensure we get raw binary
|
|
@@ -410,7 +441,7 @@ class Assistant {
|
|
|
410
441
|
|
|
411
442
|
// #region Admin Endpoints & HTTP Handlers
|
|
412
443
|
|
|
413
|
-
initAdminEndpoints (RED
|
|
444
|
+
initAdminEndpoints (RED) {
|
|
414
445
|
// Hook up routes first ordered by static --> specific --> generic
|
|
415
446
|
|
|
416
447
|
RED.httpAdmin.get('/nr-assistant/mcp/prompts', RED.auth.needsPermission('write'), async function (req, res) {
|
|
@@ -425,17 +456,23 @@ class Assistant {
|
|
|
425
456
|
return assistant.handlePostToolRequest(req, res)
|
|
426
457
|
})
|
|
427
458
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
})
|
|
432
|
-
}
|
|
459
|
+
RED.httpAdmin.post('/nr-assistant/fim/:nodeModule/:nodeType', RED.auth.needsPermission('write'), function (req, res) {
|
|
460
|
+
return assistant.handlePostFimRequest(req, res)
|
|
461
|
+
})
|
|
433
462
|
|
|
434
463
|
RED.httpAdmin.post('/nr-assistant/:method', RED.auth.needsPermission('write'), function (req, res) {
|
|
435
464
|
return assistant.handlePostMethodRequest(req, res)
|
|
436
465
|
})
|
|
437
466
|
}
|
|
438
467
|
|
|
468
|
+
initAdminAuthEndpoints (RED) {
|
|
469
|
+
if (!this._adminRoutesInitialized) {
|
|
470
|
+
// Only required in standalone mode
|
|
471
|
+
auth.setupRoutes(this, RED)
|
|
472
|
+
this._adminRoutesInitialized = true
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
439
476
|
/**
|
|
440
477
|
* Handles POST requests to the /nr-assistant/:method endpoint.
|
|
441
478
|
* This is for handling custom methods that the Assistant can perform.
|
|
@@ -471,7 +508,7 @@ class Assistant {
|
|
|
471
508
|
headers: {
|
|
472
509
|
Accept: '*/*',
|
|
473
510
|
'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8,es;q=0.7',
|
|
474
|
-
Authorization: `Bearer ${
|
|
511
|
+
Authorization: `Bearer ${auth.getUserToken()}`,
|
|
475
512
|
'Content-Type': 'application/json',
|
|
476
513
|
'User-Agent': FF_ASSISTANT_USER_AGENT
|
|
477
514
|
},
|
|
@@ -491,7 +528,7 @@ class Assistant {
|
|
|
491
528
|
// ignore
|
|
492
529
|
}
|
|
493
530
|
}
|
|
494
|
-
let message = 'FlowFuse
|
|
531
|
+
let message = 'FlowFuse Expert request was unsuccessful'
|
|
495
532
|
const errorData = { status: 'error', message, body }
|
|
496
533
|
const errorCode = (error.response && error.response.statusCode) || 500
|
|
497
534
|
res.status(errorCode).json(errorData)
|
|
@@ -511,7 +548,10 @@ class Assistant {
|
|
|
511
548
|
*/
|
|
512
549
|
async handlePostFimRequest (req, res) {
|
|
513
550
|
if (!this.isInitialized || this.isLoading) {
|
|
514
|
-
return res.status(503).send('
|
|
551
|
+
return res.status(503).send('Expert is not ready')
|
|
552
|
+
}
|
|
553
|
+
if (this.options.completions?.inlineEnabled !== true) {
|
|
554
|
+
return res.status(400).send('Inline completions are not enabled')
|
|
515
555
|
}
|
|
516
556
|
|
|
517
557
|
const nodeModule = req.params.nodeModule
|
|
@@ -544,7 +584,7 @@ class Assistant {
|
|
|
544
584
|
headers: {
|
|
545
585
|
Accept: '*/*',
|
|
546
586
|
'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8,es;q=0.7',
|
|
547
|
-
Authorization: `Bearer ${
|
|
587
|
+
Authorization: `Bearer ${auth.getUserToken()}`,
|
|
548
588
|
'Content-Type': 'application/json',
|
|
549
589
|
'User-Agent': FF_ASSISTANT_USER_AGENT
|
|
550
590
|
},
|
|
@@ -557,7 +597,7 @@ class Assistant {
|
|
|
557
597
|
})
|
|
558
598
|
}).catch((_error) => {
|
|
559
599
|
// fim requests are inline completion opportunities - lets not complain if they fail
|
|
560
|
-
const message = 'FlowFuse
|
|
600
|
+
const message = 'FlowFuse Expert FIM request was unsuccessful'
|
|
561
601
|
this.RED.log.trace(message, _error)
|
|
562
602
|
})
|
|
563
603
|
}
|
|
@@ -636,7 +676,7 @@ class Assistant {
|
|
|
636
676
|
headers: {
|
|
637
677
|
Accept: '*/*',
|
|
638
678
|
'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8,es;q=0.7',
|
|
639
|
-
Authorization: `Bearer ${
|
|
679
|
+
Authorization: `Bearer ${auth.getUserToken()}`,
|
|
640
680
|
'Content-Type': 'application/json'
|
|
641
681
|
},
|
|
642
682
|
json: body
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
const crypto = require('node:crypto')
|
|
2
|
+
const path = require('node:path')
|
|
3
|
+
const { readFile, writeFile } = require('node:fs/promises')
|
|
4
|
+
const { existsSync } = require('node:fs')
|
|
5
|
+
|
|
6
|
+
const base64url = require('base64url')
|
|
7
|
+
const store = require('./store')
|
|
8
|
+
const got = require('got').default
|
|
9
|
+
let baseURL = 'https://app.flowfuse.com'
|
|
10
|
+
const authorizationURL = () => `${baseURL}/account/authorize`
|
|
11
|
+
const tokenURL = () => `${baseURL}/account/token`
|
|
12
|
+
|
|
13
|
+
let activeTokens = { }
|
|
14
|
+
const activeTimers = { }
|
|
15
|
+
let assistant = null
|
|
16
|
+
let tokenFile
|
|
17
|
+
let RED
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Initialise the auth handling for standalone FF Assistant mode
|
|
21
|
+
* @param {*} _RED
|
|
22
|
+
*/
|
|
23
|
+
async function init (_RED) {
|
|
24
|
+
RED = _RED
|
|
25
|
+
baseURL = RED.settings.flowfuse?.assistant?.url || 'https://app.flowfuse.com'
|
|
26
|
+
tokenFile = path.join(RED.settings.userDir, '.flowfuse-assistant.json')
|
|
27
|
+
if (existsSync(tokenFile)) {
|
|
28
|
+
try {
|
|
29
|
+
const data = await readFile(tokenFile, 'utf8')
|
|
30
|
+
if (data) {
|
|
31
|
+
activeTokens = JSON.parse(data)
|
|
32
|
+
// We currently only support a single user '_'
|
|
33
|
+
const token = activeTokens._
|
|
34
|
+
if (!token || token.expires_at < Date.now()) {
|
|
35
|
+
RED.log.info('FlowFuse Assistant: access has expired, please log in again')
|
|
36
|
+
await deleteUserToken('_')
|
|
37
|
+
} else {
|
|
38
|
+
setupRefreshTimer('_')
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} catch (err) {
|
|
42
|
+
RED.log.error('FlowFuse Assistant: Failed to load access tokens')
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Write tokens to file
|
|
49
|
+
*/
|
|
50
|
+
async function saveTokens () {
|
|
51
|
+
await writeFile(tokenFile, JSON.stringify(activeTokens), 'utf8')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Update a user token, setup refresh timer and save to file
|
|
56
|
+
* @param {*} user
|
|
57
|
+
* @param {*} token
|
|
58
|
+
*/
|
|
59
|
+
async function setUserToken (user, token) {
|
|
60
|
+
activeTokens[user] = token
|
|
61
|
+
setupRefreshTimer(user)
|
|
62
|
+
await saveTokens()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Setup the refresh timer for the token. User tokens have a relatively short expiry time, but can be refreshed before expiry
|
|
67
|
+
* to provide a longer session.
|
|
68
|
+
* @param {*} user
|
|
69
|
+
*/
|
|
70
|
+
function setupRefreshTimer (user) {
|
|
71
|
+
const token = activeTokens[user]
|
|
72
|
+
if (!token.expires_at) {
|
|
73
|
+
token.expires_at = Date.now() + (token.expires_in * 1000)
|
|
74
|
+
}
|
|
75
|
+
const refreshInterval = token.expires_at - Date.now() - 10000 // refresh with 10 secs to spare
|
|
76
|
+
if (refreshInterval > 0) {
|
|
77
|
+
activeTimers[user] = setTimeout(async () => {
|
|
78
|
+
try {
|
|
79
|
+
const newTokens = await refreshToken(token)
|
|
80
|
+
newTokens.expires_at = Date.now() + (newTokens.expires_in * 1000)
|
|
81
|
+
await setUserToken(user, newTokens)
|
|
82
|
+
} catch (err) {
|
|
83
|
+
// Failed to refresh token - remove it and disable the agent
|
|
84
|
+
await deleteUserToken(user)
|
|
85
|
+
assistant.RED.log.warn('Failed to refresh FlowFuse Assistant access token')
|
|
86
|
+
reinitialiseAssistant()
|
|
87
|
+
}
|
|
88
|
+
}, refreshInterval)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* When running in a managed FF environment, the tokens are provided statically via settings.
|
|
94
|
+
* This is used to insert the token into the auth table so we have a single place to retrieve
|
|
95
|
+
* tokens from.
|
|
96
|
+
* @param {*} token
|
|
97
|
+
*/
|
|
98
|
+
function setStaticToken (token) {
|
|
99
|
+
// A token provided by settings file - does not need refresh logic
|
|
100
|
+
activeTokens._ = {
|
|
101
|
+
access_token: token
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get the current user token
|
|
107
|
+
*/
|
|
108
|
+
function getUserToken () {
|
|
109
|
+
// Only single shared user supported currently
|
|
110
|
+
return activeTokens._?.access_token
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Delete a users token, clears up refresh timers and updates saved tokens
|
|
115
|
+
* @param {*} user
|
|
116
|
+
*/
|
|
117
|
+
async function deleteUserToken (user) {
|
|
118
|
+
const token = activeTokens[user]
|
|
119
|
+
if (token) {
|
|
120
|
+
clearTimeout(activeTimers[user])
|
|
121
|
+
}
|
|
122
|
+
delete activeTokens[user]
|
|
123
|
+
delete activeTimers[user]
|
|
124
|
+
saveTokens()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Make an API request to refresh the user token
|
|
129
|
+
* @param {*} token
|
|
130
|
+
*/
|
|
131
|
+
async function refreshToken (token) {
|
|
132
|
+
const params = {
|
|
133
|
+
grant_type: 'refresh_token',
|
|
134
|
+
client_id: 'ff-plugin',
|
|
135
|
+
refresh_token: token.refresh_token
|
|
136
|
+
}
|
|
137
|
+
return got.post(tokenURL(), {
|
|
138
|
+
headers: {
|
|
139
|
+
'Content-Type': 'application/json'
|
|
140
|
+
},
|
|
141
|
+
json: params
|
|
142
|
+
}).then(async (result) => {
|
|
143
|
+
return JSON.parse(result.body)
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function setupRoutes (_assistant, RED) {
|
|
148
|
+
assistant = _assistant
|
|
149
|
+
RED.httpAdmin.get('/nr-assistant/auth/authorize', (request, response) => {
|
|
150
|
+
const existingRequest = store.getRequest(request.query.s)
|
|
151
|
+
if (!existingRequest) {
|
|
152
|
+
return response.send(404)
|
|
153
|
+
}
|
|
154
|
+
const verifier = base64url(crypto.randomBytes(32))
|
|
155
|
+
const scope = 'ff-assistant'
|
|
156
|
+
store.storeRequest({ ...existingRequest, verifier, scope })
|
|
157
|
+
const params = {}
|
|
158
|
+
params.client_id = 'ff-plugin'
|
|
159
|
+
params.scope = scope
|
|
160
|
+
params.response_type = 'code'
|
|
161
|
+
params.state = existingRequest.state
|
|
162
|
+
params.code_challenge = base64url(crypto.createHash('sha256').update(verifier).digest())
|
|
163
|
+
params.code_challenge_method = 'S256'
|
|
164
|
+
params.redirect_uri = existingRequest.redirect_uri
|
|
165
|
+
const authURL = new URL(authorizationURL())
|
|
166
|
+
authURL.search = new URLSearchParams(params)
|
|
167
|
+
response.redirect(authURL.toString())
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
RED.httpAdmin.get('/nr-assistant/auth/callback', async (request, response) => {
|
|
171
|
+
if (request.query.error) {
|
|
172
|
+
const postMessage = JSON.stringify({ code: 'flowfuse-auth-error', error: request.query.error, message: request.query.errorDescription })
|
|
173
|
+
response.send(`
|
|
174
|
+
<html><head>
|
|
175
|
+
<script>
|
|
176
|
+
if (window.opener) {
|
|
177
|
+
window.opener.postMessage('${postMessage}', '*')
|
|
178
|
+
window.close()
|
|
179
|
+
}
|
|
180
|
+
</script>
|
|
181
|
+
</head><body>Failed to complete authentication.</body></html>
|
|
182
|
+
`)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
if (!request.query.code || !request.query.state) {
|
|
186
|
+
response.send('Failed to complete authentication')
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
const originalRequest = store.getRequest(request.query.state)
|
|
190
|
+
if (!originalRequest) {
|
|
191
|
+
response.send('Failed to complete authentication - unknown state')
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const params = {}
|
|
196
|
+
params.grant_type = 'authorization_code'
|
|
197
|
+
params.code = request.query.code
|
|
198
|
+
params.redirect_uri = originalRequest.redirect_uri
|
|
199
|
+
params.client_id = 'ff-plugin'
|
|
200
|
+
params.code_verifier = originalRequest.verifier
|
|
201
|
+
|
|
202
|
+
got.post(tokenURL(), {
|
|
203
|
+
headers: {
|
|
204
|
+
'Content-Type': 'application/json'
|
|
205
|
+
},
|
|
206
|
+
json: params
|
|
207
|
+
}).then(async (result) => {
|
|
208
|
+
const tokens = JSON.parse(result.body)
|
|
209
|
+
await setUserToken(originalRequest.user, tokens)
|
|
210
|
+
const postMessage = JSON.stringify({ code: 'flowfuse-auth-complete', state: originalRequest.state })
|
|
211
|
+
response.send(`
|
|
212
|
+
<html><head>
|
|
213
|
+
<script>
|
|
214
|
+
if (window.opener) {
|
|
215
|
+
window.opener.postMessage('${postMessage}', '*')
|
|
216
|
+
window.close()
|
|
217
|
+
}
|
|
218
|
+
</script>
|
|
219
|
+
</head><body>Success! You're connected to FlowFuse. You can now close this window to continue.</body></html>
|
|
220
|
+
`)
|
|
221
|
+
// Now we have a token for the user, reinitialise the assistant to enable it
|
|
222
|
+
reinitialiseAssistant()
|
|
223
|
+
}).catch((error) => {
|
|
224
|
+
console.warn('request failed', error)
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
RED.httpAdmin.use('/nr-assistant/*', RED.auth.needsPermission('flowfuse.write'))
|
|
229
|
+
|
|
230
|
+
RED.httpAdmin.post('/nr-assistant/auth/start', async (request, response) => {
|
|
231
|
+
// This request is made from the editor, so will have the Node-RED user attached.
|
|
232
|
+
// Generate the login url for the auth pop-up window
|
|
233
|
+
// if (request.body.forgeURL) {
|
|
234
|
+
// request.body.forgeURL = request.body.forgeURL.replace(/\/$/, '')
|
|
235
|
+
// settings.set('forgeURL', request.body.forgeURL)
|
|
236
|
+
// }
|
|
237
|
+
|
|
238
|
+
// Ping the server to check it is responsive and looks like a valid FF endpoint
|
|
239
|
+
got.get(`${baseURL}/api/v1/settings`).then(result => {
|
|
240
|
+
const state = base64url(crypto.randomBytes(16))
|
|
241
|
+
const redirect = request.body.editorURL + (request.body.editorURL.endsWith('/') ? '' : '/') + 'nr-assistant/auth/callback'
|
|
242
|
+
store.storeRequest({
|
|
243
|
+
user: '_',
|
|
244
|
+
state,
|
|
245
|
+
redirect_uri: redirect
|
|
246
|
+
})
|
|
247
|
+
const authPath = 'nr-assistant/auth/authorize?s=' + state
|
|
248
|
+
response.send({ path: authPath, state })
|
|
249
|
+
}).catch(err => {
|
|
250
|
+
RED.log.error(`[nr-assistant] Failed to connect to server: ${err.toString()}`)
|
|
251
|
+
response.send({ error: err.toString(), code: 'connect_failed' })
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
RED.httpAdmin.post('/nr-assistant/auth/disconnect', async (request, response) => {
|
|
255
|
+
deleteUserToken('_')
|
|
256
|
+
response.send({ })
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function reinitialiseAssistant () {
|
|
261
|
+
if (assistant && assistant.RED) {
|
|
262
|
+
const newSettings = await require('../settings').getSettings(assistant.RED)
|
|
263
|
+
assistant.init(assistant.RED, newSettings).then(() => {
|
|
264
|
+
// All good, the assistant is initialized.
|
|
265
|
+
// Any info messages made during initialization are logged in the assistant module
|
|
266
|
+
}).catch((error) => {
|
|
267
|
+
console.error(error)
|
|
268
|
+
assistant.RED.log.error('Failed to initialize FlowFuse Assistant Plugin:', error)
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
module.exports = {
|
|
273
|
+
init,
|
|
274
|
+
setupRoutes,
|
|
275
|
+
getUserToken,
|
|
276
|
+
setStaticToken
|
|
277
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const pendingRequests = {}
|
|
2
|
+
|
|
3
|
+
function storeRequest (request) {
|
|
4
|
+
pruneRequests()
|
|
5
|
+
request.ttl = Date.now() + 1000 * 60 * 5 // 5 minute ttl
|
|
6
|
+
pendingRequests[request.state] = request
|
|
7
|
+
}
|
|
8
|
+
function getRequest (state) {
|
|
9
|
+
pruneRequests()
|
|
10
|
+
const result = pendingRequests[state]
|
|
11
|
+
delete pendingRequests[state]
|
|
12
|
+
return result
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function deleteRequest (state) {
|
|
16
|
+
pruneRequests()
|
|
17
|
+
delete pendingRequests[state]
|
|
18
|
+
}
|
|
19
|
+
function pruneRequests () {
|
|
20
|
+
const now = Date.now()
|
|
21
|
+
for (const [key, value] of Object.entries(pendingRequests)) {
|
|
22
|
+
if (value.ttl < now) {
|
|
23
|
+
delete pendingRequests[key]
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = {
|
|
29
|
+
getRequest,
|
|
30
|
+
storeRequest,
|
|
31
|
+
deleteRequest,
|
|
32
|
+
export: () => { return { ...pendingRequests } }
|
|
33
|
+
}
|
package/lib/settings.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const auth = require('./auth/index.js')
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* @typedef {Object} AssistantSettings
|
|
3
5
|
* @property {boolean} enabled - Whether the Assistant is enabled
|
|
@@ -20,26 +22,61 @@ module.exports = {
|
|
|
20
22
|
* @param {Object} RED - The RED instance
|
|
21
23
|
* @returns {AssistantSettings} - The Assistant settings
|
|
22
24
|
*/
|
|
23
|
-
getSettings: (RED) => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
25
|
+
getSettings: async (RED) => {
|
|
26
|
+
if (RED.settings.flowforge?.assistant === undefined) {
|
|
27
|
+
// No settings provided via settings.js; enable standalone mode
|
|
28
|
+
const token = auth.getUserToken()
|
|
29
|
+
if (token) {
|
|
30
|
+
// There is a locally stored token, so enable the assistant with default settings
|
|
31
|
+
const baseURL = RED.settings.flowfuse?.assistant?.url || 'https://app.flowfuse.com'
|
|
32
|
+
return {
|
|
33
|
+
url: baseURL + '/api/v1/assistant/',
|
|
34
|
+
enabled: true,
|
|
35
|
+
standalone: true,
|
|
36
|
+
mcp: { enabled: true },
|
|
37
|
+
completions: {
|
|
38
|
+
enabled: true,
|
|
39
|
+
// TODO: what should these be?
|
|
40
|
+
modelUrl: null,
|
|
41
|
+
vocabularyUrl: null,
|
|
42
|
+
inlineEnabled: true
|
|
43
|
+
},
|
|
44
|
+
// Tables is not available outside of FF
|
|
45
|
+
tables: { enabled: false },
|
|
46
|
+
inlineCompletions: { enabled: true }
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
// Not token, disable the assistant but in standalone mode
|
|
50
|
+
return {
|
|
51
|
+
enabled: false,
|
|
52
|
+
standalone: true
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
const assistantSettings = RED.settings.flowforge?.assistant || {}
|
|
57
|
+
if (assistantSettings.enabled !== true) {
|
|
58
|
+
assistantSettings.enabled = false
|
|
59
|
+
assistantSettings.completions = null // if the assistant is not enabled, completions should not be enabled
|
|
60
|
+
}
|
|
61
|
+
assistantSettings.mcp = assistantSettings.mcp || {
|
|
62
|
+
enabled: true // default to enabled
|
|
63
|
+
}
|
|
64
|
+
assistantSettings.completions = assistantSettings.completions || {
|
|
65
|
+
enabled: true, // default to enabled
|
|
66
|
+
modelUrl: null,
|
|
67
|
+
vocabularyUrl: null
|
|
68
|
+
}
|
|
69
|
+
assistantSettings.tables = {
|
|
70
|
+
enabled: !!(RED.settings.flowforge?.tables?.token) // for MVP, use the presence of a token is an indicator that tables are enabled
|
|
71
|
+
}
|
|
72
|
+
assistantSettings.inlineCompletions = {
|
|
73
|
+
enabled: !!assistantSettings.completions.inlineEnabled
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (assistantSettings.token) {
|
|
77
|
+
auth.setStaticToken(assistantSettings.token)
|
|
78
|
+
}
|
|
79
|
+
return assistantSettings
|
|
42
80
|
}
|
|
43
|
-
return assistantSettings
|
|
44
81
|
}
|
|
45
82
|
}
|