@flowfuse/nr-assistant 0.5.1-78995c8-202508280848.0 → 0.5.1-bdb2232-202509041429.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 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
  ![flowfuse-assistant-assistant-builder](https://github.com/user-attachments/assets/6520eeaf-83f5-466e-ad32-6b4ae1d62954)
@@ -34,6 +35,18 @@ This plugin is designed to assist users of the FlowFuse platform by providing to
34
35
  ![flowfuse-assistant-css](https://github.com/user-attachments/assets/fea87030-294e-4bce-a9ce-146249ee0459)
35
36
 
36
37
 
38
+ ### Inline Code Completions
39
+ NOTE: This feature will be limited to pro and enterprise tiers.
40
+
41
+ #### functions
42
+ ![flowfuse-assistant-inline-function-completions](https://github.com/user-attachments/assets/487b07be-861b-48d1-88c7-22c9cebffefa)
43
+
44
+ #### templates
45
+ ![flowfuse-assistant-inline-template-completions](https://github.com/user-attachments/assets/a6a53c1d-b067-411f-b155-0dcbf67f7e88)
46
+
47
+ #### tables
48
+ ![flowfuse-assistant-inline-table-completions](https://github.com/user-attachments/assets/d9b18f96-9889-401e-a530-c89623f72610)
49
+
37
50
  ## Installation
38
51
 
39
52
  ```bash
package/index.html CHANGED
@@ -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('loading...')
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
- // walk up the tree to find the parent div with an id and include that in context
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
- // selectedText: model.getValueInRange(userSelection)
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 _libs = []
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 = _libs.find(l => l.module === lib.module)
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: 'g',
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 code (i.e. the monaco editor)
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]', ...arguments)
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
- RED.httpAdmin.post('/nr-assistant/:method', RED.auth.needsPermission('write'), function (req, res) {
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowfuse/nr-assistant",
3
- "version": "0.5.1-78995c8-202508280848.0",
3
+ "version": "0.5.1-bdb2232-202509041429.0",
4
4
  "description": "FlowFuse Node-RED assistant plugin",
5
5
  "main": "index.js",
6
6
  "scripts": {