@flowfuse/nr-assistant 0.5.1-78995c8-202508280848.0 → 0.5.1-ee8d657-202509051329.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/README.md +13 -0
- package/completions.html +1 -1
- package/index.html +535 -57
- package/lib/assistant.js +73 -6
- package/lib/settings.js +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ This plugin is designed to assist users of the FlowFuse platform by providing to
|
|
|
17
17
|
* JSON generation in all typed inputs and JSON editors (like the inject node, change node, template node, etc)
|
|
18
18
|
* Flows Explainer
|
|
19
19
|
* HTML, VUE, and CSS generation in FlowFuse Dashboard ui-template nodes
|
|
20
|
+
* Context-aware inline and multi-line code completions for functions, templates, and tables
|
|
20
21
|
|
|
21
22
|
### Function Builder
|
|
22
23
|

|
|
@@ -34,6 +35,18 @@ This plugin is designed to assist users of the FlowFuse platform by providing to
|
|
|
34
35
|

|
|
35
36
|
|
|
36
37
|
|
|
38
|
+
### Inline Code Completions
|
|
39
|
+
NOTE: This feature will be limited to pro and enterprise tiers.
|
|
40
|
+
|
|
41
|
+
#### functions
|
|
42
|
+

|
|
43
|
+
|
|
44
|
+
#### templates
|
|
45
|
+

|
|
46
|
+
|
|
47
|
+
#### tables
|
|
48
|
+

|
|
49
|
+
|
|
37
50
|
## Installation
|
|
38
51
|
|
|
39
52
|
```bash
|
package/completions.html
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<script src="
|
|
1
|
+
<script src="resources/@flowfuse/nr-assistant/sharedUtils.js"></script>
|
|
2
2
|
<script>
|
|
3
3
|
/* global FFAssistantUtils */ /* loaded from sharedUtils.js */
|
|
4
4
|
/* global RED, $ */ /* loaded from Node-RED core */
|
package/index.html
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<script src="
|
|
1
|
+
<script src="resources/@flowfuse/nr-assistant/sharedUtils.js"></script>
|
|
2
2
|
<script>
|
|
3
3
|
/* global FFAssistantUtils */ /* loaded from sharedUtils.js */
|
|
4
4
|
/* global RED, $ */ /* loaded from Node-RED core */
|
|
@@ -13,6 +13,24 @@
|
|
|
13
13
|
* @property {string} [selectedText] - Any selected text. // future feature
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} MethodBodyData
|
|
18
|
+
* @property {string} prompt - The prompt to be sent to the API.
|
|
19
|
+
* @property {string} transactionId - The transaction ID for the request.
|
|
20
|
+
* @property {MethodBodyDataContext} [context] - Context data to accompany the request.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {Object} MethodBodyDataContext
|
|
24
|
+
* @property {string} [nodeName] - The nodes label
|
|
25
|
+
* @property {string} [type] - The type of the context (e.g. 'function', 'template').
|
|
26
|
+
* @property {string} [subType] - The sub-type of the context (e.g. 'on-start', 'on-message').
|
|
27
|
+
* @property {string} [codeSection] - The code section of the context (alias for sub-type)
|
|
28
|
+
* @property {string} [scope] - The scope of the context (e.g. 'fim' (Fill-in-the-middle), 'inline' (inline codelens), 'node' (generate a node), 'flow' (generate a flow)).
|
|
29
|
+
* @property {Array<string>} [outputs] - The count of outputs the node has (typically only for function nodes)
|
|
30
|
+
* @property {Boolean} [modulesAllowed] - Whether external modules are allowed in the function node
|
|
31
|
+
* @property {Array<{module:string, var:string}>} [modules] - The list of modules setup/available (typically only for function nodes)
|
|
32
|
+
*/
|
|
33
|
+
|
|
16
34
|
/**
|
|
17
35
|
* @typedef {Object} PromptUIOptions
|
|
18
36
|
* @property {string} title - The title of the FlowFuse Assistant.
|
|
@@ -21,15 +39,19 @@
|
|
|
21
39
|
*/
|
|
22
40
|
|
|
23
41
|
const AI_TIMEOUT = 90000 // default request timeout in milliseconds
|
|
42
|
+
const FIM_TIMEOUT = 5000 // A default, hard upper bound timeout for FIM inline completions
|
|
24
43
|
const modulesAllowed = RED.settings.functionExternalModules !== false
|
|
25
44
|
const assistantOptions = {
|
|
26
45
|
enabled: false,
|
|
27
46
|
tablesEnabled: false,
|
|
47
|
+
inlineCompletionsEnabled: false,
|
|
28
48
|
requestTimeout: AI_TIMEOUT
|
|
29
49
|
}
|
|
50
|
+
let initialisedInterlock = false
|
|
51
|
+
let mcpReadyInterlock = false
|
|
30
52
|
let assistantInitialised = false
|
|
31
53
|
let mcpReady = false
|
|
32
|
-
debug('
|
|
54
|
+
debug('Loading Node-RED Assistant Plugin...')
|
|
33
55
|
const plugin = {
|
|
34
56
|
type: 'assistant',
|
|
35
57
|
name: 'Node-RED Assistant Plugin',
|
|
@@ -40,15 +62,18 @@
|
|
|
40
62
|
}
|
|
41
63
|
RED.comms.subscribe('nr-assistant/#', (topic, msg) => {
|
|
42
64
|
debug('comms', topic, msg)
|
|
43
|
-
if (topic === 'nr-assistant/initialise') {
|
|
65
|
+
if (topic === 'nr-assistant/initialise' && !initialisedInterlock) {
|
|
66
|
+
initialisedInterlock = true
|
|
44
67
|
assistantOptions.enabled = !!msg?.enabled
|
|
45
68
|
assistantOptions.requestTimeout = msg?.requestTimeout || AI_TIMEOUT
|
|
46
69
|
assistantOptions.tablesEnabled = msg?.tablesEnabled === true
|
|
70
|
+
assistantOptions.inlineCompletionsEnabled = msg?.inlineCompletionsEnabled === true
|
|
47
71
|
initAssistant(msg)
|
|
48
72
|
RED.actions.add('flowfuse-nr-assistant:function-builder', showFunctionBuilderPrompt, { label: '@flowfuse/nr-assistant/flowfuse-nr-assistant:function-builder.action.label' })
|
|
49
73
|
setMenuShortcutKey('ff-assistant-function-builder', 'red-ui-workspace', 'ctrl-alt-f', 'flowfuse-nr-assistant:function-builder')
|
|
50
74
|
}
|
|
51
|
-
if (topic === 'nr-assistant/mcp/ready') {
|
|
75
|
+
if (topic === 'nr-assistant/mcp/ready' && !mcpReadyInterlock) {
|
|
76
|
+
mcpReadyInterlock = true
|
|
52
77
|
mcpReady = !!msg?.enabled && assistantOptions.enabled
|
|
53
78
|
if (mcpReady) {
|
|
54
79
|
debug('assistant MCP initialised')
|
|
@@ -329,40 +354,14 @@
|
|
|
329
354
|
return
|
|
330
355
|
}
|
|
331
356
|
|
|
332
|
-
|
|
333
|
-
let subType = 'on message'
|
|
334
|
-
let parent = thisEditor.getDomNode().parentNode
|
|
335
|
-
while (parent?.tagName !== 'FORM') {
|
|
336
|
-
if (parent.id) {
|
|
337
|
-
break
|
|
338
|
-
}
|
|
339
|
-
parent = parent.parentNode
|
|
340
|
-
}
|
|
341
|
-
switch (parent?.id) {
|
|
342
|
-
case 'func-tab-init':
|
|
343
|
-
case 'node-input-init-editor':
|
|
344
|
-
subType = 'on start'
|
|
345
|
-
break
|
|
346
|
-
case 'func-tab-body':
|
|
347
|
-
case 'node-input-func-editor':
|
|
348
|
-
subType = 'on message'
|
|
349
|
-
break
|
|
350
|
-
case 'func-tab-finalize':
|
|
351
|
-
case 'node-input-finalize-editor':
|
|
352
|
-
subType = 'on message'
|
|
353
|
-
break
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// FUTURE: for including selected text in the context for features like "fix my code", "refactor this", "what is this?" etc
|
|
357
|
-
// const userSelection = triggeredEditor.getSelection()
|
|
358
|
-
// const selectedText = model.getValueInRange(userSelection)
|
|
357
|
+
const subType = getFunctionNodeEditorCodeSection(thisEditor)
|
|
359
358
|
/** @type {PromptOptions} */
|
|
360
359
|
const promptOptions = {
|
|
361
360
|
method: 'function',
|
|
362
361
|
lang: 'javascript',
|
|
363
362
|
type: 'function',
|
|
364
|
-
subType
|
|
365
|
-
|
|
363
|
+
subType,
|
|
364
|
+
codeSection: subType
|
|
366
365
|
}
|
|
367
366
|
/** @type {PromptUIOptions} */
|
|
368
367
|
const uiOptions = {
|
|
@@ -406,27 +405,9 @@
|
|
|
406
405
|
// if the lib is not already in the list, add it
|
|
407
406
|
if (modulesAllowed) {
|
|
408
407
|
if (Array.isArray(responseData?.node_modules) && responseData.node_modules.length > 0) {
|
|
409
|
-
const
|
|
410
|
-
const libs = $('#node-input-libs-container').editableList('items')
|
|
411
|
-
libs.each(function (i) {
|
|
412
|
-
const item = $(this)
|
|
413
|
-
const v = item.find('.node-input-libs-var').val()
|
|
414
|
-
let n = item.find('.node-input-libs-val').typedInput('type')
|
|
415
|
-
if (n === '_custom_') {
|
|
416
|
-
n = item.find('.node-input-libs-val').val()
|
|
417
|
-
}
|
|
418
|
-
if ((!v || (v === '')) ||
|
|
419
|
-
(!n || (n === ''))) {
|
|
420
|
-
return
|
|
421
|
-
}
|
|
422
|
-
_libs.push({
|
|
423
|
-
var: v,
|
|
424
|
-
module: n
|
|
425
|
-
})
|
|
426
|
-
})
|
|
427
|
-
|
|
408
|
+
const currentModulesInSetup = getFunctionNodeModules()
|
|
428
409
|
responseData.node_modules.forEach((lib) => {
|
|
429
|
-
const existing =
|
|
410
|
+
const existing = currentModulesInSetup.find(l => l.module === lib.module)
|
|
430
411
|
if (!existing) {
|
|
431
412
|
$('#node-input-libs-container').editableList('addItem', { var: lib.var, module: lib.module })
|
|
432
413
|
}
|
|
@@ -651,7 +632,7 @@
|
|
|
651
632
|
const promptOptions = {
|
|
652
633
|
method: 'flowfuse-tables-query',
|
|
653
634
|
lang: 'sql',
|
|
654
|
-
dialect: '
|
|
635
|
+
dialect: 'postgres',
|
|
655
636
|
type: node.type
|
|
656
637
|
// selectedText: model.getValueInRange(userSelection)
|
|
657
638
|
}
|
|
@@ -689,6 +670,362 @@
|
|
|
689
670
|
}
|
|
690
671
|
})
|
|
691
672
|
|
|
673
|
+
debug('registering inline completions')
|
|
674
|
+
|
|
675
|
+
if (assistantOptions.inlineCompletionsEnabled) {
|
|
676
|
+
const stateByLanguage = {}
|
|
677
|
+
const supportedInlineCompletions = [
|
|
678
|
+
{
|
|
679
|
+
languageId: 'javascript',
|
|
680
|
+
nodeType: 'function',
|
|
681
|
+
nodeModule: 'node-red'
|
|
682
|
+
},
|
|
683
|
+
{
|
|
684
|
+
languageId: 'css',
|
|
685
|
+
nodeType: 'ui-template',
|
|
686
|
+
nodeModule: '@flowfuse/node-red-dashboard'
|
|
687
|
+
},
|
|
688
|
+
{
|
|
689
|
+
languageId: 'html',
|
|
690
|
+
nodeType: 'ui-template',
|
|
691
|
+
nodeModule: '@flowfuse/node-red-dashboard'
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
languageId: 'sql',
|
|
695
|
+
nodeType: 'tables-query',
|
|
696
|
+
nodeModule: '@flowfuse/nr-tables-nodes'
|
|
697
|
+
}
|
|
698
|
+
]
|
|
699
|
+
const getState = (languageId) => {
|
|
700
|
+
if (!stateByLanguage[languageId]) {
|
|
701
|
+
stateByLanguage[languageId] = {
|
|
702
|
+
MAX_FIFO_SIZE: 1, // MVP - only 1 suggestion is supported
|
|
703
|
+
suggestions: [],
|
|
704
|
+
lastSuggestion: null,
|
|
705
|
+
inflightRequest: null
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return stateByLanguage[languageId]
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const getContext = (node, editor, position) => {
|
|
712
|
+
const model = editor.getModel()
|
|
713
|
+
/** @type {MethodBodyDataContext} */
|
|
714
|
+
const context = {
|
|
715
|
+
scope: 'fim',
|
|
716
|
+
languageId: model.getLanguageIdAtPosition(position.lineNumber, position.column),
|
|
717
|
+
nodeName: node.name || node.type,
|
|
718
|
+
nodeType: node.type,
|
|
719
|
+
nodeModule: node._def?.set?.module || 'node-red'
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
context.outputs = +(node.outputs || 1)
|
|
723
|
+
if (isNaN(context.outputs) || context.outputs < 1) {
|
|
724
|
+
context.outputs = 1
|
|
725
|
+
}
|
|
726
|
+
if (node.type === 'function') {
|
|
727
|
+
context.subType = getFunctionNodeEditorCodeSection(editor)
|
|
728
|
+
context.codeSection = context.subType
|
|
729
|
+
context.modulesAllowed = modulesAllowed
|
|
730
|
+
context.modules = modulesAllowed ? getFunctionNodeModules() : []
|
|
731
|
+
}
|
|
732
|
+
if (node.type === 'tables-query') {
|
|
733
|
+
context.dialect = 'postgres'
|
|
734
|
+
}
|
|
735
|
+
return context
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ---------------- Language Strategies ----------------
|
|
739
|
+
const languageStrategies = {
|
|
740
|
+
javascript: {
|
|
741
|
+
adjustIndentation (model, position, suggestionText) {
|
|
742
|
+
const currentIndent = model.getLineContent(position.lineNumber).match(/^\s*/)?.[0] ?? ''
|
|
743
|
+
return suggestionText.split('\n').map((line, idx) =>
|
|
744
|
+
idx === 0 ? line : currentIndent + line.trimEnd()
|
|
745
|
+
).join('\n')
|
|
746
|
+
}
|
|
747
|
+
},
|
|
748
|
+
css: {
|
|
749
|
+
adjustIndentation (model, position, suggestionText) {
|
|
750
|
+
const currentIndent = model.getLineContent(position.lineNumber).match(/^\s*/)?.[0] ?? ''
|
|
751
|
+
return suggestionText.split('\n').map((line, idx) =>
|
|
752
|
+
idx === 0 ? line : currentIndent + line.trimEnd()
|
|
753
|
+
).join('\n')
|
|
754
|
+
}
|
|
755
|
+
},
|
|
756
|
+
html: {
|
|
757
|
+
adjustIndentation (model, position, suggestionText) {
|
|
758
|
+
const lineContent = model.getLineContent(position.lineNumber).trim()
|
|
759
|
+
const currentIndent = model.getLineContent(position.lineNumber).match(/^\s*/)?.[0] ?? ''
|
|
760
|
+
const extraIndent =
|
|
761
|
+
lineContent.endsWith('>') && !lineContent.endsWith('/>')
|
|
762
|
+
? ' '
|
|
763
|
+
: ''
|
|
764
|
+
return suggestionText.split('\n').map((line, idx) =>
|
|
765
|
+
idx === 0 ? line : currentIndent + extraIndent + line.trimEnd()
|
|
766
|
+
).join('\n')
|
|
767
|
+
}
|
|
768
|
+
},
|
|
769
|
+
sql: {
|
|
770
|
+
adjustIndentation (_model, _position, suggestionText) {
|
|
771
|
+
// SQL: flat, no indent
|
|
772
|
+
return suggestionText.split('\n').map((l) => l.trim()).join('\n')
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// #region "Inline Completion Helper Functions"
|
|
778
|
+
const computeInlineCompletionRange = (model, position, suggestionText) => {
|
|
779
|
+
const lineContent = model.getLineContent(position.lineNumber)
|
|
780
|
+
|
|
781
|
+
// Text before and after the cursor
|
|
782
|
+
const beforeCursor = lineContent.substring(0, position.column - 1)
|
|
783
|
+
const afterCursor = lineContent.substring(position.column - 1)
|
|
784
|
+
|
|
785
|
+
// 1. Backward overlap (user already typed some of the suggestion)
|
|
786
|
+
let backOverlap = 0
|
|
787
|
+
for (let i = 0; i < suggestionText.length; i++) {
|
|
788
|
+
if (beforeCursor.endsWith(suggestionText.substring(0, i + 1))) {
|
|
789
|
+
backOverlap = i + 1
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// 2. Forward overlap (document already contains trailing part of suggestion)
|
|
794
|
+
let forwardOverlap = 0
|
|
795
|
+
const firstNewline = suggestionText.indexOf('\n')
|
|
796
|
+
const suggestionEnd = firstNewline === -1 ? suggestionText : suggestionText.substring(0, firstNewline)
|
|
797
|
+
|
|
798
|
+
for (let i = 0; i < suggestionEnd.length; i++) {
|
|
799
|
+
if (afterCursor.startsWith(suggestionEnd.substring(suggestionEnd.length - (i + 1)))) {
|
|
800
|
+
forwardOverlap = i + 1
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
return new monaco.Range(
|
|
805
|
+
position.lineNumber,
|
|
806
|
+
position.column - backOverlap,
|
|
807
|
+
position.lineNumber,
|
|
808
|
+
position.column + forwardOverlap
|
|
809
|
+
)
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const trimDuplicates = (model, position, suggestionText) => {
|
|
813
|
+
// Grab a small lookahead (e.g. next 3 lines of code after cursor)
|
|
814
|
+
const lookaheadLines = Math.min(position.lineNumber + 3, model.getLineCount())
|
|
815
|
+
const lookahead = model.getValueInRange({
|
|
816
|
+
startLineNumber: position.lineNumber,
|
|
817
|
+
startColumn: 1,
|
|
818
|
+
endLineNumber: lookaheadLines,
|
|
819
|
+
endColumn: model.getLineMaxColumn(lookaheadLines)
|
|
820
|
+
})
|
|
821
|
+
const lookaheadNormalized = lookahead.trimStart()
|
|
822
|
+
let trimmed = suggestionText.trimEnd()
|
|
823
|
+
|
|
824
|
+
// Simple heuristic: if suggestion ends with same text as lookahead, drop it
|
|
825
|
+
if (lookaheadNormalized.startsWith(trimmed.split('\n').slice(-1)[0])) {
|
|
826
|
+
debug('Trimming duplicate text from suggestion (end matches lookahead)', { lookaheadNormalized, suggestionText })
|
|
827
|
+
const lines = trimmed.split('\n')
|
|
828
|
+
lines.pop() // remove the duplicate trailing line
|
|
829
|
+
trimmed = lines.join('\n')
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
return trimmed
|
|
833
|
+
}
|
|
834
|
+
// #endregion "Inline Completion Helper Functions"
|
|
835
|
+
|
|
836
|
+
// --------------- Fetch Wrapper ----------------
|
|
837
|
+
const fetchAICompletion = (options, node, editor, model, position, resolve) => {
|
|
838
|
+
const state = getState(options.languageId)
|
|
839
|
+
|
|
840
|
+
// inhibit new request if one is already running
|
|
841
|
+
if (state.inflightRequest) {
|
|
842
|
+
debug('Skipping FIM request, one already in-flight')
|
|
843
|
+
return
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const fullRange = model.getFullModelRange()
|
|
847
|
+
|
|
848
|
+
const fimPrefix = model.getValueInRange({
|
|
849
|
+
startLineNumber: 1,
|
|
850
|
+
startColumn: 1,
|
|
851
|
+
endLineNumber: position.lineNumber,
|
|
852
|
+
endColumn: position.column
|
|
853
|
+
})
|
|
854
|
+
const fimSuffix = model.getValueInRange({
|
|
855
|
+
startLineNumber: position.lineNumber,
|
|
856
|
+
startColumn: position.column,
|
|
857
|
+
endLineNumber: fullRange.endLineNumber,
|
|
858
|
+
endColumn: fullRange.endColumn
|
|
859
|
+
})
|
|
860
|
+
|
|
861
|
+
/** @type {MethodBodyData} */
|
|
862
|
+
const data = {
|
|
863
|
+
prompt: `${fimPrefix}<|fim_completion|>${fimSuffix}`,
|
|
864
|
+
transactionId: generateId(8) + '-' + Date.now(),
|
|
865
|
+
context: getContext(node, editor, position)
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const req = $.ajax({
|
|
869
|
+
url: `nr-assistant/fim/${encodeURIComponent(options.nodeModule)}/${encodeURIComponent(options.nodeType)}`,
|
|
870
|
+
type: 'POST',
|
|
871
|
+
data: JSON.stringify(data),
|
|
872
|
+
contentType: 'application/json',
|
|
873
|
+
timeout: FIM_TIMEOUT
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
state.inflightRequest = req
|
|
877
|
+
|
|
878
|
+
req.then((res) => {
|
|
879
|
+
const responseData = res?.data?.data
|
|
880
|
+
if (!responseData?.fim_completion) return
|
|
881
|
+
|
|
882
|
+
// before accessing the model or doing any further processing, lets check that the
|
|
883
|
+
// editor/model are still in dom/un-disposed. This can happen when the user closes
|
|
884
|
+
// the node edit panel while a request is in flight
|
|
885
|
+
if (!model || model.isDisposed()) {
|
|
886
|
+
debug('Skipping FIM response processing: Model has been disposed')
|
|
887
|
+
return
|
|
888
|
+
}
|
|
889
|
+
const { editor: checkEditor } = getEditorAndNode(model)
|
|
890
|
+
if (!checkEditor) {
|
|
891
|
+
debug('Skipping FIM response processing: Editor has been disposed')
|
|
892
|
+
return
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const strategy = languageStrategies[options.languageId] || languageStrategies.javascript
|
|
896
|
+
let aiText = strategy.adjustIndentation(model, position, responseData.fim_completion)
|
|
897
|
+
aiText = trimDuplicates(model, position, aiText)
|
|
898
|
+
|
|
899
|
+
const completionRange = computeInlineCompletionRange(model, position, aiText)
|
|
900
|
+
const suggestion = {
|
|
901
|
+
insertText: aiText,
|
|
902
|
+
range: completionRange,
|
|
903
|
+
meta: { fimPrefix, fimSuffix }
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const currentPrefix = model.getValueInRange({
|
|
907
|
+
startLineNumber: 1,
|
|
908
|
+
startColumn: 1,
|
|
909
|
+
endLineNumber: position.lineNumber,
|
|
910
|
+
endColumn: position.column
|
|
911
|
+
})
|
|
912
|
+
|
|
913
|
+
const typedSinceRequest = currentPrefix.slice(fimPrefix.length)
|
|
914
|
+
if (aiText.startsWith(typedSinceRequest)) {
|
|
915
|
+
state.suggestions.unshift(suggestion)
|
|
916
|
+
if (state.suggestions.length > state.MAX_FIFO_SIZE) {
|
|
917
|
+
state.suggestions.pop()
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
state.lastSuggestion = {
|
|
921
|
+
fullText: aiText,
|
|
922
|
+
typedSoFar: typedSinceRequest,
|
|
923
|
+
startLine: position.lineNumber,
|
|
924
|
+
startColumn: position.column
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
resolve({ items: [suggestion] })
|
|
928
|
+
}
|
|
929
|
+
})
|
|
930
|
+
.fail((_xhr, textStatus, errorThrown) => {
|
|
931
|
+
// completions can fail for many reasons but it is not necessary to log it
|
|
932
|
+
// to the console just carry on regardless. However, for debugging purposes
|
|
933
|
+
// you can set flag RED.nrAssistant = { DEBUG: true } in the console
|
|
934
|
+
debug('FIM AI request failed: ' + textStatus, errorThrown)
|
|
935
|
+
})
|
|
936
|
+
.always(() => {
|
|
937
|
+
state.inflightRequest = null
|
|
938
|
+
})
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// ---------------- Provider Factory ----------------
|
|
942
|
+
const registerAIInlineCompletions = (options) => {
|
|
943
|
+
const { languageId, nodeType, nodeModule } = options
|
|
944
|
+
debug('Registering inline completions for', { languageId, nodeType, nodeModule })
|
|
945
|
+
monaco.languages.registerInlineCompletionsProvider(languageId, {
|
|
946
|
+
provideInlineCompletions: (model, position, context, token) =>
|
|
947
|
+
new Promise((resolve) => {
|
|
948
|
+
debug('provideInlineCompletions: handling inline completion for languageId ' + languageId, position, context, token)
|
|
949
|
+
|
|
950
|
+
// prep - get the editor and node, exit if not found or not supported
|
|
951
|
+
const { editor, node } = getEditorAndNode(model)
|
|
952
|
+
if (!editor || !node) { return }
|
|
953
|
+
if (node.type !== nodeType) { return }
|
|
954
|
+
if (!node || node.type !== nodeType || node._def?.set?.module !== nodeModule) {
|
|
955
|
+
debug('Node type does not support inline completions. Type:', node?.type, 'def:', node?._def?.set?.id, 'expected def:', `${nodeModule}/${nodeType}`)
|
|
956
|
+
return
|
|
957
|
+
}
|
|
958
|
+
if (isInsideComment(model, position)) {
|
|
959
|
+
debug('Skipping AI completion: cursor is inside a comment')
|
|
960
|
+
return resolve({ items: [] })
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const state = getState(languageId)
|
|
964
|
+
|
|
965
|
+
if (
|
|
966
|
+
context.triggerKind === monaco.languages.InlineCompletionTriggerKind.Automatic &&
|
|
967
|
+
context.selectedSuggestionInfo
|
|
968
|
+
) {
|
|
969
|
+
debug('User just accepted a suggestion, skipping this event')
|
|
970
|
+
return
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
if (context.triggerKind === monaco.languages.InlineCompletionTriggerKind.Automatic) {
|
|
974
|
+
// see if the full code now matches the prefix+suggestion+suffix, if yes, then it has just been accepted.
|
|
975
|
+
const suggestion = state.suggestions[0]
|
|
976
|
+
if (suggestion && (suggestion.meta.fimPrefix + suggestion.insertText + suggestion.meta.fimSuffix) === model.getValue()) {
|
|
977
|
+
debug('Alt User just accepted a suggestion, skipping this event')
|
|
978
|
+
resolve({ items: [] })
|
|
979
|
+
return
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// reuse last suggestion if possible
|
|
984
|
+
if (state.lastSuggestion?.fullText) {
|
|
985
|
+
const prefix = model.getValueInRange({
|
|
986
|
+
startLineNumber: state.lastSuggestion.startLine,
|
|
987
|
+
startColumn: state.lastSuggestion.startColumn,
|
|
988
|
+
endLineNumber: position.lineNumber,
|
|
989
|
+
endColumn: position.column
|
|
990
|
+
})
|
|
991
|
+
|
|
992
|
+
if (state.lastSuggestion.fullText.startsWith(prefix)) {
|
|
993
|
+
state.lastSuggestion.typedSoFar = prefix
|
|
994
|
+
const remaining = state.lastSuggestion.fullText.slice(prefix.length)
|
|
995
|
+
if (remaining) {
|
|
996
|
+
resolve({
|
|
997
|
+
items: [{
|
|
998
|
+
range: new monaco.Range(
|
|
999
|
+
position.lineNumber,
|
|
1000
|
+
position.column,
|
|
1001
|
+
position.lineNumber,
|
|
1002
|
+
position.column
|
|
1003
|
+
),
|
|
1004
|
+
insertText: remaining
|
|
1005
|
+
}]
|
|
1006
|
+
})
|
|
1007
|
+
return
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
fetchAICompletion(
|
|
1013
|
+
options,
|
|
1014
|
+
node,
|
|
1015
|
+
editor,
|
|
1016
|
+
model,
|
|
1017
|
+
position,
|
|
1018
|
+
resolve
|
|
1019
|
+
)
|
|
1020
|
+
}),
|
|
1021
|
+
freeInlineCompletions (completions) {
|
|
1022
|
+
getState(languageId).lastSuggestion = null
|
|
1023
|
+
}
|
|
1024
|
+
})
|
|
1025
|
+
}
|
|
1026
|
+
supportedInlineCompletions.forEach(options => registerAIInlineCompletions(options))
|
|
1027
|
+
}
|
|
1028
|
+
|
|
692
1029
|
const toolbarMenuButton = $('<li><a id="red-ui-header-button-ff-ai" class="button" href="#"></a></li>')
|
|
693
1030
|
const toolbarMenuButtonAnchor = toolbarMenuButton.find('a')
|
|
694
1031
|
const deployButtonLi = $('#red-ui-header-button-deploy').closest('li')
|
|
@@ -765,7 +1102,7 @@
|
|
|
765
1102
|
context: {
|
|
766
1103
|
type: promptOptions.type,
|
|
767
1104
|
subType: promptOptions.subType,
|
|
768
|
-
scope: 'inline', // inline denotes that the prompt is for a inline
|
|
1105
|
+
scope: 'inline', // inline denotes that the prompt is for a inline codelens action (i.e. the monaco editor)
|
|
769
1106
|
modulesAllowed,
|
|
770
1107
|
codeSection: promptOptions.subType
|
|
771
1108
|
// selection: selectedText // FUTURE: include the selected text in the context for features like "fix my code", "refactor this", "what is this?" etc
|
|
@@ -1361,6 +1698,131 @@
|
|
|
1361
1698
|
return editors.find(editor => editor && document.body.contains(editor.getDomNode()) && editor.getModel()?.uri?.toString() === modelUri)
|
|
1362
1699
|
}
|
|
1363
1700
|
|
|
1701
|
+
function getEditorAndNode (model, { noLogs = false, alertIfNotEnabled = true } = {}) {
|
|
1702
|
+
const node = RED.view.selection()?.nodes?.[0]
|
|
1703
|
+
if (!node) {
|
|
1704
|
+
!noLogs && console.warn('No node selected')
|
|
1705
|
+
return null
|
|
1706
|
+
}
|
|
1707
|
+
if (!assistantOptions.enabled) {
|
|
1708
|
+
node.warnIfNotEnabled && RED.notify(plugin._('errors.assistant-not-enabled'), 'warning')
|
|
1709
|
+
return null
|
|
1710
|
+
}
|
|
1711
|
+
const editor = getMonacoEditorForModel(model)
|
|
1712
|
+
if (!editor) {
|
|
1713
|
+
!noLogs && console.warn('Could not find editor for model', model.uri.toString())
|
|
1714
|
+
return null
|
|
1715
|
+
}
|
|
1716
|
+
const editorElement = editor.getDomNode()
|
|
1717
|
+
if (!document.body.contains(editorElement)) {
|
|
1718
|
+
!noLogs && console.warn('Editor is no longer in the DOM, cannot proceed.')
|
|
1719
|
+
return null
|
|
1720
|
+
}
|
|
1721
|
+
return { node, editor, editorElement }
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
function isInsideComment (model, position) {
|
|
1725
|
+
const lineNumber = position.lineNumber
|
|
1726
|
+
const column = position.column
|
|
1727
|
+
const offset = column - 1
|
|
1728
|
+
// Prefer the language at the exact position if API exists
|
|
1729
|
+
const languageId = (typeof model.getLanguageIdAtPosition === 'function')
|
|
1730
|
+
? model.getLanguageIdAtPosition(lineNumber, column)
|
|
1731
|
+
: model.getLanguageId?.() || 'javascript'
|
|
1732
|
+
const line = model.getLineContent(lineNumber)
|
|
1733
|
+
|
|
1734
|
+
const supportedLanguages = ['javascript', 'css', 'sql', 'html']
|
|
1735
|
+
if (!supportedLanguages.includes(languageId)) {
|
|
1736
|
+
return null // Not supported
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
const lineBeforeCursor = line.substring(0, offset)
|
|
1740
|
+
const textBeforeCursor = model.getValueInRange({
|
|
1741
|
+
startLineNumber: 1,
|
|
1742
|
+
startColumn: 1,
|
|
1743
|
+
endLineNumber: lineNumber,
|
|
1744
|
+
endColumn: column
|
|
1745
|
+
})
|
|
1746
|
+
|
|
1747
|
+
const isWithinBlockComment = (startMarker = '/*', endMarker = '*/') => textBeforeCursor.lastIndexOf(startMarker) > textBeforeCursor.lastIndexOf(endMarker)
|
|
1748
|
+
|
|
1749
|
+
if (languageId === 'javascript' || languageId === 'typescript') {
|
|
1750
|
+
// `//` single-line comment
|
|
1751
|
+
if (lineBeforeCursor.lastIndexOf('//') !== -1) return true
|
|
1752
|
+
// `/* */` block comment
|
|
1753
|
+
if (isWithinBlockComment()) return true
|
|
1754
|
+
} else if (languageId === 'css') {
|
|
1755
|
+
if (isWithinBlockComment()) return true
|
|
1756
|
+
} else if (languageId === 'sql') {
|
|
1757
|
+
// SQL single-line comment `--`
|
|
1758
|
+
if (lineBeforeCursor.lastIndexOf('--') !== -1) return true
|
|
1759
|
+
// SQL block comment `/* */`
|
|
1760
|
+
if (isWithinBlockComment()) return true
|
|
1761
|
+
} else if (languageId === 'html') {
|
|
1762
|
+
// HTML comment markers: <!-- -->
|
|
1763
|
+
if (isWithinBlockComment('<!--', '-->')) return true
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
// Not detected as comment
|
|
1767
|
+
return false
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
function getFunctionNodeEditorCodeSection (editor) {
|
|
1771
|
+
if (!editor || typeof editor.getDomNode !== 'function') {
|
|
1772
|
+
return 'unknown'
|
|
1773
|
+
}
|
|
1774
|
+
let subType = 'on message'
|
|
1775
|
+
let parent = editor.getDomNode().parentNode
|
|
1776
|
+
while (parent?.tagName !== 'FORM') {
|
|
1777
|
+
if (parent.id) {
|
|
1778
|
+
break
|
|
1779
|
+
}
|
|
1780
|
+
parent = parent.parentNode
|
|
1781
|
+
}
|
|
1782
|
+
switch (parent?.id) {
|
|
1783
|
+
case 'func-tab-init':
|
|
1784
|
+
case 'node-input-init-editor':
|
|
1785
|
+
subType = 'on start'
|
|
1786
|
+
break
|
|
1787
|
+
case 'func-tab-body':
|
|
1788
|
+
case 'node-input-func-editor':
|
|
1789
|
+
subType = 'on message'
|
|
1790
|
+
break
|
|
1791
|
+
case 'func-tab-finalize':
|
|
1792
|
+
case 'node-input-finalize-editor':
|
|
1793
|
+
subType = 'on stop'
|
|
1794
|
+
break
|
|
1795
|
+
}
|
|
1796
|
+
return subType
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
/**
|
|
1800
|
+
* Gets the list of modules added to the setup tab.
|
|
1801
|
+
* NOTE: This function expects the nodes edit panel to be open.
|
|
1802
|
+
* @returns {Array} - The list of modules used by the function node.
|
|
1803
|
+
*/
|
|
1804
|
+
function getFunctionNodeModules () {
|
|
1805
|
+
const _libs = []
|
|
1806
|
+
const libs = $('#node-input-libs-container').editableList('items')
|
|
1807
|
+
libs.each(function (i) {
|
|
1808
|
+
const item = $(this)
|
|
1809
|
+
const v = item.find('.node-input-libs-var').val()
|
|
1810
|
+
let n = item.find('.node-input-libs-val').typedInput('type')
|
|
1811
|
+
if (n === '_custom_') {
|
|
1812
|
+
n = item.find('.node-input-libs-val').val()
|
|
1813
|
+
}
|
|
1814
|
+
if ((!v || (v === '')) ||
|
|
1815
|
+
(!n || (n === ''))) {
|
|
1816
|
+
return
|
|
1817
|
+
}
|
|
1818
|
+
_libs.push({
|
|
1819
|
+
var: v,
|
|
1820
|
+
module: n
|
|
1821
|
+
})
|
|
1822
|
+
})
|
|
1823
|
+
return _libs
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1364
1826
|
function generateId (length = 16) {
|
|
1365
1827
|
if (typeof length !== 'number' || length < 1) {
|
|
1366
1828
|
throw new Error('Invalid length')
|
|
@@ -1412,13 +1874,29 @@
|
|
|
1412
1874
|
}
|
|
1413
1875
|
RED.menu.refreshShortcuts()
|
|
1414
1876
|
}
|
|
1415
|
-
function debug () {
|
|
1877
|
+
function debug (...args) {
|
|
1416
1878
|
if (RED.nrAssistant?.DEBUG) {
|
|
1879
|
+
const scriptName = 'assistant-index.html.js' // must match the sourceURL set in the script below
|
|
1880
|
+
const stackLine = new Error().stack.split('\n')[2].trim()
|
|
1881
|
+
const match = stackLine.match(/\(?([^\s)]+):(\d+):(\d+)\)?$/) || stackLine.match(/@?([^@]+):(\d+):(\d+)$/)
|
|
1882
|
+
const file = match?.[1] || 'anonymous'
|
|
1883
|
+
const line = match?.[2] || '1'
|
|
1884
|
+
const col = match?.[3] || '1'
|
|
1885
|
+
let link = `${window.location.origin}/${scriptName}:${line}:${col}`
|
|
1886
|
+
if (/^VM\d+$/.test(file)) {
|
|
1887
|
+
link = `debugger:///${file}:${line}:${col}`
|
|
1888
|
+
} else if (file !== 'anonymous' && file !== '<anonymous>' && file !== scriptName) {
|
|
1889
|
+
link = `${file}:${line}:${col}`
|
|
1890
|
+
if (!link.startsWith('http') && !link.includes('/')) {
|
|
1891
|
+
link = `${window.location.origin}/${link}`
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1417
1894
|
// eslint-disable-next-line no-console
|
|
1418
|
-
console.log('[nr-assistant]', ...
|
|
1895
|
+
console.log('[nr-assistant]', ...args, `\n at ${link}`)
|
|
1419
1896
|
}
|
|
1420
1897
|
}
|
|
1421
1898
|
}(RED, $))
|
|
1899
|
+
// # sourceURL=assistant-index.html.js
|
|
1422
1900
|
</script>
|
|
1423
1901
|
|
|
1424
1902
|
<style>
|
package/lib/assistant.js
CHANGED
|
@@ -81,6 +81,7 @@ class Assistant {
|
|
|
81
81
|
const clientSettings = {
|
|
82
82
|
enabled: this.options.enabled !== false && !!this.options.url,
|
|
83
83
|
tablesEnabled: this.options.tables?.enabled === true,
|
|
84
|
+
inlineCompletionsEnabled: this.options.completions?.inlineEnabled === true,
|
|
84
85
|
requestTimeout: this.options.requestTimeout || 60000
|
|
85
86
|
}
|
|
86
87
|
RED.comms.publish('nr-assistant/initialise', clientSettings, true /* retain */)
|
|
@@ -145,7 +146,7 @@ class Assistant {
|
|
|
145
146
|
}
|
|
146
147
|
})
|
|
147
148
|
}
|
|
148
|
-
this.initAdminEndpoints(RED) // Initialize the admin endpoints for the Assistant
|
|
149
|
+
this.initAdminEndpoints(RED, { inlineCompletionsEnabled: clientSettings.inlineCompletionsEnabled }) // Initialize the admin endpoints for the Assistant
|
|
149
150
|
const degraded = (mcpEnabled && !this.mcpReady)
|
|
150
151
|
RED.log.info('FlowFuse Assistant Plugin loaded' + (degraded ? ' (reduced functionality)' : ''))
|
|
151
152
|
} finally {
|
|
@@ -409,10 +410,8 @@ class Assistant {
|
|
|
409
410
|
|
|
410
411
|
// #region Admin Endpoints & HTTP Handlers
|
|
411
412
|
|
|
412
|
-
initAdminEndpoints (RED) {
|
|
413
|
-
|
|
414
|
-
return assistant.handlePostMethodRequest(req, res)
|
|
415
|
-
})
|
|
413
|
+
initAdminEndpoints (RED, { inlineCompletionsEnabled }) {
|
|
414
|
+
// Hook up routes first ordered by static --> specific --> generic
|
|
416
415
|
|
|
417
416
|
RED.httpAdmin.get('/nr-assistant/mcp/prompts', RED.auth.needsPermission('write'), async function (req, res) {
|
|
418
417
|
return assistant.handlePostPromptsRequest(req, res)
|
|
@@ -425,6 +424,16 @@ class Assistant {
|
|
|
425
424
|
RED.httpAdmin.post('/nr-assistant/mcp/tools/:toolId', RED.auth.needsPermission('write'), async function (req, res) {
|
|
426
425
|
return assistant.handlePostToolRequest(req, res)
|
|
427
426
|
})
|
|
427
|
+
|
|
428
|
+
if (inlineCompletionsEnabled) {
|
|
429
|
+
RED.httpAdmin.post('/nr-assistant/fim/:nodeModule/:nodeType', RED.auth.needsPermission('write'), function (req, res) {
|
|
430
|
+
return assistant.handlePostFimRequest(req, res)
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
RED.httpAdmin.post('/nr-assistant/:method', RED.auth.needsPermission('write'), function (req, res) {
|
|
435
|
+
return assistant.handlePostMethodRequest(req, res)
|
|
436
|
+
})
|
|
428
437
|
}
|
|
429
438
|
|
|
430
439
|
/**
|
|
@@ -453,7 +462,6 @@ class Assistant {
|
|
|
453
462
|
}
|
|
454
463
|
const body = {
|
|
455
464
|
prompt: input.prompt, // this is the prompt to the AI
|
|
456
|
-
promptHint: input.promptHint, // this is used to let the AI know what we are generating (`function node? Node JavaScript? flow?)
|
|
457
465
|
context: input.context, // this is used to provide additional context to the AI (e.g. the selected text of the function node)
|
|
458
466
|
transactionId: input.transactionId // used to correlate the request with the response
|
|
459
467
|
}
|
|
@@ -495,6 +503,65 @@ class Assistant {
|
|
|
495
503
|
})
|
|
496
504
|
}
|
|
497
505
|
|
|
506
|
+
/**
|
|
507
|
+
* Handles POST requests to the /nr-assistant/fim/:languageId endpoint.
|
|
508
|
+
* This is for handling custom methods that the Assistant can perform.
|
|
509
|
+
* @param {import('express').Request} req - The request object
|
|
510
|
+
* @param {import('express').Response} res - The response object
|
|
511
|
+
*/
|
|
512
|
+
async handlePostFimRequest (req, res) {
|
|
513
|
+
if (!this.isInitialized || this.isLoading) {
|
|
514
|
+
return res.status(503).send('Assistant is not ready')
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const nodeModule = req.params.nodeModule
|
|
518
|
+
const nodeType = req.params.nodeType
|
|
519
|
+
// limit nodeModule and nodeType to prevent path traversal
|
|
520
|
+
if (!nodeModule || typeof nodeModule !== 'string') {
|
|
521
|
+
res.status(400)
|
|
522
|
+
res.json({ status: 'error', message: 'Invalid nodeModule' })
|
|
523
|
+
return
|
|
524
|
+
}
|
|
525
|
+
if (!nodeType || typeof nodeType !== 'string') {
|
|
526
|
+
res.status(400)
|
|
527
|
+
res.json({ status: 'error', message: 'Invalid nodeType' })
|
|
528
|
+
return
|
|
529
|
+
}
|
|
530
|
+
const input = req.body
|
|
531
|
+
if (!input || !input.prompt || typeof input.prompt !== 'string') {
|
|
532
|
+
res.status(400)
|
|
533
|
+
res.json({ status: 'error', message: 'prompt is required' })
|
|
534
|
+
return
|
|
535
|
+
}
|
|
536
|
+
const body = {
|
|
537
|
+
prompt: input.prompt, // this is the prompt to the AI
|
|
538
|
+
context: input.context, // this is used to provide additional context to the AI (e.g. the selected text of the function node)
|
|
539
|
+
transactionId: input.transactionId // used to correlate the request with the response
|
|
540
|
+
}
|
|
541
|
+
// join url & method (taking care of trailing slashes)
|
|
542
|
+
const url = `${this.options.url.replace(/\/$/, '')}/fim/${encodeURIComponent(nodeModule)}/${encodeURIComponent(nodeType)}`
|
|
543
|
+
this.got.post(url, {
|
|
544
|
+
headers: {
|
|
545
|
+
Accept: '*/*',
|
|
546
|
+
'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8,es;q=0.7',
|
|
547
|
+
Authorization: `Bearer ${this.options.token}`,
|
|
548
|
+
'Content-Type': 'application/json',
|
|
549
|
+
'User-Agent': FF_ASSISTANT_USER_AGENT
|
|
550
|
+
},
|
|
551
|
+
json: body
|
|
552
|
+
}).then(response => {
|
|
553
|
+
const data = JSON.parse(response.body)
|
|
554
|
+
res.json({
|
|
555
|
+
status: 'ok',
|
|
556
|
+
data
|
|
557
|
+
})
|
|
558
|
+
}).catch((_error) => {
|
|
559
|
+
// fim requests are inline completion opportunities - lets not complain if they fail
|
|
560
|
+
const message = 'FlowFuse Assistant FIM request was unsuccessful'
|
|
561
|
+
this.RED.log.trace(message, _error)
|
|
562
|
+
})
|
|
563
|
+
}
|
|
564
|
+
|
|
498
565
|
/**
|
|
499
566
|
* Handles POST requests to the /nr-assistant/mcp/prompts endpoint.
|
|
500
567
|
* Returns a list of available prompts from the Model Context Protocol (MCP).
|
package/lib/settings.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* @property {string} completions.vocabularyUrl - The URL to the completions vocabulary lookup data
|
|
11
11
|
* @property {Object} tables - Settings for tables
|
|
12
12
|
* @property {Boolean} tables.enabled - Whether the tables feature is enabled
|
|
13
|
+
* @property {Object} inlineCompletions - Settings for inline completions
|
|
14
|
+
* @property {Boolean} inlineCompletions.enabled - Whether the inline completions feature is enabled
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
module.exports = {
|
|
@@ -35,6 +37,9 @@ module.exports = {
|
|
|
35
37
|
assistantSettings.tables = {
|
|
36
38
|
enabled: !!(RED.settings.flowforge?.tables?.token) // for MVP, use the presence of a token is an indicator that tables are enabled
|
|
37
39
|
}
|
|
40
|
+
assistantSettings.inlineCompletions = {
|
|
41
|
+
enabled: !!assistantSettings.completions.inlineEnabled
|
|
42
|
+
}
|
|
38
43
|
return assistantSettings
|
|
39
44
|
}
|
|
40
45
|
}
|