modules/assistant.js

  1. const { cleanParams, trimNonBreakingSpaces, encodeURIComponentRFC3986, stringify } = require('../utils/helpers');
  2. // Create URL from supplied intent (term) and parameters
  3. function createAssistantUrl(intent, parameters, options) {
  4. const {
  5. apiKey,
  6. version,
  7. sessionId,
  8. clientId,
  9. userId,
  10. segments,
  11. testCells,
  12. assistantServiceUrl,
  13. } = options;
  14. let queryParams = { c: version };
  15. queryParams.key = apiKey;
  16. queryParams.i = clientId;
  17. queryParams.s = sessionId;
  18. // Validate intent is provided
  19. if (!intent || typeof intent !== 'string') {
  20. throw new Error('intent is a required parameter of type string');
  21. }
  22. // Validate domain is provided
  23. if (!parameters.domain || typeof parameters.domain !== 'string') {
  24. throw new Error('parameters.domain is a required parameter of type string');
  25. }
  26. // Pull test cells from options
  27. if (testCells) {
  28. Object.keys(testCells).forEach((testCellKey) => {
  29. queryParams[`ef-${testCellKey}`] = testCells[testCellKey];
  30. });
  31. }
  32. // Pull user segments from options
  33. if (segments && segments.length) {
  34. queryParams.us = segments;
  35. }
  36. // Pull user id from options and ensure string
  37. if (userId) {
  38. queryParams.ui = String(userId);
  39. }
  40. if (parameters) {
  41. const { domain, numResultsPerPage } = parameters;
  42. // Pull domain from parameters
  43. if (domain) {
  44. queryParams.domain = domain;
  45. }
  46. // Pull results number from parameters
  47. if (numResultsPerPage) {
  48. queryParams.num_results_per_page = numResultsPerPage;
  49. }
  50. }
  51. // eslint-disable-next-line no-underscore-dangle
  52. queryParams._dt = Date.now();
  53. queryParams = cleanParams(queryParams);
  54. const queryString = stringify(queryParams);
  55. const cleanedQuery = intent.replace(/^\//, '|'); // For compatibility with backend API
  56. return `${assistantServiceUrl}/v1/intent/${encodeURIComponentRFC3986(trimNonBreakingSpaces(cleanedQuery))}?${queryString}`;
  57. }
  58. // Add event listeners to custom SSE that pushes data to the stream
  59. function setupEventListeners(eventSource, controller, eventTypes) {
  60. const addListener = (type) => {
  61. eventSource.addEventListener(type, (event) => {
  62. const data = JSON.parse(event.data);
  63. controller.enqueue({ type, data }); // Enqueue data into the stream
  64. });
  65. };
  66. // Set up listeners for all event types except END
  67. Object.values(eventTypes).forEach((type) => {
  68. if (type !== eventTypes.END) {
  69. addListener(type);
  70. }
  71. });
  72. // Handle the END event separately to close the stream
  73. eventSource.addEventListener(eventTypes.END, () => {
  74. controller.close(); // Close the stream
  75. eventSource.close(); // Close the EventSource connection
  76. });
  77. // Handle errors from the EventSource
  78. // eslint-disable-next-line no-param-reassign
  79. eventSource.onerror = (error) => {
  80. controller.error(error); // Pass the error to the stream
  81. eventSource.close(); // Close the EventSource connection
  82. };
  83. }
  84. /**
  85. * Interface to assistant SSE.
  86. *
  87. * @module assistant
  88. * @inner
  89. * @returns {object}
  90. */
  91. class Assistant {
  92. constructor(options) {
  93. this.options = options || {};
  94. }
  95. static EventTypes = {
  96. START: 'start', // Denotes the start of the stream
  97. GROUP: 'group', // Represents a semantic grouping of search results, optionally having textual explanation
  98. SEARCH_RESULT: 'search_result', // Represents a set of results with metadata (used to show results with search refinements)
  99. ARTICLE_REFERENCE: 'article_reference', // Represents a set of content with metadata
  100. RECIPE_INFO: 'recipe_info', // Represents recipes' auxiliary information like cooking times & serving sizes
  101. RECIPE_INSTRUCTIONS: 'recipe_instructions', // Represents recipe instructions
  102. SERVER_ERROR: 'server_error', // Server Error event
  103. IMAGE_META: 'image_meta', // This event type is used for enhancing recommendations with media content such as images
  104. END: 'end', // Represents the end of data stream
  105. };
  106. /**
  107. * Retrieve assistant results from EventStream
  108. *
  109. * @function getAssistantResultsStream
  110. * @description Retrieve a stream of assistant results from Constructor.io API
  111. * @param {string} intent - Intent to use to perform an intent based recommendations
  112. * @param {object} [parameters] - Additional parameters to refine result set
  113. * @param {string} [parameters.domain] - domain name e.g. swimming sports gear, groceries
  114. * @param {number} [parameters.numResultsPerPage] - The total number of results to return
  115. * @returns {ReadableStream} Returns a ReadableStream.
  116. * @example
  117. * const readableStream = constructorio.assistant.getAssistantResultsStream('I want to get shoes', {
  118. * domain: "nike_sportswear",
  119. * });
  120. * const reader = readableStream.getReader();
  121. * const { value, done } = await reader.read();
  122. */
  123. getAssistantResultsStream(query, parameters) {
  124. let eventSource;
  125. let readableStream;
  126. try {
  127. const requestUrl = createAssistantUrl(query, parameters, this.options);
  128. // Create an EventSource that connects to the Server Sent Events API
  129. eventSource = new EventSource(requestUrl);
  130. // Create a readable stream that data will be pushed into
  131. readableStream = new ReadableStream({
  132. // To be called on stream start
  133. start(controller) {
  134. // Listen to events emitted from ASA Server Sent Events and push data to the ReadableStream
  135. setupEventListeners(eventSource, controller, Assistant.EventTypes);
  136. },
  137. // To be called on stream cancelling
  138. cancel() {
  139. // Close the EventSource connection when the stream is prematurely canceled
  140. eventSource.close();
  141. },
  142. });
  143. } catch (e) {
  144. if (readableStream) {
  145. readableStream?.cancel();
  146. } else {
  147. // If the stream was not successfully created, close the EventSource directly
  148. eventSource?.close();
  149. }
  150. throw new Error(e.message);
  151. }
  152. return readableStream;
  153. }
  154. }
  155. module.exports = Assistant;
  156. module.exports.createAssistantUrl = createAssistantUrl;
  157. module.exports.setupEventListeners = setupEventListeners;