@cpwc/node-red-contrib-ai-intent 3.1.0-alpha

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.
Files changed (70) hide show
  1. package/.eslintrc +49 -0
  2. package/.idea/modules.xml +8 -0
  3. package/.idea/node-red-contrib-ai-intent.iml +12 -0
  4. package/.idea/vcs.xml +6 -0
  5. package/LICENSE +21 -0
  6. package/README.md +233 -0
  7. package/call-intent/icons/promotion-icon.svg +8 -0
  8. package/call-intent/index.html +114 -0
  9. package/call-intent/index.js +110 -0
  10. package/constants.js +31 -0
  11. package/database.js +9 -0
  12. package/examples/home-assistant-automation.json +167 -0
  13. package/examples/llm-chat-node-example.json +208 -0
  14. package/examples/openai-call-registered-intent-example.json +174 -0
  15. package/examples/openai-system-node-example.json +178 -0
  16. package/examples/openai-tool-node-example.json +120 -0
  17. package/examples/openai-user-node-exampe.json +234 -0
  18. package/geminiai-chat/geminiai-configuration/index.html +18 -0
  19. package/geminiai-chat/geminiai-configuration/index.js +7 -0
  20. package/geminiai-chat/icons/diamond.svg +8 -0
  21. package/geminiai-chat/icons/gemini-icon.svg +1 -0
  22. package/geminiai-chat/icons/gemini.svg +8 -0
  23. package/geminiai-chat/index.html +189 -0
  24. package/geminiai-chat/index.js +92 -0
  25. package/globalUtils.js +39 -0
  26. package/images/call_register_intent.jpeg +0 -0
  27. package/images/finally.jpg +0 -0
  28. package/images/set-config-node.gif +0 -0
  29. package/llm-chat/AzureOpenAIHelper.js +204 -0
  30. package/llm-chat/ChatGPTHelper.js +197 -0
  31. package/llm-chat/GeminiHelper.js +260 -0
  32. package/llm-chat/OllamaHelper.js +196 -0
  33. package/llm-chat/icons/bot-message-square.svg +1 -0
  34. package/llm-chat/icons/brain-circuit.svg +1 -0
  35. package/llm-chat/icons/chatgpt-icon.svg +7 -0
  36. package/llm-chat/index.html +205 -0
  37. package/llm-chat/index.js +73 -0
  38. package/llm-chat/platform-configuration/index.html +136 -0
  39. package/llm-chat/platform-configuration/index.js +16 -0
  40. package/localai-chat/icons/gem-icon.svg +1 -0
  41. package/localai-chat/icons/llama.svg +8 -0
  42. package/localai-chat/index.html +244 -0
  43. package/localai-chat/index.js +108 -0
  44. package/localai-chat/localai-configuration/index.html +18 -0
  45. package/localai-chat/localai-configuration/index.js +7 -0
  46. package/openai-chat/icons/chatgpt-icon.svg +7 -0
  47. package/openai-chat/index.html +196 -0
  48. package/openai-chat/index.js +58 -0
  49. package/openai-chat/openai-configuration/index.html +18 -0
  50. package/openai-chat/openai-configuration/index.js +7 -0
  51. package/openai-response/index.html +66 -0
  52. package/openai-response/index.js +154 -0
  53. package/openai-system/index.html +68 -0
  54. package/openai-system/index.js +28 -0
  55. package/openai-tool/index.html +57 -0
  56. package/openai-tool/index.js +50 -0
  57. package/openai-user/index.html +76 -0
  58. package/openai-user/index.js +26 -0
  59. package/package.json +49 -0
  60. package/register-intent/icons/register-icon.svg +8 -0
  61. package/register-intent/index.html +195 -0
  62. package/register-intent/index.js +72 -0
  63. package/register-intent/utils.js +10 -0
  64. package/utilities/chat-controller.js +249 -0
  65. package/utilities/chat-ledger.js +122 -0
  66. package/utilities/conversationHistory.js +68 -0
  67. package/utilities/format.js +94 -0
  68. package/utilities/gemini-controller.js +243 -0
  69. package/utilities/global-context.js +30 -0
  70. package/utilities/validateSchema.js +74 -0
@@ -0,0 +1,68 @@
1
+ const {CONVERSATION_CONTEXT, ROLES} = require("../constants");
2
+
3
+ class ConversationHistory {
4
+ constructor(nodeDB, conversationId) {
5
+ this.conversationId = conversationId;
6
+ this.nodeDB = nodeDB;
7
+ this.conversation = []
8
+
9
+ if(!nodeDB){
10
+ throw new Error("nodeDB does not exist");
11
+ }else if(conversationId){
12
+ const allConversations = nodeDB.getValueFromGlobalContext(CONVERSATION_CONTEXT) || {}
13
+ this.conversation = allConversations[conversationId] || []
14
+ }
15
+ }
16
+
17
+ addSystemMessage(content){
18
+ if(!content){
19
+ return false
20
+ }
21
+
22
+ const entry = {role: ROLES.System, content }
23
+
24
+ if(this.conversation[0]?.role === ROLES.System){
25
+ this.conversation[0] = entry
26
+ }else{
27
+ this.conversation = [entry, ...this.conversation]
28
+ }
29
+
30
+ this.saveHistory()
31
+ }
32
+
33
+ addUserMessage(content){
34
+ if(!content){
35
+ return false
36
+ }
37
+
38
+ const entry = {role: ROLES.User, content }
39
+ this.conversation.push(entry)
40
+ this.saveHistory()
41
+ }
42
+ addAssistantMessage(content){
43
+ if(!content){
44
+ return false
45
+ }
46
+
47
+ const entry = {role: ROLES.Assistant, content }
48
+ this.conversation.push(entry)
49
+ this.saveHistory()
50
+ }
51
+
52
+ clearHistory(){
53
+ this.conversation = []
54
+ this.saveHistory()
55
+ }
56
+
57
+ saveHistory(){
58
+ if(this.conversationId){
59
+ const allConversations = this.nodeDB.getValueFromGlobalContext(CONVERSATION_CONTEXT) || {}
60
+ allConversations[this.conversationId] = this.conversation
61
+ this.nodeDB.setValueToGlobalContext(allConversations, CONVERSATION_CONTEXT)
62
+ }
63
+ }
64
+ }
65
+
66
+ module.exports = {
67
+ ConversationHistory,
68
+ };
@@ -0,0 +1,94 @@
1
+ const Sugar = require("sugar");
2
+
3
+ class Format {
4
+ createConsistentPayload(content){
5
+ return {
6
+ args: {
7
+ response: content
8
+ },
9
+ };
10
+ };
11
+
12
+ formatPayloadForLocalAI (msg){
13
+ const { tool_calls = [], content } = msg;
14
+ const output = [];
15
+ const payload = this.createConsistentPayload(content);
16
+
17
+ if(tool_calls.length){
18
+ tool_calls.forEach((answer) => {
19
+
20
+ const payload = this.createConsistentPayload(answer.content);
21
+
22
+ if (answer.function) {
23
+ const deepCopyPayload = Sugar.Object.clone(payload, true);
24
+
25
+ deepCopyPayload.args = {
26
+ ...answer.function.arguments,
27
+ };
28
+ deepCopyPayload.nodeName = answer.function.name;
29
+ output.push(deepCopyPayload);
30
+
31
+ } else {
32
+ output.push(payload);
33
+ }
34
+ });
35
+ }else{
36
+ output.push(payload);
37
+ }
38
+
39
+ return output
40
+ };
41
+
42
+ formatPayloadForOpenAI (choices) {
43
+ const output = [];
44
+ choices.forEach((answer) => {
45
+ const { content = "", tool_calls } = answer.message;
46
+ const payload = this.createConsistentPayload(content);
47
+
48
+ if (tool_calls) {
49
+ tool_calls.forEach((tool) => {
50
+ const deepCopyPayload = Sugar.Object.clone(payload, true);
51
+
52
+ if (tool.type === "function") {
53
+ deepCopyPayload.args = {
54
+ ...JSON.parse(tool.function.arguments),
55
+ };
56
+ deepCopyPayload.nodeName = tool.function.name;
57
+ output.push(deepCopyPayload);
58
+ }
59
+ });
60
+ } else {
61
+ output.push(payload);
62
+ }
63
+ });
64
+
65
+ return output;
66
+ };
67
+
68
+ formatPayloadForGeminiAI (msg) {
69
+ const output = [];
70
+ const { functions = [], text } = msg;
71
+ const payload = this.createConsistentPayload(text);
72
+
73
+ if (functions.length > 0) {
74
+ functions.forEach((tool) => {
75
+ const { name, args } = tool;
76
+ output.push({
77
+ args: {
78
+ ...payload.args,
79
+ ...args,
80
+ },
81
+ nodeName: name,
82
+ });
83
+ });
84
+ } else {
85
+ output.push(payload);
86
+ }
87
+
88
+ return output;
89
+ };
90
+ }
91
+
92
+ module.exports = {
93
+ Format,
94
+ };
@@ -0,0 +1,243 @@
1
+ const { TOOL_CHOICE } = require("../constants");
2
+ const { ChatLedger } = require("./chat-ledger");
3
+ const { ContextDatabase } = require("../globalUtils");
4
+
5
+ class GeminiController {
6
+ constructor(node, config, msg, RED) {
7
+ this.msg = msg;
8
+ this.node = node;
9
+ this.config = config;
10
+ this.apiProperties = getChatCompletionProps(msg, config, node);
11
+
12
+ const registeredIntentFunctions = this.getRegisteredIntentFunctions(RED);
13
+ const rawIntents = this.getRawIntents(RED);
14
+
15
+ this.tools = [
16
+ ...this.apiProperties.tools,
17
+ ...registeredIntentFunctions,
18
+ ].filter(Boolean);
19
+
20
+ this.tools = convertToolsToGeminiCompatibleTools(this.tools);
21
+
22
+ const toolProperties = determineToolProperties(
23
+ rawIntents,
24
+ this.tools,
25
+ this.apiProperties.tool_choice
26
+ );
27
+
28
+ this.toolProperties = validateToolProperties(toolProperties, node);
29
+ this.tools;
30
+ }
31
+
32
+ mergeResponseWithMessage = (payload, request) => {
33
+ const { user, system, tools, ...rest } = this.msg;
34
+ const ledger = new ChatLedger(this.config.conversation_id, this.node);
35
+ const response = {
36
+ functions: payload.functions,
37
+ message: { role: "assistant", content: payload.text },
38
+ };
39
+ const fullConversation = ledger.addResponseToConversationAndSave(
40
+ request,
41
+ response,
42
+ this.node.type
43
+ );
44
+
45
+ return {
46
+ ...rest,
47
+ payload: response,
48
+ _debug: {
49
+ ...request,
50
+ type: this.node.type,
51
+ fullConversation,
52
+ conversation_id: this.config.conversation_id,
53
+ },
54
+ };
55
+ };
56
+
57
+ /**
58
+ * Converts the raw intents into functions that the LLM can use.
59
+ * @param {*} node
60
+ * @returns
61
+ */
62
+ getRegisteredIntentFunctions = (RED) => {
63
+ const intents = this.getRawIntents(RED);
64
+ return createFunctionsFromContext(intents);
65
+ };
66
+
67
+ /**
68
+ * This will return all stored Registered Intents throughout the entire system
69
+ * and Tool Nodes that are attached directly to this flow
70
+ * This will return:
71
+ * type RawIntent = {
72
+ * [node_id]: node // could be Registered Intent or Tool node
73
+ * }
74
+ */
75
+ getRawIntents = (RED) => {
76
+ const context = new ContextDatabase(RED);
77
+ return context.getNodeStore() || {};
78
+ };
79
+ }
80
+
81
+ /**
82
+ * If no tools exist, then remove tools and toolChoice from the payload
83
+ */
84
+ const validateToolProperties = (toolProperties, node) => {
85
+ if (!toolProperties.tools?.functionDeclarations?.length) {
86
+ const { toolChoice, ...rest } = toolProperties;
87
+
88
+ if (toolChoice && toolChoice !== "none") {
89
+ node.warn(
90
+ "Removing tools from payload since no tools are available. Flow will continue."
91
+ );
92
+ }
93
+
94
+ return rest;
95
+ }
96
+ return toolProperties;
97
+ };
98
+
99
+ /**
100
+ * combines various properties from `msg` and `config` to return all the properties needed for OpenAI API request
101
+ * @param {Record<string,any>} msg
102
+ * @param {Record<string, any>} config
103
+ * @returns
104
+ */
105
+ const getChatCompletionProps = (msg, config, node) => {
106
+ const model = msg.payload?.model || config.model;
107
+ const temperature = Number(msg.payload?.temperature || config.temperature);
108
+ const max_tokens = Number(msg.payload?.max_tokens || config.max_tokens);
109
+ const top_p = Number(msg.payload?.top_p || config.top_p);
110
+ const top_k = Number(msg.payload?.top_k || config.top_k);
111
+ const tools = msg?.tools || [];
112
+ const tool_choice = msg.payload?.tool_choice || config?.tool_choice || "auto";
113
+ const ledger = new ChatLedger(config.conversation_id, node);
114
+ const messages = ledger.combineExistingMessages(msg.user, msg.system);
115
+ const { updated, original } = convertChatToGeminiCompatibleChat(messages);
116
+ const history = [...updated];
117
+ const nextMessage = history.pop();
118
+ const message = nextMessage?.parts[0]?.text || "";
119
+
120
+ return {
121
+ model,
122
+ temperature,
123
+ maxOutputTokens: max_tokens,
124
+ topP: top_p,
125
+ topK: top_k,
126
+ messages: original, // contains the full chat in it's original form
127
+ history, // contains the gemini compatible chat w/o the user's current message (the last message in the array)
128
+ message,
129
+ tool_choice,
130
+ tools,
131
+ };
132
+ };
133
+
134
+ const convertChatToGeminiCompatibleChat = (messages = []) => {
135
+ const original = [...messages];
136
+ const updated = messages.map((message) => {
137
+ let role = message.role;
138
+ // Gemini doesn't seem to have a system role. We wil convert it to a user
139
+ if (role === "system") {
140
+ role = "user";
141
+ }
142
+
143
+ return { role, parts: [{ text: message.content }] };
144
+ });
145
+ return { original, updated };
146
+ };
147
+
148
+ const convertToolsToGeminiCompatibleTools = (tools = []) => {
149
+ return {
150
+ functionDeclarations: tools.map((tool) => {
151
+ return tool.function;
152
+ }),
153
+ };
154
+ };
155
+
156
+ /**
157
+ * converts the registered intents stored in the context into functions that can be used by the LLM.
158
+ * The registered intent will be ignored if excludeFromOpenAi is set to true.
159
+ * rawIntents may have tool nodes included so the values need to be filtered by the node type.
160
+ * rawIntents have the following shape:
161
+ *
162
+ * type RawIntents = {
163
+ * [node_id]: node // node could be Registered Intent or Tool node
164
+ * }
165
+ */
166
+ const createFunctionsFromContext = (rawIntents = {}) => {
167
+ return (
168
+ Object.values(rawIntents)
169
+ .filter((payload) => {
170
+ return payload.type === "Register Intent";
171
+ })
172
+ .map((payload) => {
173
+ if (payload.excludeFromOpenAi) {
174
+ return undefined;
175
+ }
176
+
177
+ return {
178
+ type: "function",
179
+ function: {
180
+ name: payload.name,
181
+ description: payload.description,
182
+ parameters: {
183
+ type: "object",
184
+ properties: {
185
+ isRegisteredIntent: { type: "string", enum: ["true"] },
186
+ response: {
187
+ type: "string",
188
+ description: "A friendly response to the given command",
189
+ },
190
+ },
191
+ required: ["isRegisteredIntent", "response"],
192
+ },
193
+ },
194
+ };
195
+ })
196
+ .filter(Boolean) || []
197
+ );
198
+ };
199
+
200
+ /**
201
+ * Based on the tool_choice the tool properties will be created. This is based off
202
+ * https://cookbook.openai.com/examples/how_to_call_functions_with_chat_models
203
+ *
204
+ * @param {Object} context - contains all the saved nodes that represents functions
205
+ * @param {*} tools - All the tools to be sent as functions
206
+ * @param {*} toolChoice - Specifies which tools to use
207
+ * @returns
208
+ */
209
+ const determineToolProperties = (
210
+ context = {},
211
+ tools = [],
212
+ toolChoice = TOOL_CHOICE.Auto
213
+ ) => {
214
+ const props = {
215
+ tools,
216
+ tool_choice: toolChoice,
217
+ };
218
+ if (toolChoice === TOOL_CHOICE.None) {
219
+ // No tools chosen
220
+ props.tools = [];
221
+ return props;
222
+ } else if (
223
+ toolChoice === TOOL_CHOICE.Auto ||
224
+ toolChoice === TOOL_CHOICE.Any
225
+ ) {
226
+ // set the choice to auto or any
227
+ return props;
228
+ } else if (context[toolChoice]?.name) {
229
+ // A specific tool was chosen
230
+ props.tool_choice = TOOL_CHOICE.Any;
231
+ props.allowedFunctionNames = [context[toolChoice].name];
232
+ return props;
233
+ }
234
+ // Something funky happened so we will use auto instead
235
+ return {
236
+ tools,
237
+ tool_choice: TOOL_CHOICE.Auto,
238
+ };
239
+ };
240
+
241
+ module.exports = {
242
+ GeminiController,
243
+ };
@@ -0,0 +1,30 @@
1
+ class GlobalContext {
2
+ constructor(node) {
3
+ if (!node) {
4
+ throw new Error(
5
+ "Fatal Error: Cannot access global context without a node."
6
+ );
7
+ }
8
+ this.node = node;
9
+ }
10
+
11
+ setValueToGlobalContext = (value, key) => {
12
+ const globalContext = getGlobalContext(this.node);
13
+
14
+ globalContext.set(key, value);
15
+ };
16
+
17
+ getValueFromGlobalContext = (key) => {
18
+ const globalContext = getGlobalContext(this.node);
19
+
20
+ return globalContext.get(key);
21
+ };
22
+ }
23
+
24
+ const getGlobalContext = (node) => {
25
+ return node.context().global;
26
+ };
27
+
28
+ module.exports = {
29
+ GlobalContext,
30
+ };
@@ -0,0 +1,74 @@
1
+ const Ajv = require("ajv");
2
+ const addFormats = require("ajv-formats");
3
+
4
+ // Initialize AJV
5
+ const ajv = new Ajv({ allErrors: true });
6
+ addFormats(ajv); // Add extra format validations
7
+
8
+ /**
9
+ * Validate the user schema against OpenAI function calling requirements.
10
+ * @param {object} userSchema - The schema to validate.
11
+ * @returns {object} { isValid: boolean, errorMsg: string }
12
+ */
13
+ function validateOpenAISchema(userSchema) {
14
+ // Ensure root schema type is "object"
15
+ if (userSchema.type !== "object") {
16
+ return { isValid: false, errorMsg: "OpenAI function calling requires `type: object` at the root level." };
17
+ }
18
+
19
+ // Ensure the schema has a "properties" field and it is an object
20
+ if (!userSchema.properties || typeof userSchema.properties !== "object") {
21
+ return { isValid: false, errorMsg: "OpenAI function calling requires a `properties` field with key-value pairs." };
22
+ }
23
+
24
+ // Validate each property inside the schema
25
+ for (const [key, value] of Object.entries(userSchema.properties)) {
26
+ if (typeof value !== "object") {
27
+ return { isValid: false, errorMsg: `Property '${key}' must be an object with type definitions.` };
28
+ }
29
+
30
+ // Check if type is defined and valid
31
+ const validTypes = ["string", "number", "integer", "boolean", "array", "object"];
32
+ if (!value.type || !validTypes.includes(value.type)) {
33
+ return { isValid: false, errorMsg: `Property '${key}' must have a valid type (string, number, integer, boolean, array, object).` };
34
+ }
35
+
36
+ // If `enum` is present, ensure it is an array with at least one item
37
+ if (value.enum) {
38
+ if (!Array.isArray(value.enum) || value.enum.length === 0) {
39
+ return { isValid: false, errorMsg: `Property '${key}' has an invalid "enum". It must be a non-empty array.` };
40
+ }
41
+ }
42
+ }
43
+
44
+ // If `required` is present, ensure it is an array of existing properties
45
+ if (userSchema.required) {
46
+ if (!Array.isArray(userSchema.required) || !userSchema.required.every((reqKey) => userSchema.properties.hasOwnProperty(reqKey))) {
47
+ return { isValid: false, errorMsg: `"required" field must be an array of existing property keys.` };
48
+ }
49
+ }
50
+
51
+ return { isValid: true, errorMsg: "" };
52
+ }
53
+
54
+ // // Example: User-Submitted JSON Schema
55
+ // const userSchema = {
56
+ // type: "object",
57
+ // properties: {
58
+ // query: { type: "string" },
59
+ // max_results: { type: "integer", minimum: 1 },
60
+ // mode: { type: "string", enum: ["fast", "slow", "balanced"] } // Example of an enum
61
+ // },
62
+ // required: ["query"],
63
+ // additionalProperties: false
64
+ // };
65
+ //
66
+ // // Run validation
67
+ // const result = validateOpenAISchema(userSchema);
68
+ // console.log(result);
69
+
70
+
71
+
72
+ module.exports = {
73
+ validateOpenAISchema
74
+ }