diff --git a/packages/js/core/src/components/chat/chat-room/actions/submitPrompt.ts b/packages/js/core/src/components/chat/chat-room/actions/submitPrompt.ts index 60ec78b0..68a03182 100644 --- a/packages/js/core/src/components/chat/chat-room/actions/submitPrompt.ts +++ b/packages/js/core/src/components/chat/chat-room/actions/submitPrompt.ts @@ -1,6 +1,7 @@ import {Observable} from '../../../../core/bus/observable'; import {ExceptionId} from '../../../../exceptions/exceptions'; -import {ChatAdapterExtras, DataTransferMode} from '../../../../types/aiChat/chatAdapter'; +import {ChatAdapterExtras} from '../../../../types/adapters/chat/chaAdapterExtras'; +import {DataTransferMode} from '../../../../types/adapters/chat/chatAdapter'; import {ControllerContext} from '../../../../types/controllerContext'; import {warn} from '../../../../x/warn'; import {CompConversation} from '../../conversation/conversation.model'; diff --git a/packages/js/core/src/components/chat/chat-room/chat-room.model.ts b/packages/js/core/src/components/chat/chat-room/chat-room.model.ts index 417de96d..cb5e9f6d 100644 --- a/packages/js/core/src/components/chat/chat-room/chat-room.model.ts +++ b/packages/js/core/src/components/chat/chat-room/chat-room.model.ts @@ -3,7 +3,7 @@ import {comp} from '../../../core/aiChat/comp/comp'; import {CompEventListener, Model} from '../../../core/aiChat/comp/decorators'; import {HistoryPayloadSize} from '../../../core/aiChat/options/conversationOptions'; import {BotPersona, UserPersona} from '../../../core/aiChat/options/personaOptions'; -import {isStandardChatAdapter, StandardChatAdapter} from '../../../types/aiChat/standardChatAdapter'; +import {isStandardChatAdapter, StandardChatAdapter} from '../../../types/adapters/chat/standardChatAdapter'; import {ControllerContext} from '../../../types/controllerContext'; import {ConversationItem} from '../../../types/conversation'; import {CompConversation} from '../conversation/conversation.model'; diff --git a/packages/js/core/src/core/aiChat/aiChat.ts b/packages/js/core/src/core/aiChat/aiChat.ts index 05a79b5e..257454bd 100644 --- a/packages/js/core/src/core/aiChat/aiChat.ts +++ b/packages/js/core/src/core/aiChat/aiChat.ts @@ -1,13 +1,13 @@ import {registerAllComponents} from '../../components/components'; -import {ChatAdapter} from '../../types/aiChat/chatAdapter'; -import {ChatAdapterBuilder} from '../../types/aiChat/chatAdapterBuilder'; +import {ChatAdapter} from '../../types/adapters/chat/chatAdapter'; +import {ChatAdapterBuilder} from '../../types/adapters/chat/chatAdapterBuilder'; +import {StandardChatAdapter} from '../../types/adapters/chat/standardChatAdapter'; +import {IAiChat} from '../../types/aiChat/aiChat'; import {AiChatProps} from '../../types/aiChat/props'; -import {StandardChatAdapter} from '../../types/aiChat/standardChatAdapter'; import {ConversationItem} from '../../types/conversation'; import {EventCallback, EventName, EventsMap} from '../../types/event'; import {debug} from '../../x/debug'; import {NluxRenderingError, NluxUsageError, NluxValidationError} from '../error'; -import {IAiChat} from '../interface'; import {NluxController} from './controller/controller'; import {HighlighterExtension} from './highlighter/highlighter'; import {ConversationOptions} from './options/conversationOptions'; @@ -205,7 +205,6 @@ export class AiChat implements IAiChat { } const anAdapterOrAdapterBuilder = adapter as any; - if (typeof anAdapterOrAdapterBuilder.create === 'function') { this.theAdapterType = 'builder'; this.theAdapterBuilder = anAdapterOrAdapterBuilder.create(); @@ -264,7 +263,7 @@ export class AiChat implements IAiChat { }); } - this.theConversationOptions = conversationOptions; + this.theConversationOptions = {...conversationOptions}; return this; } @@ -283,7 +282,7 @@ export class AiChat implements IAiChat { }); } - this.theInitialConversation = initialConversation; + this.theInitialConversation = [...initialConversation]; return this; } @@ -302,8 +301,7 @@ export class AiChat implements IAiChat { }); } - this.theLayoutOptions = layoutOptions; - + this.theLayoutOptions = {...layoutOptions}; return this; } @@ -322,7 +320,7 @@ export class AiChat implements IAiChat { }); } - this.thePersonasOptions = personaOptions; + this.thePersonasOptions = {...personaOptions}; return this; } @@ -341,7 +339,7 @@ export class AiChat implements IAiChat { }); } - this.thePromptBoxOptions = promptBoxOptions; + this.thePromptBoxOptions = {...promptBoxOptions}; return this; } diff --git a/packages/js/core/src/core/aiContext/aiContext.ts b/packages/js/core/src/core/aiContext/aiContext.ts new file mode 100644 index 00000000..1f8c6792 --- /dev/null +++ b/packages/js/core/src/core/aiContext/aiContext.ts @@ -0,0 +1,534 @@ +import {ContextAdapter} from '../../types/adapters/context/contextAdapter'; +import {ContextAdapterBuilder} from '../../types/adapters/context/contextAdapterBuilder'; +import {ContextTasksAdapter} from '../../types/adapters/context/contextTasksAdapter'; +import {AiContext, AiContextItemStatus, AiContextStatus} from '../../types/aiContext/aiContext'; +import {ContextItemHandler, ContextTaskHandler} from '../../types/aiContext/contextObservers'; +import { + ContextActionResult, + DestroyContextResult, + FlushContextResult, + InitializeContextResult, + RunTaskResult, +} from '../../types/aiContext/contextResults'; +import {ContextItemDataType, ContextItems} from '../../types/aiContext/data'; +import {isContextTasksAdapter} from '../../utils/adapters/isContextTasksAdapter'; +import {warn} from '../../x/warn'; +import {DataSyncService} from './dataSyncService'; +import {DataSyncOptions} from './options/dataSyncOptions'; +import {TasksService} from './tasksService'; + +/** + * Default implementation of the AiContext. + * This class is responsible for managing the context and its observers. + */ +class AiContextImpl implements AiContext { + + public destroy = async (): Promise => { + if (this.theStatus === 'destroyed') { + return { + success: true, + }; + } + + // We immediately set the status to 'destroyed' to prevent the context from being used. + // This is to prevent any use of the context while it is being destroyed, including any ongoing data sync + // or initialization. + this.theStatus = 'destroyed'; + + // TODO - Handle error recovery and retry + await Promise.all([ + this.theDataSyncService?.destroy(), + this.theTasksService?.destroy(), + ]); + + this.theDataSyncService = null; + this.theTasksService = null; + + this.theDataAdapter = null; + this.theTasksAdapter = null; + + return { + success: true, + }; + }; + + public flush = async (): Promise => { + try { + await this.theDataSyncService?.flush(); + } catch (error) { + return { + success: false, + error: 'Failed to flush context data', + }; + } + + try { + await this.theTasksService?.flush(); + } catch (error) { + return { + success: false, + error: 'Failed to flush context tasks', + }; + } + + return { + success: true, + }; + }; + + public initialize = async (data?: ContextItems): Promise => { + // Among the possible context statuses: 'idle' | 'initializing' | 'syncing' | 'error' | 'destroyed' + // Initialization cannot happen when the context is 'idle'. + + if (this.theStatus === 'initializing') { + warn( + `${this.constructor.name}.initialize() called while context is still initializing! ` + + `You cannot initialize twice at the same time. Use ${this.constructor.name}.status or await ` + + `${this.constructor.name}.initialize() to make sure that the context is not initializing before ` + + `calling this method.`, + ); + + return { + success: false, + error: 'Context is still initializing! Use AiContext.status to check the context status ' + + 'before calling this method.', + }; + } + + if (this.theStatus === 'syncing') { + warn( + `${this.constructor.name}.initialize() called on an already initialized context! ` + + `Use ${this.constructor.name}.status to check the context status before calling this method. `, + ); + + return { + success: false, + error: 'Context already initialized! Use AiContext.status to check the context status ' + + 'before calling this method.', + }; + } + + if (this.theStatus === 'destroyed') { + warn( + `${this.constructor.name}.initialize() called on destroyed context! ` + + `Use ${this.constructor.name}.status to check the context status before calling this method. ` + + `When the context is destroyed, it cannot be used anymore and you should create a new context.`, + ); + + return { + success: false, + error: 'Context has been destroyed', + }; + } + + if (this.theStatus === 'error') { + warn( + `${this.constructor.name}.initialize() called on a context in error state! ` + + `Use ${this.constructor.name}.status to check the context status before calling this method. ` + + `When the context is in error state, it cannot be used anymore and you should create a new context.`, + ); + + // TODO - Enable recovery from error state. + + return { + success: false, + error: 'Context is in error state', + }; + } + + if (!this.theDataAdapter) { + warn( + `Adapter not set! You must set the adapter before initializing the context. ` + + `Use ${this.constructor.name}.withAdapter() to set the adapter before calling this method.`, + ); + + return { + success: false, + error: 'Adapter not set', + }; + } + + this.theStatus = 'initializing'; + this.theDataSyncService = new DataSyncService(this.theDataAdapter); + + try { + const result = await this.theDataSyncService.createContext(data); + + // Status can change to 'destroyed' if the context is destroyed while adapter.set is in progress. + // In that case, we should not set the contextId and we should immediately clear the context. + // @ts-ignore + if (this.status === 'destroyed') { + if (result.success) { + await this.theDataSyncService?.resetContextData(); + } + + return { + success: false, + error: 'Context has been destroyed', + }; + } + + if (!result.success) { + this.theStatus = 'error'; + return { + success: false, + error: 'Failed to initialize context', + }; + } + + if (!this.contextId) { + this.theStatus = 'error'; + return { + success: false, + error: 'Failed to obtain context ID', + }; + } + + // + // Handling the happy path, when the context is successfully initialized. + // + this.theStatus = 'syncing'; + + if (this.theTasksAdapter) { + this.theTasksService = new TasksService(this.contextId, this.theTasksAdapter); + } else { + warn( + 'Initializing nlux AiContext without tasks adapter. The context will not handle registering and ' + + 'executing tasks by AI. If you want to use tasks triggered by AI, you should provide an adapter ' + + 'that implements ContextAdapter interface ' + + '[type ContextAdapter = ContextDataAdapter & ContextTasksAdapter]', + ); + } + + return { + success: true, + contextId: result.contextId, + }; + } catch (error) { + this.theStatus = 'error'; + return { + success: false, + error: `${error}`, + }; + } + }; + + public observeState = ( + itemId: string, + description: string, + initialData?: ContextItemDataType, + ): ContextItemHandler | undefined => { + if (this.theStatus === 'idle') { + warn( + `${this.constructor.name}.observeState() called on idle context! ` + + `Use ${this.constructor.name}.status to check the context status before calling this method. ` + + `Use ${this.constructor.name}.initialize() to initialize the context when it is not initialized.`, + ); + + return undefined; + } + + if (this.theStatus === 'initializing') { + warn( + `${this.constructor.name}.observeState() called while context is still initializing! ` + + `You cannot observe state items while the context is initializing. ` + + `Use ${this.constructor.name}.status or await ${this.constructor.name}.initialize() to make sure ` + + `that the context is not initializing before calling this method.`, + ); + + return undefined; + } + + if (this.theStatus === 'destroyed') { + warn( + `${this.constructor.name}.observeState() called on destroyed context! ` + + `Use ${this.constructor.name}.status to check the context status before calling this method. ` + + `When the context is destroyed, it cannot be used anymore and you should create a new context.`, + ); + + return undefined; + } + + // TODO - Look for a way to handle this + this.theDataSyncService?.setItemData(itemId, description, initialData ?? null); + + return { + setData: (data: ContextItemDataType) => { + this.theDataSyncService?.updateItemData(itemId, undefined, data); + }, + setDescription: (description: string) => { + this.theDataSyncService?.updateItemData(itemId, description, undefined); + }, + discard: () => { + this.theDataSyncService?.removeItem(itemId); + }, + }; + }; + + public registerTask = ( + taskId: string, + description: string, + callback: Function, + parameters: string[], + ): ContextTaskHandler | undefined => { + if (this.theStatus === 'idle') { + warn( + `${this.constructor.name}.registerTask() called on idle context! ` + + `Use ${this.constructor.name}.status to check the context status before calling this method. ` + + `Use ${this.constructor.name}.initialize() to initialize the context when it is not initialized.`, + ); + + return undefined; + } + + if (!this.theTasksService) { + warn( + `${this.constructor.name}.registerTask() called on a context that has does not have tasks service! ` + + `You should use an adapter that implements ContextTasksAdapter interface in order to register tasks. ` + + `Use ${this.constructor.name}.withAdapter() to set the right adapter before calling this method.`, + ); + + return undefined; + } + + if (this.theStatus === 'destroyed') { + warn( + `${this.constructor.name}.registerTask() called on destroyed context! ` + + `Use ${this.constructor.name}.status to check the context status before calling this method. ` + + `When the context is destroyed, it cannot be used anymore and you should create a new context.`, + ); + + return undefined; + } + + + let status: AiContextItemStatus = 'updating'; + + if (this.theTasksService.hasTask(taskId)) { + console.warn( + `${this.constructor.name}.registerTask() called with existing taskId: ${taskId}! ` + + `It's only possible to register a task once. Use ${this.constructor.name}.hasTask() to check ` + + `if the task already exists. Use ${this.constructor.name}.registerTask() with a different taskId if ` + + `you want to register a different task.`, + ); + + return undefined; + } + + this.theTasksService.registerTask(taskId, description, callback, parameters) + .then(() => { + if (status === 'updating') { + status = 'set'; + } + }) + .catch((error) => { + warn( + `${this.constructor.name}.registerTask() failed to register task \'${taskId}\'!\n` + + `The task will be marked as deleted and will not be updated anymore.`, + ); + + // TODO - Better error handling + + if (status === 'updating') { + status = 'deleted'; + this.unregisterTask(taskId); + } + }); + + return { + discard: () => { + status = 'deleted'; + this.unregisterTask(taskId); + }, + setDescription: (description: string) => { + if (status === 'deleted') { + throw new Error('Task has been deleted'); + } + + status = 'updating'; + + this.theTasksService?.updateTaskDescription(taskId, description) + .then(() => { + if (status === 'updating') { + status = 'set'; + } + }) + .catch((error) => { + if (status === 'updating') { + status = 'set'; + } + }); + }, + setCallback: (callback: Function) => { + if (status === 'deleted') { + throw new Error('Task has been deleted'); + } + + status = 'updating'; + + this.theTasksService?.updateTaskCallback(taskId, callback) + .then(() => { + if (status === 'updating') { + status = 'set'; + } + }) + .catch((error) => { + if (status === 'updating') { + status = 'set'; + } + }); + }, + setParamDescriptions: (paramDescriptions: string[]) => { + if (status === 'deleted') { + throw new Error('Task has been deleted'); + } + + status = 'updating'; + + this.theTasksService?.updateTaskParamDescriptions( + taskId, paramDescriptions, + ) + .then(() => { + if (status === 'updating') { + status = 'set'; + } + }) + .catch((error) => { + if (status === 'updating') { + status = 'set'; + } + }); + }, + }; + }; + + public reset = async (data?: ContextItems): Promise => { + if (!this.theDataSyncService) { + warn( + `${this.constructor.name}.reset() called on a state that has not been initialized! ` + + `Use ${this.constructor.name}.initialize() to initialize the context before attempting any reset.`, + ); + + return { + success: false, + error: 'Context has not been initialized', + }; + } + + try { + await this.theDataSyncService?.resetContextData(data); + await this.theTasksService?.resetContextData(); + this.theStatus = 'syncing'; + + return { + success: true, + }; + } catch (error) { + // TODO - Handle retry and error recovery + this.theStatus = 'error'; + return { + success: false, + error: `${error}`, + }; + } + }; + + public runTask = async (taskId: string, parameters?: Array): Promise => { + if (!this.theTasksService) { + warn( + `${this.constructor.name}.runTask() called on a state that has not been initialized! ` + + `Use ${this.constructor.name}.initialize() to initialize the context before attempting any task ` + + `execution.`, + ); + + return Promise.resolve({ + success: false, + error: 'Context has not been initialized with tasks service. An adapter that implements ' + + 'ContextTasksAdapter interface should be provided to the context, and the context should be ' + + 'initialized before running any tasks.', + }); + } + + return this.theTasksService.runTask(taskId, parameters); + }; + public withAdapter = ( + adapter: ContextAdapterBuilder | ContextAdapter, + ): AiContext => { + if (this.theDataAdapter) { + throw new Error('Adapter already set'); + } + + const isBuilder = typeof (adapter as any)?.build === 'function'; + if (isBuilder) { + this.theDataAdapter = (adapter as ContextAdapterBuilder).build(); + } else { + this.theDataAdapter = adapter as ContextAdapter; + } + + const adapterAsTaskAdapter = isContextTasksAdapter(this.theDataAdapter); + if (adapterAsTaskAdapter) { + this.theTasksAdapter = adapterAsTaskAdapter; + } + + return this; + }; + + public withDataSyncOptions = ( + options: DataSyncOptions, + ): AiContext => { + if (this.theDataSyncOptions) { + throw new Error('Data sync options already set'); + } + + this.theDataSyncOptions = {...options}; + return this; + }; + + private theDataAdapter: ContextAdapter | null = null; + private theDataSyncOptions: DataSyncOptions | null = null; + private theDataSyncService: DataSyncService | null = null; + private theStatus: AiContextStatus = 'idle'; + private theTasksAdapter: ContextTasksAdapter | null = null; + private theTasksService: TasksService | null = null; + + private unregisterTask = (taskId: string): Promise => { + if (!this.theTasksService) { + warn( + `${this.constructor.name}.unregisterTask() called on a state that has not been initialized! ` + + `Use ${this.constructor.name}.initialize() to initialize the context before attempting any task ` + + `unregistration.`, + ); + + return Promise.resolve({ + success: false, + error: 'Context has not been initialized', + }); + } + + return this.theTasksService.unregisterTask(taskId); + }; + + get contextId(): string | null { + return this.theDataSyncService?.contextId ?? null; + } + + get status(): AiContextStatus { + return this.theStatus; + } + + hasItem(itemId: string): boolean { + return this.theDataSyncService?.hasItemWithId(itemId) ?? false; + } + + hasRunnableTask(taskId: string): boolean { + return this.theTasksService?.canRunTask(taskId) ?? false; + } + + hasTask(taskId: string): boolean { + return this.theTasksService?.hasTask(taskId) ?? false; + } +} + +export const createAiContext = (): AiContext => { + return new AiContextImpl(); +}; diff --git a/packages/js/core/src/core/aiContext/dataSyncService.ts b/packages/js/core/src/core/aiContext/dataSyncService.ts new file mode 100644 index 00000000..282081fd --- /dev/null +++ b/packages/js/core/src/core/aiContext/dataSyncService.ts @@ -0,0 +1,337 @@ +import {ContextAdapter} from '../../types/adapters/context/contextAdapter'; +import {ContextDataAdapter} from '../../types/adapters/context/contextDataAdapter'; +import {DestroyContextResult, InitializeContextResult} from '../../types/aiContext/contextResults'; +import {ContextItemDataType, ContextItems} from '../../types/aiContext/data'; +import {warn} from '../../x/warn'; + +type UpdateQueueItem = { + operation: 'set'; + data: ContextItemDataType; + description: string; +} | { + operation: 'update'; + data?: ContextItemDataType; + description?: string; +} | { + operation: 'delete'; +}; + +export class DataSyncService { + + private actionToPerformWhenIdle: 'flush' | 'reset' | 'none' = 'none'; + private dataAdapter: ContextDataAdapter; + private readonly itemIds = new Set(); + private status: 'idle' | 'updating' | 'destroyed' = 'idle'; + private theContextId: string | null = null; + private readonly updateQueueByItemId: Map = new Map(); + + constructor(adapter: ContextDataAdapter | ContextAdapter) { + this.dataAdapter = adapter; + } + + get contextId(): string | null { + return this.theContextId; + } + + async createContext(initialItems?: ContextItems): Promise { + if (this.status === 'destroyed') { + return { + success: false, + error: 'The context has been destroyed', + }; + } + + const contextItems = initialItems ?? null; + this.itemIds.clear(); + if (contextItems !== null) { + const itemIds = Object.keys(contextItems); + itemIds.forEach((itemId) => { + this.itemIds.add(itemId); + }); + } + + this.actionToPerformWhenIdle = 'none'; + + try { + // TODO - Handle retry on failure. + const result = await this.dataAdapter.create(contextItems ?? {}); + if (result.success) { + this.theContextId = result.contextId; + return { + success: true, + contextId: result.contextId, + }; + } else { + return { + success: false, + error: 'Failed to set the context', + }; + } + } catch (error) { + return { + success: false, + error: `${error}`, + }; + } + } + + async destroy(): Promise { + if (this.status === 'destroyed') { + warn(`Context.DataSyncService.destroy() called on a state that has already been destroyed`); + return { + success: true, + }; + } + + if (this.status === 'updating' && !this.contextId) { + warn(`Context.DataSyncService.destroy() called with no contextId!`); + } + + if (this.contextId) { + this.status = 'updating'; + await this.dataAdapter.discard(this.contextId); + // TODO - Handle failure to reset context data. + } + + this.status = 'destroyed'; + this.theContextId = null; + this.updateQueueByItemId.clear(); + this.actionToPerformWhenIdle = 'none'; + + return { + success: true, + }; + } + + async flush(): Promise { + if (!this.contextId) { + throw new Error('Context not initialized'); + } + + if (this.status === 'updating') { + this.actionToPerformWhenIdle = 'flush'; + return; + } + + this.status = 'updating'; + + const itemsInQueue = this.updateQueueByItemId.keys(); + const itemsToUpdate: string[] = []; + const itemsToDelete: string[] = []; + for (const itemId of itemsInQueue) { + const item = this.updateQueueByItemId.get(itemId); + if (!item) { + continue; + } + + if (item.operation === 'delete') { + itemsToDelete.push(itemId); + continue; + } + + if (['set', 'update'].includes(item.operation)) { + itemsToUpdate.push(itemId); + continue; + } + } + + const itemsUpdateObject: Partial = itemsToUpdate.reduce( + (acc: Partial, itemId) => { + const op = this.updateQueueByItemId.get(itemId); + if (!op) { + return acc; + } + + if (op.operation === 'set') { + acc[itemId] = { + value: op.data, + description: op.description, + }; + } + + if ( + op.operation === 'update' && + (op.data !== undefined || op.description !== undefined) + ) { + // @ts-ignore + // We know that at least one of the two arguments is not undefined. + acc[itemId] = {value: op.data, description: op.description}; + } + + return acc; + }, + {}, + ); + + if (Object.keys(itemsUpdateObject).length > 0) { + Object.keys(itemsUpdateObject).forEach((itemId) => { + this.updateQueueByItemId.delete(itemId); + }); + + try { + await this.dataAdapter.updateItems(this.contextId, itemsUpdateObject); + } catch (error) { + warn(`Failed to update context data: ${error}`); + + // Reset the items that failed to update. + Object.keys(itemsUpdateObject).forEach((itemId) => { + const item = itemsUpdateObject[itemId]; + if (!item) { + return; + } + + this.updateQueueByItemId.set(itemId, { + operation: 'update', + data: item.value, + description: item.description, + }); + }); + } + } + + if (itemsToDelete.length > 0) { + itemsToDelete.forEach((itemId) => { + this.itemIds.delete(itemId); + this.updateQueueByItemId.delete(itemId); + }); + + try { + await this.dataAdapter.removeItems(this.contextId, itemsToDelete); + } catch (error) { + warn(`Failed to delete context data: ${error}`); + + // Reset the items that failed to delete. + itemsToDelete.forEach((itemId) => { + this.itemIds.add(itemId); + this.updateQueueByItemId.set(itemId, {operation: 'delete'}); + }); + } + } + + await this.backToIdle(); + } + + hasActiveItemWithId(itemId: string): boolean { + return this.itemIds.has(itemId) && ( + !this.updateQueueByItemId.has(itemId) || + this.updateQueueByItemId.get(itemId)?.operation !== 'delete' + ); + } + + hasItemWithId(itemId: string): boolean { + return this.itemIds.has(itemId); + } + + removeItem(itemId: string) { + if (this.status === 'destroyed') { + throw new Error('The context has been destroyed'); + } + + if (!this.contextId) { + throw new Error('Context not initialized'); + } + + if (!this.itemIds.has(itemId)) { + throw new Error('Item not found'); + } + + this.updateQueueByItemId.set(itemId, {operation: 'delete'}); + } + + async resetContextData(newInitialData?: ContextItems): Promise { + const victimContextId = this.contextId; + + this.itemIds.clear(); + this.updateQueueByItemId.clear(); + + if (this.status === 'updating') { + this.actionToPerformWhenIdle = 'reset'; + return; + } + + if (!victimContextId) { + warn(`resetContextData() called with no contextId!`); + await this.backToIdle(); + return; + } + + try { + this.status = 'updating'; + await this.dataAdapter.resetItems(victimContextId, newInitialData); + } catch (error) { + warn(`Failed to reset context data: ${error}`); + // TODO - Handle retry on failure. + } + + this.updateQueueByItemId.clear(); + if (newInitialData) { + this.itemIds.clear(); + const newItems = Object.keys(newInitialData); + newItems.forEach((itemId) => { + this.itemIds.add(itemId); + }); + } else { + this.itemIds.clear(); + } + + await this.backToIdle(); + } + + setItemData(itemId: string, description: string, data: ContextItemDataType) { + if (this.status === 'destroyed') { + throw new Error('The context has been destroyed'); + } + + this.updateQueueByItemId.set( + itemId, + { + operation: 'set', + description, + data, + }, + ); + + this.itemIds.add(itemId); + } + + updateItemData(itemId: string, description?: string, data?: ContextItemDataType) { + if (this.status === 'destroyed') { + throw new Error('The context has been destroyed'); + } + + if (data === undefined && description === undefined) { + return; + } + + const currentInQueue: UpdateQueueItem | undefined = this.updateQueueByItemId.get(itemId); + if (currentInQueue?.operation === 'delete') { + throw new Error('Item has been deleted'); + } + + const updatedData = data ?? currentInQueue?.data ?? undefined; + const updatedDescription = description ?? currentInQueue?.description ?? undefined; + + // We know that at least one of the two arguments is not undefined, which is what's required by + // updateQueueByItemId[itemId] type. + + this.updateQueueByItemId.set(itemId, { + operation: 'update', data: updatedData, description: updatedDescription, + }); + } + + private async backToIdle() { + this.status = 'idle'; + const actionToPerformWhenIdle = this.actionToPerformWhenIdle; + this.actionToPerformWhenIdle = 'none'; + + if (actionToPerformWhenIdle === 'flush') { + await this.flush(); + return; + } + + if (actionToPerformWhenIdle === 'reset') { + await this.resetContextData(); + return; + } + } +} diff --git a/packages/js/core/src/core/aiContext/options/dataSyncOptions.ts b/packages/js/core/src/core/aiContext/options/dataSyncOptions.ts new file mode 100644 index 00000000..a1452d6c --- /dev/null +++ b/packages/js/core/src/core/aiContext/options/dataSyncOptions.ts @@ -0,0 +1,29 @@ +export const predefinedContextSize = { + '1k': 1000, + '10k': 10000, + '100k': 100000, + '1mb': 1000000, + '10mb': 10000000, +}; + +/** + * Context data synchronization options. + */ +export type DataSyncOptions = { + /** + * Data synchronization strategy. + * - `auto` - Batch updates and automatically sync the context data. + * - `lazy` - Only sync when the user is about to send a message. + * + * Default: `auto` + */ + syncStrategy?: 'auto' | 'lazy'; + + /** + * The maximum size of the context data to be allowed and synced. + * When the limit is reached, the oldest data will be removed. + * + * Default: `10kb` + */ + contextSize?: number; +}; diff --git a/packages/js/core/src/core/aiContext/tasksService.ts b/packages/js/core/src/core/aiContext/tasksService.ts new file mode 100644 index 00000000..c94b14c5 --- /dev/null +++ b/packages/js/core/src/core/aiContext/tasksService.ts @@ -0,0 +1,417 @@ +import {ContextTasksAdapter} from '../../types/adapters/context/contextTasksAdapter'; +import {ContextActionResult, DestroyContextResult, RunTaskResult} from '../../types/aiContext/contextResults'; +import {ContextTasks} from '../../types/aiContext/data'; +import {warn} from '../../x/warn'; + +type UpdateQueueItem = { + operation: 'set'; + description: string; + paramDescriptions: string[]; + callback: Function; +} | { + operation: 'update'; + description?: string; + paramDescriptions?: string[]; + callback?: Function; +} | { + operation: 'delete'; +}; + +export class TasksService { + private actionToPerformWhenIdle: 'flush' | 'reset' | 'none' = 'none'; + private adapter: ContextTasksAdapter; + private readonly contextId: string; + private status: 'idle' | 'updating' | 'destroyed' = 'idle'; + private readonly taskCallbacks: Map = new Map(); + private readonly tasks: Set = new Set(); + private readonly updateQueueByTaskId: Map = new Map(); + + constructor(contextId: string, adapter: ContextTasksAdapter) { + this.contextId = contextId; + this.adapter = adapter; + } + + canRunTask(taskId: string): boolean { + return this.taskCallbacks.has(taskId); + } + + async destroy(): Promise { + if (this.status === 'destroyed') { + warn(`Context.TasksService.destroy() called on a state that has already been destroyed`); + return { + success: true, + }; + } + + this.status = 'updating'; + await this.unregisterAllTasks(); + + this.status = 'destroyed'; + this.updateQueueByTaskId.clear(); + this.tasks.clear(); + + return { + success: true, + }; + } + + async flush(): Promise { + if (this.status === 'updating') { + this.actionToPerformWhenIdle = 'flush'; + return; + } + + const itemsInQueue = this.updateQueueByTaskId.keys(); + const itemsToSet: string[] = []; + const itemsToUpdate: string[] = []; + const itemsToDelete: string[] = []; + + for (const itemId of itemsInQueue) { + const item = this.updateQueueByTaskId.get(itemId); + if (!item) { + continue; + } + + if (item.operation === 'delete') { + itemsToDelete.push(itemId); + continue; + } + + if (item.operation === 'set') { + itemsToSet.push(itemId); + continue; + } + + if (item.operation === 'update') { + itemsToUpdate.push(itemId); + continue; + } + } + + if (itemsToSet.length === 0 && itemsToUpdate.length === 0 && itemsToDelete.length === 0) { + return; + } + + // At least one item is in the queue, so we need to update the context. + this.status = 'updating'; + + const itemsToSetByItemIdData = this.buildUpdateObject(itemsToSet); + const itemsToUpdateByItemIdData = this.buildUpdateObject(itemsToUpdate); + const allItemsToUpdate = { + ...itemsToSetByItemIdData, + ...itemsToUpdateByItemIdData, + }; + + if (Object.keys(allItemsToUpdate).length > 0) { + try { + const registerTasksResult = await this.adapter.updateTasks( + this.contextId, allItemsToUpdate, + ); + + if (!registerTasksResult.success) { + warn( + `Context.TasksService.flush() failed to register tasks for context ID ${this.contextId}\n` + + `Error: ${registerTasksResult.error}`, + ); + } else { + for (const taskId of Object.keys(allItemsToUpdate)) { + const queuedTask = this.updateQueueByTaskId.get(taskId); + if (queuedTask && queuedTask.operation === 'set') { + this.taskCallbacks.set(taskId, queuedTask.callback); + this.updateQueueByTaskId.delete(taskId); + } + } + } + } catch (error) { + warn( + `Context.TasksService.flush() failed to register tasks for context ID ${this.contextId}\n` + + `Error: ${error}`, + ); + } + } + + if (itemsToDelete.length > 0) { + try { + const unregisterTasksResult = await this.adapter.removeTasks( + this.contextId, itemsToDelete, + ); + + if (!unregisterTasksResult.success) { + warn( + `Context.TasksService.flush() failed to unregister tasks for context ID ${this.contextId}\n` + + `Error: ${unregisterTasksResult.error}`, + ); + } else { + for (const taskId of itemsToDelete) { + this.taskCallbacks.delete(taskId); + this.updateQueueByTaskId.delete(taskId); + } + } + } catch (error) { + warn( + `Context.TasksService.flush() failed to unregister tasks for context ID ${this.contextId}\n` + + `Error: ${error}`, + ); + } + } + + await this.backToIdle(); + } + + hasTask(taskId: string): boolean { + return this.tasks.has(taskId); + } + + async registerTask( + taskId: string, + description: string, + callback: Function, + paramDescriptions?: string[], + ): Promise { + if (this.status === 'destroyed') { + throw new Error('Context has been destroyed'); + } + + if (this.tasks.has(taskId)) { + throw new Error(`A task with ID \'${taskId}\' already exists. Task IDs must be unique.`); + } + + this.updateQueueByTaskId.set(taskId, { + operation: 'set', + description, + paramDescriptions: paramDescriptions || [], + callback, + }); + + this.tasks.add(taskId); + } + + async resetContextData(): Promise { + const victimContextId = this.contextId; + + this.tasks.clear(); + this.taskCallbacks.clear(); + this.updateQueueByTaskId.clear(); + + if (this.status === 'updating') { + this.actionToPerformWhenIdle = 'reset'; + return; + } + + if (!victimContextId) { + warn(`resetContextData() called with no contextId!`); + await this.backToIdle(); + return; + } + + try { + this.status = 'updating'; + await this.unregisterAllTasks(); + } catch (error) { + warn(`Failed to reset context data: ${error}`); + // TODO - Handle retry on failure. + } + + await this.backToIdle(); + } + + async runTask(taskId: string, parameters?: Array): Promise { + if (this.status === 'destroyed') { + throw new Error('Context has been destroyed'); + } + + if (!this.tasks.has(taskId)) { + return { + success: false, + error: `Task with ID ${taskId} not found`, + }; + } + + const callback = this.taskCallbacks.get(taskId); + if (!callback) { + return { + success: false, + error: `The task with ID \'${taskId}\' has no callback. This is potential due to failed registration.`, + }; + } + + try { + const result = callback(parameters); + return { + success: true, + result, + }; + } catch (error) { + return { + success: false, + error: `${error}`, + }; + } + } + + async unregisterAllTasks(): Promise { + if (this.tasks.size === 0) { + return { + success: true, + }; + } + + const result = await this.adapter.resetTasks(this.contextId); + if (result.success) { + this.tasks.clear(); + this.taskCallbacks.clear(); + this.updateQueueByTaskId.clear(); + } + + return result; + } + + async unregisterTask(taskId: string): Promise { + if (this.status === 'destroyed') { + throw new Error('Context has been destroyed'); + } + + if (!this.tasks.has(taskId)) { + return { + success: true, + }; + } + + this.tasks.delete(taskId); + this.taskCallbacks.delete(taskId); + this.updateQueueByTaskId.set(taskId, { + operation: 'delete', + }); + + return { + success: true, + }; + } + + async updateTaskCallback(taskId: string, callback: Function): Promise { + if (this.status === 'destroyed') { + throw new Error('The context has been destroyed'); + } + + if (!this.tasks.has(taskId)) { + throw new Error(`Task with ID ${taskId} not found`); + } + + this.taskCallbacks.set(taskId, callback); + } + + async updateTaskDescription(taskId: string, description: string): Promise { + if (this.status === 'destroyed') { + throw new Error('The context has been destroyed'); + } + + if (!this.tasks.has(taskId)) { + throw new Error(`Task with ID ${taskId} not found`); + } + + const item = this.updateQueueByTaskId.get(taskId); + if (item) { + if (item.operation !== 'update') { + const newItem: UpdateQueueItem = { + operation: 'update', + description, + }; + + this.updateQueueByTaskId.set(taskId, newItem); + } else { + item.description = description; + } + } else { + this.updateQueueByTaskId.set(taskId, { + operation: 'update', + description, + }); + } + } + + async updateTaskParamDescriptions( + taskId: string, paramDescriptions: string[], + ): Promise { + if (this.status === 'destroyed') { + throw new Error('The context has been destroyed'); + } + + if (!this.tasks.has(taskId)) { + throw new Error(`Task with ID ${taskId} not found`); + } + + const item = this.updateQueueByTaskId.get(taskId); + if (item) { + if (item.operation !== 'update') { + const newItem: UpdateQueueItem = { + operation: 'update', + paramDescriptions, + }; + + this.updateQueueByTaskId.set(taskId, newItem); + } else { + item.paramDescriptions = paramDescriptions; + } + } else { + this.updateQueueByTaskId.set(taskId, { + operation: 'update', + paramDescriptions, + }); + } + } + + private async backToIdle() { + this.status = 'idle'; + const actionToPerformWhenIdle = this.actionToPerformWhenIdle; + this.actionToPerformWhenIdle = 'none'; + + if (actionToPerformWhenIdle === 'flush') { + await this.flush(); + return; + } + + if (actionToPerformWhenIdle === 'reset') { + await this.unregisterAllTasks(); + return; + } + } + + private buildUpdateObject(itemIds: string[]): ContextTasks { + return itemIds.reduce( + (acc: any, itemId) => { + const item = this.updateQueueByTaskId.get(itemId); + if (!item) { + return acc; + } + + if (item.operation === 'set') { + acc[itemId] = { + description: item.description, + paramDescriptions: item.paramDescriptions, + }; + } + + if ( + item.operation === 'update' && + (item.description !== undefined || item.paramDescriptions !== undefined) + ) { + // @ts-ignore + // We know that at least one of the two arguments is not undefined. + const updateData: any = {}; + if (item.description !== undefined) { + updateData.description = item.description; + } + + if (item.paramDescriptions !== undefined) { + updateData.paramDescriptions = item.paramDescriptions; + } + + acc[itemId] = updateData; + } + + return acc; + }, + {}, + ); + } +} diff --git a/packages/js/core/src/core/interface.ts b/packages/js/core/src/core/interface.ts deleted file mode 100644 index 0b198f45..00000000 --- a/packages/js/core/src/core/interface.ts +++ /dev/null @@ -1,33 +0,0 @@ -import {ChatAdapterBuilder} from '../types/aiChat/chatAdapterBuilder'; -import {AiChatProps} from '../types/aiChat/props'; -import {ConversationItem} from '../types/conversation'; -import {EventCallback, EventName, EventsMap} from '../types/event'; -import {HighlighterExtension} from './aiChat/highlighter/highlighter'; -import {ConversationOptions} from './aiChat/options/conversationOptions'; -import {LayoutOptions} from './aiChat/options/layoutOptions'; -import {PersonaOptions} from './aiChat/options/personaOptions'; -import {PromptBoxOptions} from './aiChat/options/promptBoxOptions'; - -export interface IAiChat { - hide(): void; - mount(rootElement: HTMLElement): void; - get mounted(): boolean; - - on(event: EventName, callback: EventsMap[EventName]): IAiChat; - removeAllEventListeners(event?: EventName): void; - removeEventListener(event: EventName, callback: EventCallback): void; - - show(): void; - unmount(): void; - updateProps(props: Partial): void; - - withAdapter(adapterBuilder: ChatAdapterBuilder): IAiChat; - withClassName(className: string): IAiChat; - withConversationOptions(conversationOptions: ConversationOptions): IAiChat; - withInitialConversation(initialConversation: ConversationItem[]): IAiChat; - withLayoutOptions(layoutOptions: LayoutOptions): IAiChat; - withPersonaOptions(personaOptions: PersonaOptions): IAiChat; - withPromptBoxOptions(promptBoxOptions: PromptBoxOptions): IAiChat; - withSyntaxHighlighter(syntaxHighlighter: HighlighterExtension): IAiChat; - withTheme(themeId: string): IAiChat; -} diff --git a/packages/js/core/src/index.ts b/packages/js/core/src/index.ts index 75871519..7de9baef 100644 --- a/packages/js/core/src/index.ts +++ b/packages/js/core/src/index.ts @@ -1,4 +1,4 @@ -// CHAT -------- +// CHAT _____________________ import {AiChat} from './core/aiChat/aiChat'; @@ -32,10 +32,6 @@ export type { AiChatInternalProps, } from './types/aiChat/props'; -export type { - AiTaskRunner, -} from './types/aiAssistant/taskRunner'; - export type { EventName, EventCallback, @@ -52,7 +48,7 @@ export type { export type { StandardChatAdapter, -} from './types/aiChat/standardChatAdapter'; +} from './types/adapters/chat/standardChatAdapter'; export type { StandardAdapterInfo, @@ -60,7 +56,7 @@ export type { AdapterDecodeFunction, InputFormat, OutputFormat, -} from './types/aiChat/standardAdapterConfig'; +} from './types/adapters/chat/standardAdapterConfig'; export type { AiChatProps, @@ -68,14 +64,26 @@ export type { export type { ChatAdapter, - ChatAdapterExtras, StreamingAdapterObserver, DataTransferMode, -} from './types/aiChat/chatAdapter'; +} from './types/adapters/chat/chatAdapter'; + +export type { + ChatAdapterExtras, +} from './types/adapters/chat/chaAdapterExtras'; export type { ChatAdapterBuilder, -} from './types/aiChat/chatAdapterBuilder'; +} from './types/adapters/chat/chatAdapterBuilder'; + +export type { + AssistResult, + AssistAdapter, +} from './types/adapters/assist/assistAdapter'; + +export type { + AssistAdapterBuilder, +} from './types/adapters/assist/assistAdapterBuilder'; export type { StreamParser, @@ -83,57 +91,71 @@ export type { StandardStreamParserOutput, } from './types/markdown/streamParser'; -// CONTEXT -------- - -export type { - GetContextDataResult, - GetContextDataCallback, -} from './types/aiContext/get'; +// CONTEXT __________________ export type { - SetContextResult, - SetContextCallback, -} from './types/aiContext/set'; + ContextItemDataType, + ContextObject, + ContextItems, + ContextTasks, + ContextItem, + ContextTask, +} from './types/aiContext/data'; export type { - UpdateContextResult, - UpdateContextCallback, -} from './types/aiContext/update'; + ContextAdapter, +} from './types/adapters/context/contextAdapter'; export type { - ClearContextResult, - ClearContextCallback, -} from './types/aiContext/clear'; + ContextAdapterExtras, +} from './types/adapters/context/contextAdapterExtras'; export type { - ContextData, -} from './types/aiContext/data'; + ContextTasksAdapter, +} from './types/adapters/context/contextTasksAdapter'; export type { - ContextAdapter, -} from './types/aiContext/contextAdapter'; + ContextDataAdapter, +} from './types/adapters/context/contextDataAdapter'; export type { ContextAdapterBuilder, -} from './types/aiContext/builder'; +} from './types/adapters/context/contextAdapterBuilder'; -// ASSISTANT -------- +export type { + AiContext, + AiContextStatus, +} from './types/aiContext/aiContext'; export type { - AssistResult, -} from './types/aiAssistant/assist'; + ContextItemHandler, + ContextTaskHandler, + ContextDomElementHandler, +} from './types/aiContext/contextObservers'; export type { - RegisterTaskResult, - RegisterTaskCallback, -} from './types/aiAssistant/registerTask'; + InitializeContextResult, + DestroyContextResult, + FlushContextResult, + RunTaskResult, + ContextActionResult, + SetContextResult, +} from './types/aiContext/contextResults'; export type { - UnregisterTaskResult, - UnregisterTaskCallback, -} from './types/aiAssistant/unregisterTask'; + DataSyncOptions, +} from './core/aiContext/options/dataSyncOptions'; + +export { + createAiContext, +} from './core/aiContext/aiContext'; + +export { + predefinedContextSize, +} from './core/aiContext/options/dataSyncOptions'; + -// HIGHLIGHTER -------- +// HIGHLIGHTER ______________ export type { Highlighter, @@ -142,7 +164,7 @@ export type { CreateHighlighterOptions, } from './core/aiChat/highlighter/highlighter'; -// OTHERS -------- +// OTHER ____________________ export type { ConversationItem, diff --git a/packages/js/core/src/types/adapters/assist/assistAdapter.ts b/packages/js/core/src/types/adapters/assist/assistAdapter.ts new file mode 100644 index 00000000..b04244b8 --- /dev/null +++ b/packages/js/core/src/types/adapters/assist/assistAdapter.ts @@ -0,0 +1,43 @@ +import {ChatAdapterExtras} from '../chat/chaAdapterExtras'; + +/** + * This type represents the result of an assist request. + * + * If the request was successful, the `success` property will be `true` and the `response` property will contain the + * text response to be displayed to the user. In addition, when the `task` property is present, it will contain the + * details of the task to be executed by the client. + * + * If the request was not successful, the `success` property will be `false` and the `error` property will contain the + * error message to be displayed to the user. + */ +export type AssistResult = { + success: true; + response: string; + task?: { + id: string; + parameters: string[]; + }; +} | { + success: false; + error: string; +}; + +/** + * This interface exposes methods that should be implemented by adapters used when the AiChat is in co-pilot mode. + * The difference between this and the `ChatAdapter` interface is that this adapter can return a task to be executed + * by the client in addition to the text response to be displayed. + * + * Assist adapters can only be used in fetch mode, and the response cannot be streamed. + */ +export interface AssistAdapter { + /** + * This method should be implemented by any adapter that wants to request data from the API in fetch mode. + * It should return a promise that resolves to the response from the API. + * Either this method or `streamText` (or both) should be implemented by any adapter. + * + * @param `string` message + * @param `ChatAdapterExtras` extras + * @returns Promise + */ + assist?: (message: string, extras: ChatAdapterExtras) => Promise; +} diff --git a/packages/js/core/src/types/adapters/assist/assistAdapterBuilder.ts b/packages/js/core/src/types/adapters/assist/assistAdapterBuilder.ts new file mode 100644 index 00000000..61d1a72d --- /dev/null +++ b/packages/js/core/src/types/adapters/assist/assistAdapterBuilder.ts @@ -0,0 +1,12 @@ +import {AssistAdapter} from './assistAdapter'; + +/** + * The base interface for creating a new instance of a StandardChatAdapter. + * Adapter builders can extend this interface to add additional methods for configuration. + */ +export interface AssistAdapterBuilder { + /** + * Create a new instance of an AssistAdapter. + */ + create(): AssistAdapter; +} diff --git a/packages/js/core/src/types/adapters/chat/chaAdapterExtras.ts b/packages/js/core/src/types/adapters/chat/chaAdapterExtras.ts new file mode 100644 index 00000000..219b6ef8 --- /dev/null +++ b/packages/js/core/src/types/adapters/chat/chaAdapterExtras.ts @@ -0,0 +1,30 @@ +import {AiChatProps} from '../../aiChat/props'; +import {ConversationItem} from '../../conversation'; + +/** + * Additional data sent to the adapter when a message is sent. + */ +export type ChatAdapterExtras = { + /** + * This attribute contains the properties used with the AiChat component. + */ + aiChatProps: AiChatProps; + + /** + * This attribute contains the conversation history. + * It's only included if the `conversationOptions.historyPayloadSize` is set to a positive number or 'all'. + */ + conversationHistory?: Readonly; + + /** + * This attribute contains the unique identifier of the context instance. + * It's only included if a context instance is used with the AiChat component. + * This can be used to send the context ID to the API and get a response that is specific to the context instance. + */ + contextId?: string; + + /** + * This contains the headers that implementers can use to send additional data such as authentication headers. + */ + headers?: Record; +} diff --git a/packages/js/core/src/types/aiChat/chatAdapter.ts b/packages/js/core/src/types/adapters/chat/chatAdapter.ts similarity index 65% rename from packages/js/core/src/types/aiChat/chatAdapter.ts rename to packages/js/core/src/types/adapters/chat/chatAdapter.ts index 8d529273..4d5b8be5 100644 --- a/packages/js/core/src/types/aiChat/chatAdapter.ts +++ b/packages/js/core/src/types/adapters/chat/chatAdapter.ts @@ -1,5 +1,4 @@ -import {ConversationItem} from '../conversation'; -import {AiChatProps} from './props'; +import {ChatAdapterExtras} from './chaAdapterExtras'; /** * This type is used to indicate the mode in which the adapter should request data from the API. @@ -7,8 +6,12 @@ import {AiChatProps} from './props'; export type DataTransferMode = 'stream' | 'fetch'; /** - * This interface exposes methods that should be implemented by any adapter to connect the AiChat component - * to any API or AI backend. + * This interface exposes methods that should be implemented by any chat adapter to connect the AiChat component + * to any API or AI backend. Chat adapters can be used to request data from the API in fetch mode or stream mode. + * + * The difference between this and the `AssistAdapter` interface is that this adapter can only return a text response + * to be displayed to the user. It cannot return a task to be executed by the client. If you are using the `AiChat` + * component in co-pilot mode, you should use the `AssistAdapter` interface instead. */ export interface ChatAdapter { /** @@ -20,7 +23,10 @@ export interface ChatAdapter { * @param `ChatAdapterExtras` extras * @returns Promise */ - fetchText?: (message: string, extras: ChatAdapterExtras) => Promise; + fetchText?: ( + message: string, + extras: ChatAdapterExtras, + ) => Promise; /** * This method should be implemented by any adapter to be used with nlux. @@ -30,7 +36,11 @@ export interface ChatAdapter { * @param {StreamingAdapterObserver} observer * @param {ChatAdapterExtras} extras */ - streamText?: (message: string, observer: StreamingAdapterObserver, extras: ChatAdapterExtras) => void; + streamText?: ( + message: string, + observer: StreamingAdapterObserver, + extras: ChatAdapterExtras, + ) => void; } /** @@ -48,34 +58,22 @@ export interface StreamingAdapterObserver { /** * This method should be called by the adapter when it has an error to send to the AiChat user interface. * This will result in the AiChat component displaying an error message to the user, resetting the - * conversation text input, removing the loading indicator, removing the sent message from the conversation. + * conversation text input, removing the loading indicator, removing the message sent from the conversation. * - * The error will be logged to the console but it will not be displayed to the user. A generic error message + * The error will be logged to the console, but it will not be displayed to the user. A generic error message * will be displayed to the user instead. * * @param {Error} error */ - error(error: Error): void; + error( + error: Error, + ): void; /** * This method should be called by the adapter when it has new data to send to the AiChat user interface. * @param {string} message */ - next(message: string): void; -} - -/** - * Additional data sent to the adapter when a message is sent. - */ -export type ChatAdapterExtras = { - /** - * This attribute contains the properties used with the AiChat component. - */ - aiChatProps: AiChatProps; - - /** - * This attribute contains the conversation history. - * It's only included if the `conversationOptions.historyPayloadSize` is set to a positive number or 'all'. - */ - conversationHistory?: Readonly; + next( + message: string, + ): void; } diff --git a/packages/js/core/src/types/adapters/chat/chatAdapterBuilder.ts b/packages/js/core/src/types/adapters/chat/chatAdapterBuilder.ts new file mode 100644 index 00000000..5217edbb --- /dev/null +++ b/packages/js/core/src/types/adapters/chat/chatAdapterBuilder.ts @@ -0,0 +1,9 @@ +import {StandardChatAdapter} from './standardChatAdapter'; + +/** + * The base interface for creating a new instance of a StandardChatAdapter. + * Adapter builders can extend this interface to add additional methods for configuration. + */ +export interface ChatAdapterBuilder { + create(): StandardChatAdapter; +} diff --git a/packages/js/core/src/types/aiChat/standardAdapterConfig.ts b/packages/js/core/src/types/adapters/chat/standardAdapterConfig.ts similarity index 75% rename from packages/js/core/src/types/aiChat/standardAdapterConfig.ts rename to packages/js/core/src/types/adapters/chat/standardAdapterConfig.ts index 6bb592d0..36df9a9f 100644 --- a/packages/js/core/src/types/aiChat/standardAdapterConfig.ts +++ b/packages/js/core/src/types/adapters/chat/standardAdapterConfig.ts @@ -4,6 +4,10 @@ export type InputFormat = 'text'; export type AdapterEncodeFunction = (input: string) => Promise; export type AdapterDecodeFunction = (output: InboundPayload) => Promise; +/** + * This type represents the information that the AiChat needs to know about an adapter. + * It is used to determine which adapters are available and what capabilities they have. + */ export type StandardAdapterInfo = Readonly<{ id: string; capabilities: Readonly<{ diff --git a/packages/js/core/src/types/adapters/chat/standardChatAdapter.ts b/packages/js/core/src/types/adapters/chat/standardChatAdapter.ts new file mode 100644 index 00000000..e3ef5711 --- /dev/null +++ b/packages/js/core/src/types/adapters/chat/standardChatAdapter.ts @@ -0,0 +1,36 @@ +import {ChatAdapterExtras} from './chaAdapterExtras'; +import {StreamingAdapterObserver} from './chatAdapter'; +import {StandardAdapterInfo} from './standardAdapterConfig'; + +/** + * This interface is used by standard adapters provided by nlux to communicate with the AiChat component. + */ +export interface StandardChatAdapter { + get dataTransferMode(): 'stream' | 'fetch'; + + fetchText( + message: string, + extras: ChatAdapterExtras, + ): Promise; + + get id(): string; + get info(): StandardAdapterInfo; + + streamText( + message: string, + observer: StreamingAdapterObserver, + extras: ChatAdapterExtras, + ): void; +} + +/** + * This function is used to determine if an object is a standard chat adapter or not. + * @param adapter + */ +export const isStandardChatAdapter = (adapter: any): boolean => { + return (typeof adapter === 'object' && adapter !== null) + && (typeof adapter.streamText === 'function' || typeof adapter.fetchText === 'function') + && ['stream', 'fetch'].includes(adapter.dataTransferMode) + && typeof adapter.id === 'string' + && (typeof adapter.info === 'object' && adapter.info !== null); +}; diff --git a/packages/js/core/src/types/adapters/context/contextAdapter.ts b/packages/js/core/src/types/adapters/context/contextAdapter.ts new file mode 100644 index 00000000..d09b7f8f --- /dev/null +++ b/packages/js/core/src/types/adapters/context/contextAdapter.ts @@ -0,0 +1,14 @@ +import {ContextDataAdapter} from './contextDataAdapter'; +import {ContextTasksAdapter} from './contextTasksAdapter'; + +/** + * The context adapter context-aware chat experience and AI assistants. + * This type provides the methods for both context data and tasks that should be implemented by adapters + * in order to synchronize data related to the context between the frontend and the backend. + * + * If your chat experience does not require the execution of tasks, you can use the ContextDataAdapter type instead. + * But if you need the LLM to execute tasks, as well as access the context data, you should use the ContextAdapter type + * to implement both the context data and tasks. + */ +export interface ContextAdapter extends ContextDataAdapter, ContextTasksAdapter { +} diff --git a/packages/js/core/src/types/adapters/context/contextAdapterBuilder.ts b/packages/js/core/src/types/adapters/context/contextAdapterBuilder.ts new file mode 100644 index 00000000..12a7f880 --- /dev/null +++ b/packages/js/core/src/types/adapters/context/contextAdapterBuilder.ts @@ -0,0 +1,10 @@ +import {ContextAdapter} from './contextAdapter'; + +/** + * This represents the base interface for the context adapter builders. + * The create method should be implemented to return a new instance of the context adapter. + * Additional methods can be added to the builder to configure the context adapter via chaining. + */ +export interface ContextAdapterBuilder { + build(): ContextAdapter; +} diff --git a/packages/js/core/src/types/adapters/context/contextAdapterExtras.ts b/packages/js/core/src/types/adapters/context/contextAdapterExtras.ts new file mode 100644 index 00000000..c24bf1db --- /dev/null +++ b/packages/js/core/src/types/adapters/context/contextAdapterExtras.ts @@ -0,0 +1,10 @@ +/** + * This represents a set of extra data that can be sent to the context adapter. + * It can be used by implementations to send additional data such as authentication headers. + */ +export type ContextAdapterExtras = { + /** + * This contains the headers that implementers can use to send additional data such as authentication headers. + */ + headers?: Record; +}; diff --git a/packages/js/core/src/types/adapters/context/contextDataAdapter.ts b/packages/js/core/src/types/adapters/context/contextDataAdapter.ts new file mode 100644 index 00000000..eb0c5fd6 --- /dev/null +++ b/packages/js/core/src/types/adapters/context/contextDataAdapter.ts @@ -0,0 +1,94 @@ +import {ContextActionResult, SetContextResult} from '../../aiContext/contextResults'; +import {ContextItems} from '../../aiContext/data'; +import {ContextAdapterExtras} from './contextAdapterExtras'; + +/** + * The context data adapter is responsible for synchronizing the context data between the frontend application + * and the backend system. In order to build a context-aware chat experience and AI assistants, the context + * adapter should be used. + * + * nlux does not set any restrictions on the context data structure or where and how the data should be stored, + * but it expects the backend system responsible for generating the chat responses to be able to access the + * context data as needed. + * + * The goal of the context this adapter is to facilitate providing the context data to the backend. + * The following methods are expected to be implemented by the context data adapter: + * + * - Set context data: On initial load, the context data should be set to the initial state. + * - Get context data: Data loaded from the backend. + * - Update context data: Called when the context data is updated. + * - Clear context data: When the app is closed or the user logs out, the context data should be cleared. + */ +export interface ContextDataAdapter { + /** + * Creates a new context and sets the initial context data when provided. + * On success, the new context ID should be returned. + * + * @param {Object} initialData + * @param {ContextAdapterExtras} extras + * @returns {Promise} + */ + create: ( + initialItems?: ContextItems, + extras?: ContextAdapterExtras, + ) => Promise; + + /** + * Deletes the context data and any registered tasks for the given context ID, and makes the context ID invalid. + * This method should be used when the context is no longer needed. + * + * @param {string} contextId + * @param {ContextAdapterExtras} extras + * @returns {Promise} + */ + discard: ( + contextId: string, + extras?: ContextAdapterExtras, + ) => Promise; + + /** + * Deletes the data for the given context ID and item IDs. + * + * @param {string} contextId The context ID. + * @param {string[]} itemIds The item IDs to delete. + * @param {ContextAdapterExtras} extras + * @returns {Promise} + */ + removeItems: ( + contextId: string, + itemIds: string[], + extras?: ContextAdapterExtras, + ) => Promise; + + /** + * Resets the context data for the given context ID. + * If no new context data is not provided, the context will be emptied. + * If new context data is provided, it will replace the existing context data. + * + * @param {string} contextId + * @param {ContextItems} newData + * @param {ContextAdapterExtras} extras + * @returns {Promise} + */ + resetItems: ( + contextId: string, + newData?: ContextItems, + extras?: ContextAdapterExtras, + ) => Promise; + + /** + * Updates data for the given context ID. + * + * + * @param {string} contextId + * @param {string} itemId + * @param {Object} data + * @param {ContextAdapterExtras} extras + * @returns {Promise} + */ + updateItems: ( + contextId: string, + itemsToUpdate: Partial, + extras?: ContextAdapterExtras, + ) => Promise; +} diff --git a/packages/js/core/src/types/adapters/context/contextTasksAdapter.ts b/packages/js/core/src/types/adapters/context/contextTasksAdapter.ts new file mode 100644 index 00000000..8b614312 --- /dev/null +++ b/packages/js/core/src/types/adapters/context/contextTasksAdapter.ts @@ -0,0 +1,61 @@ +import {ContextActionResult} from '../../aiContext/contextResults'; +import {ContextTasks} from '../../aiContext/data'; +import {ContextAdapterExtras} from './contextAdapterExtras'; + +/** + * The context tasks adapter is responsible for registering and unregistering tasks that can be triggered by + * the AI assistant. The tasks are front-end specific but can be triggered by backend based on specific user + * prompts in the AI chat. In order to build a context-aware chat experience that can also trigger front-end + * tasks, the context tasks adapter should be used to let the backend know about the tasks that can be triggered. + * + * The following methods are expected to be implemented by the context tasks adapter: + * + * - Register task: When a new screen is loaded, or a specific state is reached, a new task can be registered. + * - Unregister task: When the screen is closed or the state is no longer valid, the task should be unregistered. + */ +export interface ContextTasksAdapter { + /** + * Unregisters specific tasks from the given context ID, based on their task IDs. + * + * @param {string} contextId + * @param {string} taskIds[] + * @param {ContextAdapterExtras} extras + * @returns {Promise} + */ + removeTasks: ( + contextId: string, + taskIds: string[], + extras?: ContextAdapterExtras, + ) => Promise; + + /** + * Resets the tasks for the given context ID. + * If new tasks are provided, they will replace the existing tasks. + * If no tasks are provided, all the tasks will be emptied. + * + * @param {string} contextId + * @param {ContextAdapterExtras} extras + * @returns {Promise} + */ + resetTasks: ( + contextId: string, + newTasks?: ContextTasks, + extras?: ContextAdapterExtras, + ) => Promise; + + /** + * Updates the tasks included in the `tasks` object, for the given context ID. + * Tasks that are not included in the `tasks` object should be left unchanged. + * If you want to remove a task, you should use the `removeTasks` method. + * + * @param {string} contextId + * @param {Partial} tasks + * @param {ContextAdapterExtras} extras + * @returns {Promise} + */ + updateTasks: ( + contextId: string, + tasks: Partial, + extras?: ContextAdapterExtras, + ) => Promise; +} diff --git a/packages/js/core/src/types/aiAssistant/assist.ts b/packages/js/core/src/types/aiAssistant/assist.ts deleted file mode 100644 index ac73ede5..00000000 --- a/packages/js/core/src/types/aiAssistant/assist.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type AssistResult = { - success: true; - response: string; -} | { - success: true; - response: string; - taskId: string; - parameters: string[]; -} | { - success: false; - error: string; -}; - -export type AssistCallback = ( - contextId: string, - prompt: string, -) => Promise; diff --git a/packages/js/core/src/types/aiAssistant/builder.ts b/packages/js/core/src/types/aiAssistant/builder.ts deleted file mode 100644 index 8ed46b2f..00000000 --- a/packages/js/core/src/types/aiAssistant/builder.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {ContextAdapter} from '../aiContext/contextAdapter'; - -export interface AssistantAdapterBuilder { - create(): ContextAdapter; -} diff --git a/packages/js/core/src/types/aiAssistant/registerTask.ts b/packages/js/core/src/types/aiAssistant/registerTask.ts deleted file mode 100644 index 2b79cd71..00000000 --- a/packages/js/core/src/types/aiAssistant/registerTask.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type RegisterTaskResult = { - success: true; -} | { - success: false; - error: string; -}; - -export type RegisterTaskCallback = ( - contextId: string, - taskId: string, - parameters: string[], -) => Promise; diff --git a/packages/js/core/src/types/aiAssistant/taskRunner.ts b/packages/js/core/src/types/aiAssistant/taskRunner.ts deleted file mode 100644 index 85acbc2d..00000000 --- a/packages/js/core/src/types/aiAssistant/taskRunner.ts +++ /dev/null @@ -1 +0,0 @@ -export type AiTaskRunner = (taskId: string, params: Array) => void; diff --git a/packages/js/core/src/types/aiAssistant/unregisterTask.ts b/packages/js/core/src/types/aiAssistant/unregisterTask.ts deleted file mode 100644 index ab7b5fd3..00000000 --- a/packages/js/core/src/types/aiAssistant/unregisterTask.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type UnregisterTaskResult = { - success: true; -} | { - success: false; - error: string; -}; - -export type UnregisterTaskCallback = ( - contextId: string, - taskId: string, -) => Promise; diff --git a/packages/js/core/src/types/aiChat/aiChat.ts b/packages/js/core/src/types/aiChat/aiChat.ts new file mode 100644 index 00000000..9666deab --- /dev/null +++ b/packages/js/core/src/types/aiChat/aiChat.ts @@ -0,0 +1,160 @@ +import {HighlighterExtension} from '../../core/aiChat/highlighter/highlighter'; +import {ConversationOptions} from '../../core/aiChat/options/conversationOptions'; +import {LayoutOptions} from '../../core/aiChat/options/layoutOptions'; +import {PersonaOptions} from '../../core/aiChat/options/personaOptions'; +import {PromptBoxOptions} from '../../core/aiChat/options/promptBoxOptions'; +import {ChatAdapterBuilder} from '../adapters/chat/chatAdapterBuilder'; +import {ConversationItem} from '../conversation'; +import {EventCallback, EventName, EventsMap} from '../event'; +import {AiChatProps} from './props'; + +/** + * The main interface representing AiChat component. + * It provides methods to instantiate, mount, and unmount the component, and listen to its events. + */ +export interface IAiChat { + /** + * Hides the chat component. + * This does not unmount the component. It will only hide the chat component from the view. + */ + hide(): void; + + /** + * Mounts the chat component to the given root element. + * + * @param {HTMLElement} rootElement + */ + mount(rootElement: HTMLElement): void; + + /** + * Returns true if the chat component is mounted. + */ + get mounted(): boolean; + + /** + * Adds an event listener to the chat component. + * The callback will be called when the event is emitted, with the expected event details. + * + * @param {EventName} event The name of the event to listen to. + * @param {EventsMap[EventName]} callback The callback to be called, that should match the event type. + * @returns {IAiChat} + */ + on(event: EventName, callback: EventsMap[EventName]): IAiChat; + + /** + * Removes all event listeners from the chat component. + * When a valid event name is provided, it will remove all listeners for that event. + * Otherwise, it will remove all listeners for all events. + * + * @param {EventName} event + */ + removeAllEventListeners(event?: EventName): void; + + /** + * Removes the given event listener for the specified event. + * + * @param {EventName} event The name of the event to remove the listener from. + * @param {EventsMap[EventName]} callback The callback to be removed. + */ + removeEventListener(event: EventName, callback: EventCallback): void; + + /** + * Shows the chat component. + * This method expects the chat component to be mounted. + */ + show(): void; + + /** + * Unmounts the chat component. + * This will remove the chat component from the view and clean up its resources. + * After unmounting, the chat component can be mounted again. + */ + unmount(): void; + + /** + * Updates the properties of the chat component. This method expects the chat component to be mounted. + * The properties will be updated and the relevant parts of the chat component will be re-rendered. + * + * @param {Partial} props The properties to be updated. + */ + updateProps(props: Partial): void; + + /** + * Enabled providing an adapter to the chat component. + * The adapter will be used to send and receive messages from the chat backend. + * This method should be called before mounting the chat component, and it should be called only once. + * + * @param {ChatAdapterBuilder} adapterBuilder The builder for the chat adapter. + **/ + withAdapter(adapterBuilder: ChatAdapterBuilder): IAiChat; + + /** + * Enables providing a class name to the chat component. + * The class name will be added to the root element of the chat component. + * This method should be called before mounting the chat component, and it should be called only once. + * + * @param {string} className The class name to be added to the chat component. + */ + withClassName(className: string): IAiChat; + + /** + * Enables providing conversation options to the chat component. + * The conversation options will be used to configure the conversation behavior. + * This method can be called before mounting the chat component, and it can be called only once. + * + * @param {ConversationOptions} conversationOptions The conversation options to be used. + */ + withConversationOptions(conversationOptions: ConversationOptions): IAiChat; + + /** + * Enables providing an initial conversation to the chat component. + * The initial conversation will be used to populate the chat component with a conversation history. + * This method can be called before mounting the chat component, and it can be called only once. + * + * @param {ConversationItem[]} initialConversation + * @returns {IAiChat} + */ + withInitialConversation(initialConversation: ConversationItem[]): IAiChat; + + /** + * Enables providing layout options to the chat component. The layout options will be used to configure the + * layout of the chat component. When no layout options are provided, the default layout options will be used. + * This method can be called before mounting the chat component, and it can be called only once. + * + * @param {LayoutOptions} layoutOptions The layout options to be used. + */ + withLayoutOptions(layoutOptions: LayoutOptions): IAiChat; + + /** + * Enables providing persona options to the chat component. The persona options will be used to configure + * the bot and user personas in the chat component. + * This method can be called before mounting the chat component, and it can be called only once. + * + * @param {PersonaOptions} personaOptions The persona options to be used. + */ + withPersonaOptions(personaOptions: PersonaOptions): IAiChat; + + /** + * Enables providing prompt box options to the chat component. + * This method can be called before mounting the chat component, and it can be called only once. + * + * @param {PromptBoxOptions} promptBoxOptions The prompt box options to be used. + */ + withPromptBoxOptions(promptBoxOptions: PromptBoxOptions): IAiChat; + + /** + * Enables providing a syntax highlighter to the chat component. + * This method can be called before mounting the chat component, and it can be called only once. + * + * @param {HighlighterExtension} syntaxHighlighter The syntax highlighter to be used. + */ + withSyntaxHighlighter(syntaxHighlighter: HighlighterExtension): IAiChat; + + /** + * Enables providing a theme to the chat component. + * This method can be called before mounting the chat component, and it can be called only once. + * + * @param {string} themeId The id of the theme to be used. + */ + withTheme(themeId: string): IAiChat; +} diff --git a/packages/js/core/src/types/aiChat/chatAdapterBuilder.ts b/packages/js/core/src/types/aiChat/chatAdapterBuilder.ts deleted file mode 100644 index 1af1766d..00000000 --- a/packages/js/core/src/types/aiChat/chatAdapterBuilder.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {StandardChatAdapter} from './standardChatAdapter'; - -export interface ChatAdapterBuilder { - create(): StandardChatAdapter; -} diff --git a/packages/js/core/src/types/aiChat/props.ts b/packages/js/core/src/types/aiChat/props.ts index 323052fd..51259704 100644 --- a/packages/js/core/src/types/aiChat/props.ts +++ b/packages/js/core/src/types/aiChat/props.ts @@ -3,11 +3,14 @@ import {ConversationOptions} from '../../core/aiChat/options/conversationOptions import {LayoutOptions} from '../../core/aiChat/options/layoutOptions'; import {PersonaOptions} from '../../core/aiChat/options/personaOptions'; import {PromptBoxOptions} from '../../core/aiChat/options/promptBoxOptions'; +import {ChatAdapter} from '../adapters/chat/chatAdapter'; +import {StandardChatAdapter} from '../adapters/chat/standardChatAdapter'; import {ConversationItem} from '../conversation'; import {EventsMap} from '../event'; -import {ChatAdapter} from './chatAdapter'; -import {StandardChatAdapter} from './standardChatAdapter'; +/** + * These are the props that are used internally by the AiChat component. + */ export type AiChatInternalProps = { adapter: ChatAdapter | StandardChatAdapter; events?: Partial; @@ -25,6 +28,8 @@ export type AiChatInternalProps = { * These are the props that are exposed to the user of the AiChat component. * They can be updated using the `updateProps` method, and they are provided to certain adapter methods * as part of the `ChatAdapterExtras` attribute. + * + * It excludes properties that are used for initialization such as `initialConversation`. */ export type AiChatProps = Readonly<{ adapter: ChatAdapter | StandardChatAdapter; diff --git a/packages/js/core/src/types/aiChat/standardChatAdapter.ts b/packages/js/core/src/types/aiChat/standardChatAdapter.ts deleted file mode 100644 index 8e3277ed..00000000 --- a/packages/js/core/src/types/aiChat/standardChatAdapter.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {ChatAdapter, ChatAdapterExtras, DataTransferMode, StreamingAdapterObserver} from './chatAdapter'; -import {StandardAdapterInfo} from './standardAdapterConfig'; - -export interface StandardChatAdapter extends ChatAdapter { - get dataTransferMode(): DataTransferMode; - fetchText(message: string, extras: ChatAdapterExtras): Promise; - get id(): string; - get info(): StandardAdapterInfo; - streamText(message: string, observer: StreamingAdapterObserver, extras: ChatAdapterExtras): void; -} - -export const isStandardChatAdapter = (adapter: any): boolean => { - return (typeof adapter === 'object' && adapter !== null) - && (typeof adapter.streamText === 'function' || typeof adapter.fetchText === 'function') - && ['stream', 'fetch'].includes(adapter.dataTransferMode) - && typeof adapter.id === 'string' - && (typeof adapter.info === 'object' && adapter.info !== null); -}; diff --git a/packages/js/core/src/types/aiContext/aiContext.ts b/packages/js/core/src/types/aiContext/aiContext.ts new file mode 100644 index 00000000..0ff4c9ba --- /dev/null +++ b/packages/js/core/src/types/aiContext/aiContext.ts @@ -0,0 +1,221 @@ +import {DataSyncOptions} from '../../core/aiContext/options/dataSyncOptions'; +import {ContextAdapter} from '../adapters/context/contextAdapter'; +import {ContextAdapterBuilder} from '../adapters/context/contextAdapterBuilder'; +import {ContextItemHandler, ContextTaskHandler} from './contextObservers'; +import { + ContextActionResult, + DestroyContextResult, + FlushContextResult, + InitializeContextResult, + RunTaskResult, +} from './contextResults'; +import {ContextItemDataType, ContextItems} from './data'; + +/** + * The current status of the context instance. + * The default status is 'idle' and it changes to 'initializing' when the context is being initialized. + * A context that is ready to be used has the status 'syncing' (i.e. the being synchronized with the backend). + * If the context fails to initialize or synchronize, the status changes to 'error'. + * The final status of a context is 'destroyed' after it has been destroyed. + */ +export type AiContextStatus = 'idle' | 'initializing' | 'syncing' | 'error' | 'destroyed'; + +/** + * The status of a specific item in the context. + * This is used to ensure that no race conditions occur when updating the context. + */ +export type AiContextItemStatus = 'set' | 'updating' | 'deleted'; + +/** + * The AiContext is responsible for managing data and tasks in the context of a user session. + * This data and tasks can be used to provide a more personalized and relevant experience to the AI chatbot user. + */ +export interface AiContext { + /** + * The unique identifier of the context instance. + * This identifier is generated by the backend and assigned to the context instance when it is initialized. + * This is only set once the context has been initialized. It will be null if the context has not been initialized. + * When the context is destroyed, the context id will be set to null. + * + * @returns {string | null} + */ + get contextId(): string | null; + + /** + * Destroys the context instance and clears all data and tasks associated with it. + * The context instance cannot be used after it has been destroyed. + * If the destroy operation fails, the context instance is left in an error state. + * + * @returns {Promise} + */ + destroy(): Promise; + + /** + * Flushes the data synchronization queue. + * This method will make an API call to the adapter to flush the data synchronization queue. + * This can be done automatically by the data sync service (the frequency depends on the data sync strategy set), + * but in situations where the data needs to be flushed immediately, this method can be called. + * + * @returns {Promise} + */ + flush(): Promise; + + /** + * Checks if the context is observing a state item with the provided itemId. + * + * @param {string} itemId + * @returns {boolean} + */ + hasItem(itemId: string): boolean; + + /** + * Checks if the context has a task with the provided taskId that can be run. + * Runnable tasks are tasks that have been registered with the backend and can be executed in the context. + * When a registration fails, the task is not runnable. + * + * @param {string} taskId + * @returns {boolean} + */ + hasRunnableTask(taskId: string): boolean; + + /** + * Checks if the context is observing a task with the provided taskId. + * + * @param {string} taskId + * @returns {boolean} + */ + hasTask(taskId: string): boolean; + + /** + * Initializes the context instance and sets the initial data. + * The context instance cannot be used before it has been initialized. + * If the initialize operation fails, the context instance is left in an error state. + * Initialization can only be done once for a context instance, when it's in the 'idle' state. + * + * @param {Object} data The optional initial data to set in the context instance. + * @returns {Promise} + */ + initialize(data?: ContextItems): Promise; + + /** + * Observes a state item and updates the context when the state item's value changes. + * This method returns a ContextItemHandler that can be used to update the state item's value. + * If the context instance is destroyed or not properly initialized, the method will return undefined and will + * log a warning. Users can use the status property to check the status of the context instance. + * + * The state item's description passed as a second argument is used by AI to determine how and it what context the + * state item is used. When the user queries the AI about a specific data in the page, the description will be used + * to determine which context state items are relevant to the query, and thus it should always be detailed and + * accurate. + * + * @param {string} itemId The unique identifier of the state item to observe. + * @param {string} description The description of the state item. The description is used to instruct the LLM on the + * how the state item is used and its role in the context. e.g. When the state item is 'logged-in-user', the + * description could be 'The logged-in user in the marketplace app. It can be used to provide a more + * personalized experience to the user.' + * + * @param {ContextItemDataType} initialData The initial data to set in the state item. + * + * @returns {ContextItemHandler | undefined} The state item handler that can be used to update the state item's + * value. If the context instance is destroyed or an item with the same itemId is already being observed, the + * method will return undefined and a warning will be logged. + */ + observeState( + itemId: string, + description: string, + initialData?: ContextItemDataType, + ): ContextItemHandler | undefined; + + /** + * Registers a task in the context instance. + * A task is a function that can be executed in the context of the user session. + * + * @param {string} taskId The unique identifier of the task. If a task with the same identifier already exists, + * the registerTask method will return an error. + * @param {string} description The description of the task. The description is used to instruct the LLM on the + * usefulness and role of the task. e.g. When the task is 'get-user-age', the description could be 'Get the age + * of the logged-in user from the user profile.' + * @param {Function} callback The function to execute when the task runs. + * @param {string[]} paramDescriptions The descriptions of the parameters that the task function expects. The + * parameter descriptions are very important because they instruct the LLM on how to retrieve the parameters' data + * from the context and how to use them in the task function. The parameter descriptions should be accurate and + * explicit. They should describe the role of the parameters in the callback function and how they can be + * retrieved. e.g. When the task function expects a parameter that represents the user's preferred language, + * the parameter description could be 'The preferred language of the logged-in user. It can be retrieved from + * the user profile.' + * + * @returns {ContextActionResult} + */ + registerTask( + taskId: string, + description: string, + callback: Function, + paramDescriptions?: string[], + ): ContextTaskHandler | undefined; + + /** + * Resets the context instance and sets the provided data. The contextId will not change after the reset operation + * but all the context data and tasks will be cleared. + * If the reset operation fails, the context instance is left in an error state. + * + * The reset operation clears all data and tasks associated with the context instance and sets the provided data. + * It will also unregister all observers and tasks. You will need to re-register the tasks and re-observe the + * elements and state items after the reset operation. + * + * @param {Object} data The optional data to set in the context instance. + * @returns {Promise} + */ + reset(data?: ContextItems): Promise; + + /** + * Runs a task in the context instance. + * + * The task runner will attempt to retrieve the parameters from the context and execute the task function. + * If the task function succeeds, the task runner will return the result of the task function under the 'result' + * property of the returned object. If the task function fails, the task runner will return an error under the + * 'error' property of the returned object. + * + * The status of the context instance will not change after running a task, regardless of the result of the task + * function. + * + * @param {string} taskId The unique identifier of the task to run. + * @param {Array} parameters The parameters to pass to the task function. + * @returns {Promise} + */ + runTask(taskId: string, parameters?: Array): Promise; + + /** + * Get the status of the context. + * The status will change as the context is being initialized, destroyed, or if an error occurs. + * + * - The initial status is 'idle'. + * - The status will change to 'initializing' once the context is being initialized. + * - The status will change to 'syncing' once the context has been initialized and is being synced. + * - The status will change to 'error' if an error occurs. + * - The status will change to 'destroyed' once the context has been destroyed. + * + * @returns {AiContextStatus} + */ + get status(): AiContextStatus; + + /** + * Sets the adapter to use for the context instance. + * The adapter is responsible for synchronizing the context instance with the backend. + * This method should be called before the context instance is initialized. + * If the adapter is not set, the initialize method will fail. + * + * @param {ContextAdapterBuilder | ContextAdapter} adapter + * @returns {AiContext} + */ + withAdapter(adapter: ContextAdapterBuilder | ContextAdapter): AiContext; + + /** + * Sets the options to use for data synchronization. + * The options are used to configure the behavior of the data synchronization process. + * This method should be called before the context instance is initialized. + * + * @param {DataSyncOptions} options + * @returns {AiContext} + */ + withDataSyncOptions(options: DataSyncOptions): AiContext; +} diff --git a/packages/js/core/src/types/aiContext/builder.ts b/packages/js/core/src/types/aiContext/builder.ts deleted file mode 100644 index 88e3794e..00000000 --- a/packages/js/core/src/types/aiContext/builder.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {ContextAdapter} from './contextAdapter'; - -export interface ContextAdapterBuilder { - create(): ContextAdapter; -} diff --git a/packages/js/core/src/types/aiContext/clear.ts b/packages/js/core/src/types/aiContext/clear.ts deleted file mode 100644 index d9b4b7e0..00000000 --- a/packages/js/core/src/types/aiContext/clear.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type ClearContextResult = { - success: true; -} | { - success: false; - error: string; -}; - -export type ClearContextCallback = (contextId: string) => Promise; diff --git a/packages/js/core/src/types/aiContext/contextAdapter.ts b/packages/js/core/src/types/aiContext/contextAdapter.ts deleted file mode 100644 index 56b55ec2..00000000 --- a/packages/js/core/src/types/aiContext/contextAdapter.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {AssistCallback} from '../aiAssistant/assist'; -import {RegisterTaskCallback} from '../aiAssistant/registerTask'; -import {UnregisterTaskCallback} from '../aiAssistant/unregisterTask'; -import {ClearContextCallback} from './clear'; -import {GetContextDataCallback} from './get'; -import {SetContextCallback} from './set'; -import {UpdateContextCallback} from './update'; - -export interface ContextAdapter { - assist: AssistCallback; - clear: ClearContextCallback; - get: GetContextDataCallback; - registerTask: RegisterTaskCallback; - set: SetContextCallback; - unregisterTask: UnregisterTaskCallback; - update: UpdateContextCallback; -} diff --git a/packages/js/core/src/types/aiContext/contextObservers.ts b/packages/js/core/src/types/aiContext/contextObservers.ts new file mode 100644 index 00000000..0431a487 --- /dev/null +++ b/packages/js/core/src/types/aiContext/contextObservers.ts @@ -0,0 +1,53 @@ +import {ContextItemDataType} from './data'; + +export interface ContextDomElementHandler { + destroy(): void; +} + +/** + * Once a context state item is registered, a state item handler with this interface will be created. + * This handler will be used to update the state item's data and to destroy it when it is no longer needed. + * It can also be used to change the description of the state item. + */ +export interface ContextItemHandler { + /** + * Once the state item is no longer needed, it should be discarded. This method should be called to destroy the + * state item. By doing so, the state item will be removed from the context and will no longer be used by the AI. + */ + discard(): void; + + /** + * This method is used to update the state item's data. The data is used by the AI to answer context-aware queries. + * For example, if the user asks the AI about the logged-in user, the AI will use the data to provide the answer. + * The data should be kept up-to-date to ensure that the AI provides accurate answers. + * + * @param {ContextItemDataType} data The new data to be used for the context state item. + */ + setData(data: ContextItemDataType): void; + + /** + * When a state item is registered, a description is provided. + * That description is used by AI to determine how and it what context the state item is used. + * For example, when the user queries the AI about a specific data in the page, the description will be used to + * determine which context state items are relevant to the query, and thus it should always be up-to-date. + * + * This method can be used to change the description of the state item when the usage of the state item changes. + * For example, the logged-in user in a marketplace app can be either a buyer or a seller, when they switch from + * one role to another, the description of the state item should be updated to reflect the new role. + * + * @param {string} description + */ + setDescription(description: string): void; +} + +/** + * Once a context task is registered, a task handler with this interface will be created. + * This handler will be used to update the task's data, callback, and to destroy it when it is no longer needed. + * It can also be used to change the description of the task and the descriptions of its parameters. + */ +export interface ContextTaskHandler { + discard(): void; + setCallback(callback: Function): void; + setDescription(description: string): void; + setParamDescriptions(paramDescriptions: string[]): void; +} diff --git a/packages/js/core/src/types/aiContext/contextResults.ts b/packages/js/core/src/types/aiContext/contextResults.ts new file mode 100644 index 00000000..2086919b --- /dev/null +++ b/packages/js/core/src/types/aiContext/contextResults.ts @@ -0,0 +1,44 @@ +export type InitializeContextResult = { + success: true; + contextId: string; +} | { + success: false; + error: string; +}; + +export type DestroyContextResult = { + success: true; +} | { + success: false; + error: string; +}; + +export type FlushContextResult = { + success: true; +} | { + success: false; + error: string; +}; + +export type RunTaskResult = { + success: true; + result?: any; +} | { + success: false; + error: string; +}; + +export type ContextActionResult = { + success: true; +} | { + success: false; + error: string; +}; + +export type SetContextResult = { + success: true; + contextId: string; +} | { + success: false; + error: string; +}; diff --git a/packages/js/core/src/types/aiContext/data.ts b/packages/js/core/src/types/aiContext/data.ts index b843e470..de93f5a9 100644 --- a/packages/js/core/src/types/aiContext/data.ts +++ b/packages/js/core/src/types/aiContext/data.ts @@ -1,3 +1,19 @@ -export type ContextData = { - [key: string]: any; +export type ContextItemDataType = number | string | boolean | null | ContextObject | ContextItemDataType[]; + +export type ContextObject = { + [key: string]: ContextItemDataType; }; + +export type ContextItem = { + value: ContextItemDataType; + description: string; +}; + +export type ContextItems = Record; + +export type ContextTask = { + description: string; + paramDescriptions: string[]; +}; + +export type ContextTasks = Record; diff --git a/packages/js/core/src/types/aiContext/get.ts b/packages/js/core/src/types/aiContext/get.ts deleted file mode 100644 index 193065a0..00000000 --- a/packages/js/core/src/types/aiContext/get.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {ContextData} from './data'; - -export type GetContextDataResult = { - success: true; - data: ContextData | undefined; -} | { - success: false; - error: string; -}; - -export type GetContextDataCallback = ( - contextId: string, - itemId: string | undefined, -) => Promise; diff --git a/packages/js/core/src/types/aiContext/set.ts b/packages/js/core/src/types/aiContext/set.ts deleted file mode 100644 index c13cffd0..00000000 --- a/packages/js/core/src/types/aiContext/set.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type SetContextResult = { - success: true; - contextId: string; -} | { - success: false; - error: string; -}; - -export type SetContextCallback = ( - initialData: Record | undefined, -) => Promise; diff --git a/packages/js/core/src/types/aiContext/update.ts b/packages/js/core/src/types/aiContext/update.ts deleted file mode 100644 index ff7ec3bb..00000000 --- a/packages/js/core/src/types/aiContext/update.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {ContextData} from './data'; - -export type UpdateContextResult = { - success: true; -} | { - success: false; - error: string; -}; - -export type UpdateContextCallback = ( - contextId: string, - data: ContextData | null, -) => Promise; diff --git a/packages/js/core/src/types/controllerContext.ts b/packages/js/core/src/types/controllerContext.ts index b268c1fd..9f76d4d7 100644 --- a/packages/js/core/src/types/controllerContext.ts +++ b/packages/js/core/src/types/controllerContext.ts @@ -1,8 +1,8 @@ import {HighlighterExtension} from '../core/aiChat/highlighter/highlighter'; import {ExceptionId} from '../exceptions/exceptions'; -import {ChatAdapter} from './aiChat/chatAdapter'; +import {ChatAdapter} from './adapters/chat/chatAdapter'; +import {StandardChatAdapter} from './adapters/chat/standardChatAdapter'; import {AiChatProps} from './aiChat/props'; -import {StandardChatAdapter} from './aiChat/standardChatAdapter'; import {EventName, EventsMap} from './event'; export type ControllerContextProps = Readonly<{ diff --git a/packages/js/core/src/types/event.ts b/packages/js/core/src/types/event.ts index d1f21081..b6882382 100644 --- a/packages/js/core/src/types/event.ts +++ b/packages/js/core/src/types/event.ts @@ -16,10 +16,44 @@ export type PreDestroyEventDetails = { conversationHistory: Readonly; } +/** + * The callback for when an error event is emitted. + * + * @param errorDetails The details of the error event such as the error message and the error id. + */ export type ErrorCallback = (errorDetails: ErrorEventDetails) => void; + +/** + * The callback for when a message is received. + * This is called when the chat component receives the full response from the adapter. + * + * @param message The message that was received. + */ export type MessageReceivedCallback = (message: string) => void; + +/** + * The callback for when a message is sent. + * This is called when the chat component sends the message to the adapter. + * + * @param message The message that was sent. + */ export type MessageSentCallback = (message: string) => void; + +/** + * The callback for when the chat component is ready. + * This is called when the chat component is fully initialized and ready to be used. + * + * @param readyDetails The details of the ready event such as the AiChatProps used to initialize the chat component. + */ export type ReadyCallback = (readyDetails: ReadyEventDetails) => void; + +/** + * The callback for when the chat component is about to be destroyed. + * This is called when the chat component is about to be destroyed and unmounted from the DOM. + * + * @param preDestroyDetails The details of the pre-destroy event such as the AiChatProps used to initialize the chat + * component and the conversation history. + */ export type PreDestroyCallback = (preDestroyDetails: PreDestroyEventDetails) => void; export type EventsMap = { @@ -31,4 +65,5 @@ export type EventsMap = { }; export type EventName = keyof EventsMap; + export type EventCallback = EventsMap[EventName]; diff --git a/packages/js/core/src/types/participant.ts b/packages/js/core/src/types/participant.ts index e97fc44f..471ae215 100644 --- a/packages/js/core/src/types/participant.ts +++ b/packages/js/core/src/types/participant.ts @@ -1 +1,8 @@ +/** + * The role of a participant in a conversation. + * + * The 'ai' role is used to represent the AI model responding to the user. + * The 'system' role is used to represent the system sending messages to the bot to control its behavior. + * The 'user' role is used to represent the user sending messages to the bot. + */ export type ParticipantRole = 'user' | 'system' | 'ai'; diff --git a/packages/js/core/src/utils/adapters/isContextTasksAdapter.ts b/packages/js/core/src/utils/adapters/isContextTasksAdapter.ts new file mode 100644 index 00000000..178b74d1 --- /dev/null +++ b/packages/js/core/src/utils/adapters/isContextTasksAdapter.ts @@ -0,0 +1,14 @@ +import {ContextTasksAdapter} from '../../types/adapters/context/contextTasksAdapter'; + +export const isContextTasksAdapter = (adapter: any): ContextTasksAdapter | false => { + if ( + adapter && + typeof adapter.resetTasks === 'function' && + typeof adapter.updateTasks === 'function' && + typeof adapter.removeTasks === 'function' + ) { + return adapter; + } + + return false; +}; diff --git a/packages/js/nlbridge/src/index.ts b/packages/js/nlbridge/src/index.ts index 9e27d1d1..6f02f83b 100644 --- a/packages/js/nlbridge/src/index.ts +++ b/packages/js/nlbridge/src/index.ts @@ -4,7 +4,6 @@ export type { StandardChatAdapter, StreamingAdapterObserver, DataTransferMode, - AiTaskRunner, } from '@nlux/core'; export { diff --git a/packages/js/nlbridge/src/nlbridge/chatAdapter/adapter.ts b/packages/js/nlbridge/src/nlbridge/chatAdapter/adapter.ts index 63570cf5..4c968796 100644 --- a/packages/js/nlbridge/src/nlbridge/chatAdapter/adapter.ts +++ b/packages/js/nlbridge/src/nlbridge/chatAdapter/adapter.ts @@ -1,5 +1,5 @@ import { - AiTaskRunner, + AiContext as CoreAiContext, ChatAdapterExtras, DataTransferMode, StandardAdapterInfo, @@ -13,22 +13,20 @@ export abstract class NLBridgeAbstractAdapter implements StandardChatAdapter { static defaultDataTransferMode: DataTransferMode = 'stream'; private readonly __instanceId: string; - private readonly theContextIdToUse: string | undefined; + private readonly theAiContextToUse: CoreAiContext | undefined = undefined; private readonly theDataTransferModeToUse: DataTransferMode; private readonly theEndpointUrlToUse: string; - private readonly theTaskRunnerToUse: AiTaskRunner | undefined; constructor(options: ChatAdapterOptions) { this.__instanceId = `${this.info.id}-${uid()}`; this.theDataTransferModeToUse = options.dataTransferMode ?? NLBridgeAbstractAdapter.defaultDataTransferMode; this.theEndpointUrlToUse = options.url; - this.theContextIdToUse = options.contextId; - this.theTaskRunnerToUse = options.taskRunner; + this.theAiContextToUse = options.context; } - get contextId(): string | undefined { - return this.theContextIdToUse; + get context(): CoreAiContext | undefined { + return this.theAiContextToUse; } get dataTransferMode(): DataTransferMode { @@ -55,19 +53,26 @@ export abstract class NLBridgeAbstractAdapter implements StandardChatAdapter { }; } - get taskRunner(): AiTaskRunner | undefined { - return this.theTaskRunnerToUse; - } - - async decode(payload: string): Promise { + async decode( + payload: string, + ): Promise { return undefined; } - async encode(message: string): Promise { + async encode( + message: string, + ): Promise { return undefined; } - abstract fetchText(message: string, extras: ChatAdapterExtras): Promise; + abstract fetchText( + message: string, + extras: ChatAdapterExtras, + ): Promise; - abstract streamText(message: string, observer: StreamingAdapterObserver, extras: ChatAdapterExtras): void; + abstract streamText( + message: string, + observer: StreamingAdapterObserver, + extras: ChatAdapterExtras, + ): void; } diff --git a/packages/js/nlbridge/src/nlbridge/chatAdapter/builder/builder.ts b/packages/js/nlbridge/src/nlbridge/chatAdapter/builder/builder.ts index cf72f602..2b75fa1d 100644 --- a/packages/js/nlbridge/src/nlbridge/chatAdapter/builder/builder.ts +++ b/packages/js/nlbridge/src/nlbridge/chatAdapter/builder/builder.ts @@ -1,5 +1,5 @@ import { - AiTaskRunner, + AiContext as CoreAiContext, ChatAdapterBuilder as CoreChatAdapterBuilder, DataTransferMode, StandardChatAdapter, @@ -7,8 +7,7 @@ import { export interface ChatAdapterBuilder extends CoreChatAdapterBuilder { create(): StandardChatAdapter; - withContextId(contextId: string): ChatAdapterBuilder; + withContext(context: CoreAiContext): ChatAdapterBuilder; withDataTransferMode(mode: DataTransferMode): ChatAdapterBuilder; - withTaskRunner(taskRunner: AiTaskRunner): ChatAdapterBuilder; withUrl(endpointUrl: string): ChatAdapterBuilder; } diff --git a/packages/js/nlbridge/src/nlbridge/chatAdapter/builder/builderImpl.ts b/packages/js/nlbridge/src/nlbridge/chatAdapter/builder/builderImpl.ts index 5e849afa..e1eac86c 100644 --- a/packages/js/nlbridge/src/nlbridge/chatAdapter/builder/builderImpl.ts +++ b/packages/js/nlbridge/src/nlbridge/chatAdapter/builder/builderImpl.ts @@ -1,4 +1,4 @@ -import {AiTaskRunner, DataTransferMode, NluxUsageError, StandardChatAdapter} from '@nlux/core'; +import {AiContext as CoreAiContext, DataTransferMode, NluxUsageError, StandardChatAdapter} from '@nlux/core'; import {ChatAdapterOptions} from '../../types/chatAdapterOptions'; import {NLBridgeAbstractAdapter} from '../adapter'; import {NLBridgeFetchAdapter} from '../fetch'; @@ -6,17 +6,16 @@ import {NLBridgeStreamAdapter} from '../stream'; import {ChatAdapterBuilder} from './builder'; export class ChatAdapterBuilderImpl implements ChatAdapterBuilder { - private theContextId?: string; + private theContext?: CoreAiContext | undefined; private theDataTransferMode?: DataTransferMode; - private theTaskRunner: AiTaskRunner | undefined; private theUrl?: string; constructor(cloneFrom?: ChatAdapterBuilderImpl) { if (cloneFrom) { this.theDataTransferMode = cloneFrom.theDataTransferMode; this.theUrl = cloneFrom.theUrl; - this.theContextId = cloneFrom.theContextId; - this.theTaskRunner = cloneFrom.theTaskRunner; + this.theContext = cloneFrom.theContext; + this.theContext = cloneFrom.theContext; } } @@ -24,16 +23,15 @@ export class ChatAdapterBuilderImpl implements ChatAdapterBuilder { if (!this.theUrl) { throw new NluxUsageError({ source: this.constructor.name, - message: 'Unable to create NLBridge adapter. URL is missing. ' + - 'Make sure you are calling withUrl() before calling create().', + message: 'Unable to create NLBridge adapter. URL is missing. Make sure you are call withUrl() ' + + 'or provide url option before calling creating the adapter.', }); } const options: ChatAdapterOptions = { url: this.theUrl, dataTransferMode: this.theDataTransferMode, - contextId: this.theContextId, - taskRunner: this.theTaskRunner, + context: this.theContext, }; const dataTransferModeToUse = options.dataTransferMode @@ -46,15 +44,15 @@ export class ChatAdapterBuilderImpl implements ChatAdapterBuilder { return new NLBridgeFetchAdapter(options); } - withContextId(contextId: string): ChatAdapterBuilderImpl { - if (this.theContextId !== undefined) { + withContext(context: CoreAiContext): ChatAdapterBuilderImpl { + if (this.theContext !== undefined) { throw new NluxUsageError({ source: this.constructor.name, message: 'Cannot set the context ID option more than once', }); } - this.theContextId = contextId; + this.theContext = context; return this; } @@ -70,18 +68,6 @@ export class ChatAdapterBuilderImpl implements ChatAdapterBuilder { return this; } - withTaskRunner(callback: AiTaskRunner): ChatAdapterBuilderImpl { - if (this.theTaskRunner !== undefined) { - throw new NluxUsageError({ - source: this.constructor.name, - message: 'Cannot set the task runner option more than once', - }); - } - - this.theTaskRunner = callback; - return this; - } - withUrl(endpointUrl: string): ChatAdapterBuilderImpl { if (this.theUrl !== undefined) { throw new NluxUsageError({ diff --git a/packages/js/nlbridge/src/nlbridge/chatAdapter/fetch.ts b/packages/js/nlbridge/src/nlbridge/chatAdapter/fetch.ts index 718c1550..284316bd 100644 --- a/packages/js/nlbridge/src/nlbridge/chatAdapter/fetch.ts +++ b/packages/js/nlbridge/src/nlbridge/chatAdapter/fetch.ts @@ -7,16 +7,20 @@ export class NLBridgeFetchAdapter extends NLBridgeAbstractAdapter { } async fetchText(message: string, extras: ChatAdapterExtras): Promise { + if (this.context && this.context.contextId) { + await this.context.flush(); + } + const response = await fetch(this.endpointUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ - action: this.contextId ? 'assist' : 'chat', + action: 'chat', payload: { message, - contextId: this.contextId, + contextId: this.context?.contextId, }, }), }); @@ -40,11 +44,11 @@ export class NLBridgeFetchAdapter extends NLBridgeAbstractAdapter { } = body.result; if ( - this.taskRunner && task + this.context && task && typeof task === 'object' && typeof task.taskId === 'string' && Array.isArray(task.parameters) ) { - this.taskRunner(task.taskId, task.parameters); + this.context.runTask(task.taskId, task.parameters); } return response; diff --git a/packages/js/nlbridge/src/nlbridge/chatAdapter/stream.ts b/packages/js/nlbridge/src/nlbridge/chatAdapter/stream.ts index 2b03a629..a70b96b7 100644 --- a/packages/js/nlbridge/src/nlbridge/chatAdapter/stream.ts +++ b/packages/js/nlbridge/src/nlbridge/chatAdapter/stream.ts @@ -14,7 +14,7 @@ export class NLBridgeStreamAdapter extends NLBridgeAbstractAdapter { } streamText(message: string, observer: StreamingAdapterObserver, extras: ChatAdapterExtras): void { - fetch(this.endpointUrl, { + const submitPrompt = () => fetch(this.endpointUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -23,7 +23,7 @@ export class NLBridgeStreamAdapter extends NLBridgeAbstractAdapter { action: 'chat-stream', payload: { message, - contextId: this.contextId, + contextId: this.context?.contextId, }, }), }).then(async (response) => { @@ -58,5 +58,18 @@ export class NLBridgeStreamAdapter extends NLBridgeAbstractAdapter { observer.complete(); }); + + if (this.context && this.context.contextId) { + this.context.flush().then(() => { + submitPrompt(); + }).catch(() => { + // Submit prompt even when flushing fails + submitPrompt(); + }); + + return; + } + + submitPrompt(); } } diff --git a/packages/js/nlbridge/src/nlbridge/contextAdapter/assistAdapter.ts b/packages/js/nlbridge/src/nlbridge/contextAdapter/assistAdapter.ts new file mode 100644 index 00000000..901960ce --- /dev/null +++ b/packages/js/nlbridge/src/nlbridge/contextAdapter/assistAdapter.ts @@ -0,0 +1,74 @@ +import {AssistAdapter, AssistResult, ChatAdapterExtras} from '@nlux/core'; + +export class NLBridgeAssistAdapter implements AssistAdapter { + private readonly url: string; + + constructor(url: string) { + this.url = url; + } + + async assist(message: string, extras: ChatAdapterExtras): Promise { + if (!extras.contextId) { + return { + success: false, + error: 'Invalid context ID', + }; + } + + if (!message) { + return { + success: false, + error: 'Invalid message', + }; + } + + try { + const result = await fetch(this.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'assist', + payload: { + contextId: extras.contextId, + message, + }, + }), + }); + + if (!result.ok) { + return { + success: false, + error: 'Failed to assist', + }; + } + + const json = await result.json(); + const response = json?.result?.response; + const taskId = json?.result?.taskId; + const parameters = json?.result?.parameters; + + if (taskId && Array.isArray(parameters)) { + return { + success: true, + response, + task: { + id: taskId, + parameters, + }, + }; + } + + return { + success: true, + response, + }; + } catch (e) { + return { + success: false, + error: 'Failed to assist', + }; + } + } +} diff --git a/packages/js/nlbridge/src/nlbridge/contextAdapter/builder/builder.ts b/packages/js/nlbridge/src/nlbridge/contextAdapter/builder/builder.ts index 580f56c3..a1001770 100644 --- a/packages/js/nlbridge/src/nlbridge/contextAdapter/builder/builder.ts +++ b/packages/js/nlbridge/src/nlbridge/contextAdapter/builder/builder.ts @@ -1,6 +1,6 @@ import {ContextAdapter, ContextAdapterBuilder as CoreContextAdapterBuilder} from '@nlux/core'; export interface ContextAdapterBuilder extends CoreContextAdapterBuilder { - create(): ContextAdapter; + build(): ContextAdapter; withUrl(endpointUrl: string): ContextAdapterBuilder; } diff --git a/packages/js/nlbridge/src/nlbridge/contextAdapter/builder/builderImpl.ts b/packages/js/nlbridge/src/nlbridge/contextAdapter/builder/builderImpl.ts index 95117dc5..e5a69fcc 100644 --- a/packages/js/nlbridge/src/nlbridge/contextAdapter/builder/builderImpl.ts +++ b/packages/js/nlbridge/src/nlbridge/contextAdapter/builder/builderImpl.ts @@ -5,7 +5,7 @@ import {ContextAdapterBuilder} from './builder'; export class ContextAdapterBuilderImpl implements ContextAdapterBuilder { private endpointUrl: string | undefined = undefined; - create(): ContextAdapter { + build(): ContextAdapter { if (!this.endpointUrl) { throw new Error('Endpoint URL is required'); } diff --git a/packages/js/nlbridge/src/nlbridge/contextAdapter/contextAdapter.ts b/packages/js/nlbridge/src/nlbridge/contextAdapter/contextAdapter.ts index 5a724e8e..57c7316c 100644 --- a/packages/js/nlbridge/src/nlbridge/contextAdapter/contextAdapter.ts +++ b/packages/js/nlbridge/src/nlbridge/contextAdapter/contextAdapter.ts @@ -1,243 +1,123 @@ import { - AssistResult, - ClearContextResult, + ContextActionResult, ContextAdapter, - ContextData, - GetContextDataResult, - RegisterTaskResult, + ContextAdapterExtras, + ContextItems, + ContextTasks, SetContextResult, - UnregisterTaskResult, - UpdateContextResult, } from '@nlux/core'; +type BackendContextAction = + 'update-context-items' + | 'update-context-tasks' + | 'remove-context-items' + | 'remove-context-tasks' + | 'reset-context-tasks' + | 'reset-context-items' + | 'discard-context' + | 'create-context'; + export class NLBridgeContextAdapter implements ContextAdapter { - private url: string; + + private readonly url: string; constructor(url: string) { this.url = url; } - async assist(contextId: string, message: string): Promise { - if (!contextId) { - return { - success: false, - error: 'Invalid context ID', - }; - } - - if (!message) { - return { - success: false, - error: 'Invalid message', - }; - } - + async create(contextItems?: ContextItems, extras?: ContextAdapterExtras): Promise { try { const result = await fetch(this.url, { method: 'POST', headers: { + ...extras?.headers, 'Content-Type': 'application/json', }, body: JSON.stringify({ - action: 'assist', - payload: { - contextId, - message, - }, + action: 'create-context', + payload: contextItems ? {items: contextItems} : undefined, }), }); if (!result.ok) { return { success: false, - error: 'Failed to assist', - }; - } - - const json = await result.json(); - const response = json?.result?.response; - const taskId = json?.result?.taskId; - const parameters = json?.result?.parameters; - - if (taskId && Array.isArray(parameters)) { - return { - success: true, - response, - taskId, - parameters, + error: 'Failed to set context', }; } - return { - success: true, - response, - }; - } catch (e) { - return { - success: false, - error: 'Failed to assist', - }; - } - } - - async clear(contextId: string): Promise { - if (!contextId) { - return { - success: false, - error: 'Invalid context ID', - }; - } - - try { - const result = await fetch(this.url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - action: 'clear-context', - payload: { - contextId, - }, - }), - }); - - if (!result.ok) { + const data = await result.json(); + if (!data?.result?.contextId) { return { success: false, - error: 'Failed to clear context', + error: 'Invalid context ID', }; } return { success: true, + contextId: data.result.contextId, }; } catch (e) { return { success: false, - error: 'Failed to clear context', + error: 'Failed to set context', }; } } - async get(contextId: string, itemId: string | undefined): Promise { - return { - success: false, - error: 'Not implemented', - }; + discard(contextId: string, extras?: ContextAdapterExtras): Promise { + return this.sendAction( + contextId, + 'discard-context', + undefined, + extras, + ); } - async registerTask( - contextId: string, - taskId: string, - parameters: string[], - ): Promise { - try { - const result = await fetch(this.url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - action: 'register-task', - payload: { - contextId, - taskId, - parameters, - }, - }), - }); - - if (!result.ok) { - return { - success: false, - error: 'Failed to register task', - }; - } - - return { - success: true, - }; - } catch (e) { - return { - success: false, - error: 'Failed to register task', - }; - } + removeItems(contextId: string, itemIds: string[], extras?: ContextAdapterExtras): Promise { + return this.sendAction( + contextId, + 'remove-context-items', + {itemIds}, + extras, + ); } - async set(initialData: Record | undefined): Promise { - try { - const result = await fetch(this.url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - action: 'set-context', - payload: { - data: initialData || null, - }, - }), - }); - - if (!result.ok) { - return { - success: false, - error: 'Failed to set context', - }; - } - - const json = await result.json(); - const contextId = json?.result?.contextId; - - return { - success: true, - contextId, - }; - } catch (e) { - return { - success: false, - error: 'Failed to set context', - }; - } + async removeTasks(contextId: string, taskIds: string[], extras?: ContextAdapterExtras): Promise { + return this.sendAction( + contextId, + 'remove-context-tasks', + {taskIds}, + extras, + ); } - async unregisterTask(contextId: string, taskId: string): Promise { - try { - const result = await fetch(this.url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - action: 'unregister-task', - payload: { - contextId, - taskId, - }, - }), - }); - - if (!result.ok) { - return { - success: false, - error: 'Failed to unregister task', - }; - } + resetItems(contextId: string, newItems?: ContextItems, extras?: ContextAdapterExtras): Promise { + return this.sendAction( + contextId, + 'reset-context-items', + newItems ? {items: newItems} : undefined, + extras, + ); + } - return { - success: true, - }; - } catch (e) { - return { - success: false, - error: 'Failed to unregister task', - }; - } + resetTasks(contextId: string, newTasks?: ContextTasks, extras?: ContextAdapterExtras): Promise { + return this.sendAction( + contextId, + 'reset-context-tasks', + newTasks, + extras, + ); } - async update(contextId: string, data: ContextData | null): Promise { + async sendAction(contextId: string, action: BackendContextAction, payload?: any, extras?: ContextAdapterExtras): Promise<{ + success: false; + error: string; + } | { + success: true; + items?: any; + }> { if (!contextId) { return { success: false, @@ -249,13 +129,14 @@ export class NLBridgeContextAdapter implements ContextAdapter { const result = await fetch(this.url, { method: 'POST', headers: { + ...extras?.headers, 'Content-Type': 'application/json', }, body: JSON.stringify({ - action: 'update-context', + action, payload: { + ...payload, contextId, - data, }, }), }); @@ -263,18 +144,34 @@ export class NLBridgeContextAdapter implements ContextAdapter { if (!result.ok) { return { success: false, - error: 'Failed to update context', + error: 'Failed to send action', }; } + const items = await result.json(); + return { success: true, + items, }; } catch (e) { return { success: false, - error: 'Failed to update context', + error: 'Failed to send action', }; } } + + async updateItems(contextId: string, itemsToUpdate: Partial, extras?: ContextAdapterExtras): Promise { + return this.sendAction( + contextId, + 'update-context-items', + {items: itemsToUpdate}, + extras, + ); + } + + async updateTasks(contextId: string, tasks: Partial, extras: ContextAdapterExtras | undefined): Promise { + throw new Error('Method not implemented.'); + } } diff --git a/packages/js/nlbridge/src/nlbridge/types/aiTaskRunner.ts b/packages/js/nlbridge/src/nlbridge/types/aiTaskRunner.ts deleted file mode 100644 index 85acbc2d..00000000 --- a/packages/js/nlbridge/src/nlbridge/types/aiTaskRunner.ts +++ /dev/null @@ -1 +0,0 @@ -export type AiTaskRunner = (taskId: string, params: Array) => void; diff --git a/packages/js/nlbridge/src/nlbridge/types/chatAdapterOptions.ts b/packages/js/nlbridge/src/nlbridge/types/chatAdapterOptions.ts index f0bd428f..6767a0f8 100644 --- a/packages/js/nlbridge/src/nlbridge/types/chatAdapterOptions.ts +++ b/packages/js/nlbridge/src/nlbridge/types/chatAdapterOptions.ts @@ -1,4 +1,4 @@ -import {AiTaskRunner, DataTransferMode} from '@nlux/core'; +import {AiContext as CoreAiContext, DataTransferMode} from '@nlux/core'; export type ChatAdapterOptions = { /** @@ -17,10 +17,5 @@ export type ChatAdapterOptions = { * The context ID to use when communicating with NLBridge. * Optional. If not provided, the adapter will not use a context. */ - contextId?: string; - - /** - * A task runner function that can be used to execute tasks returned by AI. - */ - taskRunner?: AiTaskRunner; + context?: CoreAiContext; }; diff --git a/packages/react/core/src/index.tsx b/packages/react/core/src/index.tsx index bb28faa3..5db06cc8 100644 --- a/packages/react/core/src/index.tsx +++ b/packages/react/core/src/index.tsx @@ -49,7 +49,7 @@ export {AiChat} from './components/AiChat'; export type { UpdateContextItem, - ClearContextItem, + DiscardContextItem, } from './providers/useAiContext'; export { @@ -59,7 +59,6 @@ export { export type { AiContext, AiContextProviderProps, - AiContextData, } from './types/AiContext'; export { diff --git a/packages/react/core/src/providers/createAiContext.tsx b/packages/react/core/src/providers/createAiContext.tsx index 4db1f6f8..0e10beff 100644 --- a/packages/react/core/src/providers/createAiContext.tsx +++ b/packages/react/core/src/providers/createAiContext.tsx @@ -1,59 +1,71 @@ -import {ContextAdapter, ContextAdapterBuilder} from '@nlux/core'; +import { + AiContext as CoreAiContext, + ContextAdapter, + ContextAdapterBuilder, + createAiContext as createCoreAiContext, + InitializeContextResult, + predefinedContextSize, +} from '@nlux/core'; import React, {createContext, useEffect} from 'react'; -import {AiContext, AiContextData, AiContextProviderProps} from '../types/AiContext'; +import {AiContext, AiContextProviderProps} from '../types/AiContext'; export const createAiContext = (adapter: ContextAdapter | ContextAdapterBuilder): AiContext => { - const adapterToUse: ContextAdapter = typeof (adapter as any).create === 'function' - ? (adapter as ContextAdapterBuilder).create() - : adapter as ContextAdapter; - const context = createContext({ - contextId: '', - adapter: adapterToUse, - data: {}, - registeredTaskCallbacks: {}, - }); + // Unused because it's only used to initialize the React context + // but as soon as the React context is used (in the Provider component) + // another value is used. + const unusedAiContext = createCoreAiContext().withAdapter(adapter); + const reactContext = createContext(unusedAiContext); return { + // React component that provides the AI context to the children + // To be used as context aware app .. Provider: (props: AiContextProviderProps) => { + // + // Provider + // const [contextId, setContextId] = React.useState(); const [contextInitError, setContextInitError] = React.useState(); + const [ + coreAiContext, + setCoreAiContext, + ] = React.useState(); - const {value, children} = props; - const data = value || {}; - + // + // Initialize the AI context and get the contextId + // useEffect(() => { - let proceed = true; + let usableContext = true; + const newContext = createCoreAiContext() + .withAdapter(adapter) + .withDataSyncOptions({ + syncStrategy: 'auto', + contextSize: predefinedContextSize['100k'], + }); - if (!contextId) { - adapterToUse.set(data).then((result) => { - if (!proceed) { - return; - } + setCoreAiContext(newContext); - if (!result.success) { - setContextInitError(new Error(result.error)); + newContext + .initialize(props.initialContext || {}) + .then((result: InitializeContextResult) => { + if (!usableContext) { return; } - setContextId(result.contextId); - }).catch((err) => { - if (proceed) { - setContextInitError(err); + if (result.success) { + setContextId(result.contextId); + } else { + setContextInitError(new Error(result.error)); } }); - } return () => { - proceed = false; + usableContext = false; + newContext.destroy(); }; - }, [contextId]); + }, []); - const contextData = { - data, - adapter: adapterToUse, - registeredTaskCallbacks: {}, - }; + const {children} = props; if (contextInitError) { return ( @@ -64,7 +76,7 @@ export const createAiContext = (adapter: ContextAdapter | ContextAdapterBuilder) ); } - if (!contextId) { + if (!contextId || !coreAiContext) { return (

Initializing AI context

@@ -73,14 +85,11 @@ export const createAiContext = (adapter: ContextAdapter | ContextAdapterBuilder) } return ( - + {children} - + ); }, - ref: context, + ref: reactContext, }; }; diff --git a/packages/react/core/src/providers/useAiContext.ts b/packages/react/core/src/providers/useAiContext.ts index 36647c68..149c75c8 100644 --- a/packages/react/core/src/providers/useAiContext.ts +++ b/packages/react/core/src/providers/useAiContext.ts @@ -1,83 +1,47 @@ -import {useContext} from 'react'; +import {ContextItemDataType, ContextItemHandler} from '@nlux/core'; +import {useContext, useEffect, useRef, useState} from 'react'; import {AiContext} from '../types/AiContext'; -export type UpdateContextItem = (data: any) => void; -export type ClearContextItem = () => void; - -export type RegisterAiTask = (id: string, callback: Function, parametersDescription: string[]) => { - cancel: () => void; -}; - -export type UnregisterAiTask = (id: string) => void; +export type UpdateContextItem = (itemValue: ContextItemDataType) => void; +export type DiscardContextItem = () => void; export const useAiContext = ( aiContext: AiContext, - propertyKey: string, -): { - update: UpdateContextItem, - clear: ClearContextItem, - registerTask: RegisterAiTask, - unregisterTask: UnregisterAiTask, -} => { - const { - adapter, - contextId, - registeredTaskCallbacks, - } = useContext(aiContext.ref); - - const update = (data: any) => { - // TODO - Improve + Batch updates - adapter.update(contextId, { - [propertyKey]: data, - }).then((result) => { - // TODO - Handle error - }).catch(() => { - // TODO - Handle exception - }); - }; - - const clear = () => { - // TODO - Improve + Batch updates - adapter.update(contextId, { - [propertyKey]: null, - }).then(() => { - // TODO - Handle error - }).catch(() => { - // TODO - Handle exception - }); - }; - - const registerTask: RegisterAiTask = (taskId, callback, parametersDescription) => { - let cancelled = false; - adapter.registerTask(contextId, taskId, parametersDescription).then((result) => { - if (result.success && !cancelled) { - registeredTaskCallbacks[taskId] = callback; - } - }).catch(() => { - // TODO - Handle exception - }); - - return { - cancel: () => { - cancelled = true; - unregisterTask(taskId); - }, + itemDescription: string, + itemValue: ContextItemDataType, +) => { + const result = useContext(aiContext.ref); + const [itemId] = useState(() => { + let itemUniqueId: string; + do { + itemUniqueId = Math.random().toString(36).substring(2, 15); + } + while (result.hasItem(itemUniqueId)); + + return itemUniqueId; + }); + + const observerRef = useRef(); + + // IMPORTANT: Discard when component is unmounted + useEffect(() => { + observerRef.current = result.observeState( + itemId, + itemDescription, + itemValue, + ); + + return () => { + observerRef.current?.discard(); + observerRef.current = undefined; }; - }; + }, []); - const unregisterTask = (id: string) => { - adapter.unregisterTask(contextId, id).then(() => { - // TODO - Handle unregister - delete registeredTaskCallbacks[id]; - }).catch(() => { - // TODO - Handle exception - }); - }; + useEffect(() => { + observerRef.current?.setDescription(itemDescription); + }, [itemDescription]); - return { - update, - clear, - registerTask, - unregisterTask, - }; + useEffect(() => { + observerRef.current?.setData(itemValue); + }, [itemValue]); }; diff --git a/packages/react/core/src/providers/useAiTask.ts b/packages/react/core/src/providers/useAiTask.ts new file mode 100644 index 00000000..9d9a1ef8 --- /dev/null +++ b/packages/react/core/src/providers/useAiTask.ts @@ -0,0 +1,46 @@ +import {ContextTaskHandler} from '@nlux/core'; +import {useContext, useEffect, useRef, useState} from 'react'; +import {AiContext} from '../types/AiContext'; + +export const useAiTask = ( + aiContext: AiContext, description: string, callback: Function, parametersDescription?: string[], +) => { + const result = useContext(aiContext.ref); + const [taskId] = useState(() => { + let itemUniqueId: string; + do { + itemUniqueId = Math.random().toString(36).substring(2, 15); + } + while (result.hasItem(itemUniqueId)); + + return itemUniqueId; + }); + + const observerRef = useRef(); + + useEffect(() => { + observerRef.current = result.registerTask( + taskId, + description, + callback, + parametersDescription, + ); + + return () => { + observerRef.current?.discard(); + observerRef.current = undefined; + }; + }, []); + + useEffect(() => { + observerRef.current?.setDescription(description); + }, [description]); + + useEffect(() => { + observerRef.current?.setCallback(callback); + }, [callback]); + + useEffect(() => { + observerRef.current?.setParamDescriptions(parametersDescription ?? []); + }, [parametersDescription]); +}; diff --git a/packages/react/core/src/types/AiContext.tsx b/packages/react/core/src/types/AiContext.tsx index 4b16d48d..4db78bae 100644 --- a/packages/react/core/src/types/AiContext.tsx +++ b/packages/react/core/src/types/AiContext.tsx @@ -1,25 +1,12 @@ -import {ContextAdapter} from '@nlux/core'; +import {AiContext as CoreAiContext, ContextItems} from '@nlux/core'; import {Context, ReactNode} from 'react'; -export type AiContextData = { - contextId: string; - adapter: ContextAdapter; - data: { - [key: string]: any; - }; - registeredTaskCallbacks: { - [key: string]: Function; - }; -}; - export type AiContextProviderProps = { - value?: { - [key: string]: any; - }; + initialContext?: ContextItems; children: ReactNode; }; export type AiContext = { Provider: (props: AiContextProviderProps) => ReactNode; - ref: Context; + ref: Context; }; diff --git a/packages/react/nlbridge/src/hooks/getChatAdapterBuilder.ts b/packages/react/nlbridge/src/hooks/getChatAdapterBuilder.ts index 6dd99d0b..23d7a63b 100644 --- a/packages/react/nlbridge/src/hooks/getChatAdapterBuilder.ts +++ b/packages/react/nlbridge/src/hooks/getChatAdapterBuilder.ts @@ -1,11 +1,10 @@ -import {ChatAdapterBuilder, ChatAdapterOptions, createChatAdapter} from '@nlux/nlbridge'; +import {ChatAdapter, ChatAdapterOptions, createChatAdapter} from '@nlux/nlbridge'; -export const getChatAdapterBuilder = (options: ChatAdapterOptions): ChatAdapterBuilder => { +export const getChatAdapterBuilder = (options: ChatAdapterOptions): ChatAdapter => { const { url, dataTransferMode, - contextId, - taskRunner, + context, } = options || {}; if (dataTransferMode && dataTransferMode !== 'stream' && dataTransferMode !== 'fetch') { @@ -22,13 +21,9 @@ export const getChatAdapterBuilder = (options: ChatAdapterOptions): ChatAdapterB newAdapter = newAdapter.withDataTransferMode(dataTransferMode); } - if (contextId) { - newAdapter = newAdapter.withContextId(contextId); + if (context) { + newAdapter = newAdapter.withContext(context); } - if (taskRunner) { - newAdapter = newAdapter.withTaskRunner(taskRunner); - } - - return newAdapter; + return newAdapter.create(); }; diff --git a/packages/react/nlbridge/src/hooks/useChatAdapter.ts b/packages/react/nlbridge/src/hooks/useChatAdapter.ts index 79bd94f5..6eee21de 100644 --- a/packages/react/nlbridge/src/hooks/useChatAdapter.ts +++ b/packages/react/nlbridge/src/hooks/useChatAdapter.ts @@ -1,58 +1,38 @@ -import {AiTaskRunner, ChatAdapterBuilder} from '@nlux/nlbridge'; -import {AiContext, AiContextData} from '@nlux/react'; +import {ChatAdapter} from '@nlux/nlbridge'; +import {AiContext as ReactAiContext} from '@nlux/react'; import {useContext, useEffect, useState} from 'react'; import {getChatAdapterBuilder} from './getChatAdapterBuilder'; export type ReactChatAdapterOptions = { url: string; dataTransferMode?: 'stream' | 'fetch'; - context?: AiContext; + context?: ReactAiContext; }; -const getTaskRunner = (contextData?: AiContextData): AiTaskRunner | undefined => { - if (!contextData) { - return; - } - - return (taskId: string, parameters: any[]) => { - const callback = contextData.registeredTaskCallbacks[taskId]; - if (callback) { - return callback(...parameters); - } - }; -}; - -export const useChatAdapter = (options: ReactChatAdapterOptions) => { - const {context, url, dataTransferMode} = options || {}; - const contextData = context ? useContext(context.ref) : undefined; - const [isInitialized, setIsInitialized] = useState(false); - const [adapterBuilder, setAdapter] = useState( +export const useChatAdapter = (options: ReactChatAdapterOptions): ChatAdapter => { + const {context, url, dataTransferMode} = options; + const coreContext = context?.ref ? useContext(context.ref) : undefined; + const [adapter, setAdapter] = useState( getChatAdapterBuilder({ - ...options, - taskRunner: getTaskRunner(contextData), + url, + dataTransferMode, + context: coreContext, }), ); useEffect(() => { - if (!isInitialized) { - setIsInitialized(true); - return; - } - - let newAdapterBuilder = getChatAdapterBuilder({ + let newAdapter = getChatAdapterBuilder({ url, dataTransferMode, - contextId: contextData?.contextId, - taskRunner: getTaskRunner(contextData), + context: coreContext, }); - setAdapter(newAdapterBuilder); + setAdapter(newAdapter); }, [ - isInitialized, - contextData, - dataTransferMode, url, + dataTransferMode, + coreContext, ]); - return adapterBuilder; + return adapter; }; diff --git a/pipeline/npm/core/README.md b/pipeline/npm/core/README.md index a4cec528..4ade1cb3 100644 --- a/pipeline/npm/core/README.md +++ b/pipeline/npm/core/README.md @@ -88,7 +88,7 @@ from [`@nlux/themes`](https://www.npmjs.com/package/@nlux/themes) or use the CDN hosted version from below: ```jsx - + ``` This CDN is provided for demo purposes only and it's not scalable. diff --git a/pipeline/npm/versions.json b/pipeline/npm/versions.json index f3ff9f1a..ef2ba9b1 100644 --- a/pipeline/npm/versions.json +++ b/pipeline/npm/versions.json @@ -1,6 +1,6 @@ { "inherit": true, - "nlux": "1.0.1", + "nlux": "1.0.2", "peerDependencies": { "react": "18.2.0", "react-dom": "18.2.0" diff --git a/samples/stock-wiz/src/@types/StockData.ts b/samples/stock-wiz/src/@types/StockData.ts index f9d542c0..579d6cdb 100644 --- a/samples/stock-wiz/src/@types/StockData.ts +++ b/samples/stock-wiz/src/@types/StockData.ts @@ -1,4 +1,4 @@ -import {ExchangeId, MarketCapTypeId, SectorId} from './Data.ts'; +import {ExchangeId, MarketCapCategoryId, SectorId} from './Data.ts'; export type StockData = { id: string; @@ -11,7 +11,7 @@ export type StockData = { exchange: ExchangeId; sector: SectorId; marketCap: string; - marketCapType: MarketCapTypeId; + marketCapCategoryId: MarketCapCategoryId; price: number; oneDayChange: number; diff --git a/samples/stock-wiz/src/app/App.tsx b/samples/stock-wiz/src/app/App.tsx index bb8011ae..e3899a3a 100644 --- a/samples/stock-wiz/src/app/App.tsx +++ b/samples/stock-wiz/src/app/App.tsx @@ -5,11 +5,20 @@ import {StockWiz} from '../StockWiz.tsx'; export const App = () => { const initialData = useMemo(() => - ({'appName': 'Stock Wiz', 'appVersion': '1.0.0'}) + ({ + 'appName': { + value: 'Stock Wiz', + description: 'The name of the application', + }, + 'appVersion': { + value: '0.1.0', + description: 'The version of the application', + }, + }) , []); return ( - + ); diff --git a/samples/stock-wiz/src/data/stocks.ts b/samples/stock-wiz/src/data/stocks.ts index 27cf69cc..1aea745d 100644 --- a/samples/stock-wiz/src/data/stocks.ts +++ b/samples/stock-wiz/src/data/stocks.ts @@ -11,7 +11,7 @@ export const stocks: StockData[] = [ exchange: 'NASDAQ', sector: 'technology', marketCap: '2.48T', - marketCapType: 'mega', + marketCapCategoryId: 'mega', price: 145.11, oneDayChange: 0.45, oneWeekChange: 1.23, @@ -27,7 +27,7 @@ export const stocks: StockData[] = [ exchange: 'NASDAQ', sector: 'technology', marketCap: '2.22T', - marketCapType: 'mega', + marketCapCategoryId: 'mega', price: 289.67, oneDayChange: -0.23, oneWeekChange: 0.45, @@ -43,7 +43,7 @@ export const stocks: StockData[] = [ exchange: 'NASDAQ', sector: 'technology', marketCap: '1.68T', - marketCapType: 'mega', + marketCapCategoryId: 'mega', price: 2739.78, oneDayChange: 0.56, oneWeekChange: 2.34, @@ -59,7 +59,7 @@ export const stocks: StockData[] = [ exchange: 'NASDAQ', sector: 'consumer-goods', marketCap: '1.65T', - marketCapType: 'mega', + marketCapCategoryId: 'mega', price: 3344.83, oneDayChange: 0.45, oneWeekChange: 12.78, @@ -75,7 +75,7 @@ export const stocks: StockData[] = [ exchange: 'NASDAQ', sector: 'consumer-goods', marketCap: '619.82B', - marketCapType: 'large', + marketCapCategoryId: 'large', price: 687.20, oneDayChange: 0.45, oneWeekChange: -5.23, @@ -91,7 +91,7 @@ export const stocks: StockData[] = [ exchange: 'NASDAQ', sector: 'technology', marketCap: '1.01T', - marketCapType: 'mega', + marketCapCategoryId: 'mega', price: 351.89, oneDayChange: 0.45, oneWeekChange: 3.23, @@ -107,7 +107,7 @@ export const stocks: StockData[] = [ exchange: 'NYSE', sector: 'financial-services', marketCap: '682.00B', - marketCapType: 'large', + marketCapCategoryId: 'large', price: 435, oneDayChange: 0.45, oneWeekChange: -1.23, @@ -123,7 +123,7 @@ export const stocks: StockData[] = [ exchange: 'NYSE', sector: 'financial-services', marketCap: '464.00B', - marketCapType: 'large', + marketCapCategoryId: 'large', price: 159.00, oneDayChange: 0.45, oneWeekChange: 1.99, @@ -139,7 +139,7 @@ export const stocks: StockData[] = [ exchange: 'NYSE', sector: 'consumer-goods', marketCap: '393.00B', - marketCapType: 'large', + marketCapCategoryId: 'large', price: 139.00, oneDayChange: 0.45, oneWeekChange: -2.93, @@ -155,7 +155,7 @@ export const stocks: StockData[] = [ exchange: 'NYSE', sector: 'financial-services', marketCap: '470.00B', - marketCapType: 'large', + marketCapCategoryId: 'large', price: 230.00, oneDayChange: 0.45, oneWeekChange: -11.23, @@ -172,7 +172,7 @@ export const stocks: StockData[] = [ exchange: 'LSE', sector: 'financial-services', marketCap: '123.00B', - marketCapType: 'large', + marketCapCategoryId: 'large', price: 400.00, oneDayChange: 0.45, oneWeekChange: 2.23, @@ -188,7 +188,7 @@ export const stocks: StockData[] = [ exchange: 'LSE', sector: 'renewable-energy', marketCap: '86.00B', - marketCapType: 'large', + marketCapCategoryId: 'large', price: 300.00, oneDayChange: 0.45, oneWeekChange: -2.23, @@ -204,7 +204,7 @@ export const stocks: StockData[] = [ exchange: 'LSE', sector: 'consumer-goods', marketCap: '45.00B', - marketCapType: 'large', + marketCapCategoryId: 'large', price: 200.00, oneDayChange: 0.45, oneWeekChange: 2.23, @@ -221,7 +221,7 @@ export const stocks: StockData[] = [ exchange: 'HK', sector: 'technology', marketCap: '500.00B', - marketCapType: 'large', + marketCapCategoryId: 'large', price: 500.00, oneDayChange: 0.45, oneWeekChange: 2.23, @@ -237,7 +237,7 @@ export const stocks: StockData[] = [ exchange: 'HK', sector: 'technology', marketCap: '120.00B', - marketCapType: 'large', + marketCapCategoryId: 'large', price: 50.00, oneDayChange: 0.45, oneWeekChange: 2.23, @@ -253,7 +253,7 @@ export const stocks: StockData[] = [ exchange: 'HK', sector: 'financial-services', marketCap: '150.00B', - marketCapType: 'large', + marketCapCategoryId: 'large', price: 100.00, oneDayChange: 0.45, oneWeekChange: 2.23, diff --git a/samples/stock-wiz/src/portfolio/Filters/ExchangeFilter/ExchangeFilter.tsx b/samples/stock-wiz/src/portfolio/Filters/ExchangeFilter/ExchangeFilter.tsx index 5fb4b19e..574cc877 100644 --- a/samples/stock-wiz/src/portfolio/Filters/ExchangeFilter/ExchangeFilter.tsx +++ b/samples/stock-wiz/src/portfolio/Filters/ExchangeFilter/ExchangeFilter.tsx @@ -1,5 +1,6 @@ -import {useAiContext} from '@nlux-dev/react/src'; -import {useCallback, useEffect} from 'react'; +import {useAiTask} from '@nlux-dev/react/src/providers/useAiTask.ts'; +import {useAiContext} from '@nlux/react'; +import {useCallback} from 'react'; import {Exchange} from '../../../@types/Data.ts'; import {MyAiContext} from '../../../context.tsx'; @@ -29,33 +30,22 @@ export const ExchangeFilter = (props: ExchangeFilterProps) => { setExchangesFilter(exchanges.map(({id}) => id)); }, [availableExchanges, setExchangesFilter]); - const compAiContext = useAiContext(MyAiContext, 'appliedFilter-view'); - useEffect(() => { - const {cancel} = compAiContext.registerTask( - 'applyExchangeFilter', - toggleExchanges, - availableExchanges.map( - (exchange) => `a boolean, set to true to include exchange ` - + `[ ${exchange.label} (${exchange.id}) ] in the appliedFilters, ` - + `setFilter to false to exclude it`, - ), - ); - - return cancel; - }, [compAiContext, toggleExchanges, availableExchanges]); - - useEffect(() => { - const {cancel} = compAiContext.registerTask( - 'resetExchangeFilter', - () => toggleExchanges(...availableExchanges.map(() => false)), - [], - ); - - return () => { - cancel(); - }; - }, [toggleExchanges, compAiContext, availableExchanges]); + useAiContext( + MyAiContext, + 'Applied Filter View', + selectedExchanges, + ); + useAiTask( + MyAiContext, + 'Apply Exchange Filter', + toggleExchanges, + availableExchanges.map( + (exchange) => `a boolean, set to true to include exchange ` + + `[ ${exchange.label} (${exchange.id}) ] in the appliedFilters, ` + + `setFilter to false to exclude it`, + ), + ); const handleExchangeChange = useCallback((event: React.ChangeEvent) => { const {name, checked} = event.target; diff --git a/samples/stock-wiz/src/portfolio/StockList/StockList.tsx b/samples/stock-wiz/src/portfolio/StockList/StockList.tsx index a78e8f60..bcaadc26 100644 --- a/samples/stock-wiz/src/portfolio/StockList/StockList.tsx +++ b/samples/stock-wiz/src/portfolio/StockList/StockList.tsx @@ -1,6 +1,7 @@ import './StockList.css'; import {useAiContext} from '@nlux-dev/react/src'; -import {useEffect, useMemo} from 'react'; +import {useAiTask} from '@nlux-dev/react/src/providers/useAiTask.ts'; +import {useMemo} from 'react'; import {StockRow} from '../../@types/StockData.ts'; import {MyAiContext} from '../../context.tsx'; import {columns} from '../../data/columns.ts'; @@ -16,21 +17,21 @@ export const StockList = (props: StockListProps) => { const {stockRows, updateRowSelection} = props; const cols = useMemo(() => columns, []); - const compAiContext = useAiContext(MyAiContext, 'stock-list-data'); + useAiContext(MyAiContext, 'Stock List Data', stockRows); - useEffect(() => { - compAiContext.update(stockRows); - const {cancel} = compAiContext.registerTask( - 'selectStock', - (stockId: string | null) => stockId && updateRowSelection(stockId, true), - ['a string representing the ID of the stock to select. if no matching stock is found, set it to null'], - ); + useAiTask( + MyAiContext, + 'Select Stock', + updateRowSelection, + ['a string representing the ID of the stock to select. if no matching stock is found, set it to null'], + ); - return () => { - cancel(); - compAiContext.clear(); - }; - }, [stockRows, compAiContext, updateRowSelection]); + useAiTask( + MyAiContext, + 'Select Stock', + updateRowSelection, + ['a string representing the ID of the stock to select. if no matching stock is found, set it to null'], + ); return (
diff --git a/samples/stock-wiz/src/portfolio/applyFilter.ts b/samples/stock-wiz/src/portfolio/applyFilter.ts index 8624f034..7e335006 100644 --- a/samples/stock-wiz/src/portfolio/applyFilter.ts +++ b/samples/stock-wiz/src/portfolio/applyFilter.ts @@ -8,7 +8,7 @@ export const applyFilter = (stockRows: StockRow[], filter: AppliedFilters): Stoc return false; } - if (filter.marketCaps?.length && !filter.marketCaps.includes(data.marketCapType)) { + if (filter.marketCaps?.length && !filter.marketCaps.includes(data.marketCapCategoryId)) { return false; } diff --git a/specs/specs/context/js/01-initialisation.spec.ts b/specs/specs/context/js/01-initialisation.spec.ts new file mode 100644 index 00000000..e35522a2 --- /dev/null +++ b/specs/specs/context/js/01-initialisation.spec.ts @@ -0,0 +1,195 @@ +import {createAiContext} from '@nlux-dev/core/src/core/aiContext/aiContext'; +import {describe, expect, it, vi} from 'vitest'; +import {createContextAdapterController} from '../../../utils/contextAdapterBuilder'; + +describe('AiContext initialisation', () => { + it('status should be idle by default', () => { + // Arrange + const aiContext = createAiContext(); + // Act + const status = aiContext.status; + // Assert + expect(status).toEqual('idle'); + }); + + it('should throw an error if the adapter is not set', async () => { + // Arrange + const aiContext = createAiContext(); + // Act + const warnBefore = console.warn; + console.warn = vi.fn(); + const result = await aiContext.initialize(); + + // Assert + expect(result).toEqual({success: false, error: expect.stringContaining('Adapter not set')}); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Adapter not set')); + + // Cleanup + console.warn = warnBefore; + }); + + it('should throw an error if the adapter is set twice', () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.withContextId('contextId123').create(); + + // Act + const action = () => aiContext.withAdapter(adapter).withAdapter(adapter); + + // Assert + expect(action).toThrow('Adapter already set'); + }); + + it('should return an error if the adapter fails to initialize', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.withContextId('contextId123').create(); + adapter.create = () => Promise.resolve({success: false, error: 'Failed to initialize context'}); + + // Act + const result = await aiContext.withAdapter(adapter).initialize(); + + // Assert + expect(result).toEqual({success: false, error: 'Failed to initialize context'}); + }); + + it('the contextId should be null if the context is not initialized', () => { + // Arrange + const aiContext = createAiContext(); + + // Act + const contextId = aiContext.contextId; + + // Assert + expect(contextId).toBeNull(); + }); + + it('should change the status to error if the adapter fails to initialize', async () => { + // Arrange + const aiContext = createAiContext(); + const adapter = { + set: () => Promise.resolve({success: false, error: 'Failed to initialize because of reasons'}), + } as any; + + // Act + await aiContext.withAdapter(adapter).initialize(); + + // Assert + expect(aiContext.status).toEqual('error'); + }); + + it('should return a contextId if the adapter initializes successfully', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.withContextId('contextId123').create(); + + // Act + const result = await aiContext.withAdapter(adapter).initialize(); + + // Assert + expect(result).toEqual({success: true, contextId: 'contextId123'}); + }); + + it('should fail if the user attempts to initialize twice', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + + // Act + const result1 = await aiContext.withAdapter(adapter).initialize(); + const result2 = await aiContext.initialize(); + + // Assert + expect(result1).toEqual({success: true, contextId: 'contextId123'}); + expect(result2).toEqual({success: false, error: expect.stringContaining('already initialized')}); + }); + + it('should set the contextId if the adapter initializes successfully', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.withContextId('contextId123').create(); + + // Act + await aiContext.withAdapter(adapter).initialize(); + // Assert + expect(aiContext.contextId).toEqual('contextId123'); + }); + + it('should change the status to syncing if the adapter initializes successfully', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + + // Act + await aiContext.withAdapter(adapter).initialize(); + + // Assert + expect(aiContext.status).toEqual('syncing'); + }); + + it('context should be initialized with the given data', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + + // Act + await aiContext.withAdapter(adapter).initialize({ + 'state-item1': { + value: {val: 123}, + description: 'State item number 1', + }, + }); + + // Assert + expect(adapter.create).toHaveBeenCalledWith({ + 'state-item1': { + value: {val: 123}, + description: 'State item number 1', + }, + }); + }); + + it('should set context to error status if adapter fails to initialize', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + adapter.create = () => Promise.resolve({success: false, error: 'Failed to initialize context'}); + + // Act + await aiContext.withAdapter(adapter).initialize(); + + // Assert + expect(aiContext.status).toEqual('error'); + }); + + it('should not resume after error when second initialize() is called twice', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + let callCount = 0; + adapter.create = () => { + if (callCount === 0) { + callCount++; + return Promise.resolve({success: false, error: 'Failed to initialize context'}); + } + + return Promise.resolve({success: true, contextId: 'contextId123'}); + }; + + // Act + await aiContext.withAdapter(adapter).initialize(); + await aiContext.initialize(); + + // Assert + expect(aiContext.status).toEqual('error'); + }); +}); diff --git a/specs/specs/context/js/02-destruction.spec.ts b/specs/specs/context/js/02-destruction.spec.ts new file mode 100644 index 00000000..395d8153 --- /dev/null +++ b/specs/specs/context/js/02-destruction.spec.ts @@ -0,0 +1,93 @@ +import {createAiContext} from '@nlux-dev/core/src/core/aiContext/aiContext'; +import {describe, expect, it, vi} from 'vitest'; +import {createContextAdapterController} from '../../../utils/contextAdapterBuilder'; +import {waitForMilliseconds} from '../../../utils/wait'; + +describe('AiContext destroy', () => { + it('should set the status to destroyed', async () => { + // Arrange + const aiContext = createAiContext(); + const adapter = { + set: async () => { + return {success: true, contextId: 'contextId123'}; + }, + clear: async () => { + return {success: true}; + }, + } as any; + + // Act + await aiContext.withAdapter(adapter).initialize(); + await aiContext.destroy(); + + // Assert + expect(aiContext.status).toEqual('destroyed'); + }); + + it('should not set the contextId when the adapter finishes initialization after the context is destroyed', + async () => { + // Arrange + const adapter = { + set: () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve({success: true, contextId: 'contextId123'}); + }, 100); + }); + }, + } as any; + const aiContext = createAiContext().withAdapter(adapter); + + // Act: Start the initialization and destroy the context while initialization is in progress + // then wait for the initialization to complete before checking the contextId, which should be null. + aiContext.initialize(); + await aiContext.destroy(); + await waitForMilliseconds(200); + + // Assert + expect(aiContext.contextId).toBeNull(); + }, + ); + + it('should not enable observers after the context is destroyed', async () => { + // Arrange + const aiContext = createAiContext(); + const adapter = { + set: async () => { + return {success: true, contextId: 'contextId123'}; + }, + clear: async () => { + return {success: true}; + }, + } as any; + + // Act + await aiContext.withAdapter(adapter).initialize(); + await aiContext.destroy(); + const stateItemHandler = aiContext.observeState( + 'state-item', + 'The state of the context has changed', + 123, + ); + + // Assert + expect(stateItemHandler).toBeUndefined(); + }); + + it('should call the adapter clear state item on destroy', async () => { + // Arrange + const adapterController = createContextAdapterController(); + const adapter = adapterController.withContextId('contextId123').create(); + adapter.discard = vi.fn(); + const aiContext = createAiContext().withAdapter(adapter); + + // Act + await aiContext.initialize(); + aiContext.observeState('selected-stock-symbol', 'The selected stock symbol', 'AAPL'); + await aiContext.flush(); + await aiContext.destroy(); + + // Assert + expect(adapter.discard).toHaveBeenCalledWith('contextId123'); + }); +}); diff --git a/specs/specs/context/js/03-reset.spec.ts b/specs/specs/context/js/03-reset.spec.ts new file mode 100644 index 00000000..f745fde5 --- /dev/null +++ b/specs/specs/context/js/03-reset.spec.ts @@ -0,0 +1,123 @@ +import {createAiContext} from '@nlux-dev/core/src/core/aiContext/aiContext'; +import {describe, expect, it, vi} from 'vitest'; +import {createContextAdapterController} from '../../../utils/contextAdapterBuilder'; + +describe('AiContext reset', () => { + it('should reset the context', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + + // Act + await aiContext.withAdapter(adapter).initialize(); + const result = await aiContext.reset(); + + // Assert + expect(result).toEqual({success: true}); + }); + + it('should fail and warn when context is not initialized', async () => { + // Arrange + const aiContext = createAiContext(); + + // Act + const warnBefore = console.warn; + console.warn = vi.fn(); + const result = await aiContext.reset(); + + // Assert + expect(result).toEqual({ + success: false, + error: expect.stringContaining('Context has not been initialized'), + }); + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('reset() called on a state that has not been initialized'), + ); + + // Cleanup + console.warn = warnBefore; + }); + + it('should not reset contextId', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + adapter.create = () => { + return new Promise((resolve) => { + // Random contextId + const contextId = `contextId${Math.ceil(Math.random() * 1000)}`; + resolve({success: true, contextId}); + }); + }; + + // Act + const initResult = await aiContext.withAdapter(adapter).initialize(); + const contextIdBefore = aiContext.contextId; + const resetResult = await aiContext.reset(); + const contextIdAfter = aiContext.contextId; + + // Assert + expect(initResult).toEqual({success: true, contextId: contextIdBefore}); + expect(resetResult).toEqual({success: true}); + expect(contextIdBefore).not.toBeNull(); + expect(contextIdBefore).toEqual(contextIdAfter); + }); + + it('should call adapter reset with contextId', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + adapter.resetItems = vi.fn(); + + // Act + await aiContext.withAdapter(adapter).initialize({ + item1: { + value: 'data1', + description: 'description1', + }, + }); + + await aiContext.reset(); + + // Assert + expect(adapter.resetItems).toHaveBeenCalledWith(aiContext.contextId, undefined); + }); + + it('should call adapter reset with contextId and data', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + adapter.resetItems = vi.fn(); + + // Act + await aiContext.withAdapter(adapter).initialize({ + item1: { + value: 'data1', + description: 'description1', + }, + }); + + await aiContext.reset({ + item2: { + value: 'data2', + description: 'description2', + }, + }); + + // Assert + expect(adapter.resetItems).toHaveBeenCalledWith( + aiContext.contextId, + { + item2: { + value: 'data2', + description: 'description2', + }, + }, + ); + }); +}); diff --git a/specs/specs/context/js/04-observe-state.spec.ts b/specs/specs/context/js/04-observe-state.spec.ts new file mode 100644 index 00000000..bf3b7ddf --- /dev/null +++ b/specs/specs/context/js/04-observe-state.spec.ts @@ -0,0 +1,240 @@ +import {createAiContext} from '@nlux-dev/core/src/core/aiContext/aiContext'; +import {describe, expect, it} from 'vitest'; +import {createContextAdapterController} from '../../../utils/contextAdapterBuilder'; +import {waitForMilliseconds} from '../../../utils/wait'; + +describe('AiContext observe state', () => { + it('should return context item handler', async () => { + // Arrange + const aiContext = createAiContext(); + const adapter = createContextAdapterController().create(); + + // Act + await aiContext.withAdapter(adapter).initialize(); + const stateItemHandler = aiContext.observeState( + 'state-item', + 'The state of the context has changed', + 123, + ); + + // Assert + expect(stateItemHandler).toBeDefined(); + }); + + it('adapter.update() should not immediately be called when context.observeState() is called', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const contextAdapter = adapterController.withContextId('contextId123').create(); + await aiContext.withAdapter(contextAdapter).initialize({ + 'state-item1': { + value: {val: 123}, + description: 'State item number 1', + }, + }); + await aiContext.flush(); + + // Act + aiContext.observeState( + 'state-item2', + 'The state of the context has changed', + {val: 456}, + ); + + // Assert + expect(contextAdapter.updateItems).not.toHaveBeenCalledWith('contextId123', expect.objectContaining({ + 'state-item2': {val: 456}, + })); + }); + + describe('on aiContext.observeState()', () => { + describe('on flush', () => { + it('context adapter should be called with the initial item data and description', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const contextAdapter = adapterController.withContextId('contextId123').create(); + await aiContext.withAdapter(contextAdapter).initialize({ + 'state-item1': { + value: {val: 123}, + description: 'State item number 1', + }, + }); + await aiContext.flush(); + + // Act + aiContext.observeState('state-item2', 'State item number 2', {val: 456}); + await aiContext.flush(); + + // Assert + expect(contextAdapter.updateItems).toHaveBeenCalledWith('contextId123', expect.objectContaining({ + 'state-item2': { + value: {val: 456}, + description: 'State item number 2', + }, + })); + }); + }); + }); + + describe('on handler.setData()', () => { + describe('on flush', () => { + it('when handler.setData(data) is called after set, only one call to adapter.update() with merged data is made', + async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const contextAdapter = adapterController.withContextId('contextId123').create(); + await aiContext.withAdapter(contextAdapter).initialize({ + 'state-item1': { + value: {val: 123}, + description: 'State item number 1', + }, + }); + await aiContext.flush(); + + // Act + const stateItemHandler = aiContext.observeState( + 'state-item2', + 'State item number 2', + {val: 456}, + )!; + + stateItemHandler.setData({val: 789}); + stateItemHandler.setData({val: 101112}); + + await waitForMilliseconds(100); + await aiContext.flush(); + + // Assert + expect(contextAdapter.updateItems).not.toHaveBeenCalledWith( + 'contextId123', + expect.objectContaining({ + 'state-item2': { + value: {val: 123}, + description: 'State item number 2', + }, + }), + ); + + expect(contextAdapter.updateItems).not.toHaveBeenCalledWith( + 'contextId123', + expect.objectContaining({ + 'state-item2': expect.objectContaining({ + value: {val: 456}, + }), + }), + ); + + expect(contextAdapter.updateItems).toHaveBeenCalledWith( + 'contextId123', + expect.objectContaining({ + 'state-item2': { + description: 'State item number 2', + value: {val: 101112}, + }, + }), + ); + }, + ); + }); + }); + + describe('on handler.setDescription()', () => { + describe('on flush', () => { + it('when handler.setDescription(str) is called after set, only one call to adapter.update() with merged data is made', + async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const contextAdapter = adapterController.withContextId('contextId123').create(); + await aiContext.withAdapter(contextAdapter).initialize({ + 'state-item1': { + value: {val: 123}, + description: 'State item number 1', + }, + }); + await aiContext.flush(); + + // Act + const stateItemHandler = aiContext.observeState( + 'state-item2', + 'State item number 2', + {val: 456}, + )!; + + stateItemHandler.setData({val: 789}); + stateItemHandler.setDescription('State item number 2 - updated'); + + await waitForMilliseconds(100); + await aiContext.flush(); + + // Assert + expect(contextAdapter.updateItems).not.toHaveBeenCalledWith( + 'contextId123', + expect.objectContaining({ + 'state-item2': { + value: {val: 789}, + description: 'State item number 2', + }, + }), + ); + + expect(contextAdapter.updateItems).not.toHaveBeenCalledWith( + 'contextId123', + expect.objectContaining({ + 'state-item2': expect.objectContaining({ + value: {val: 456}, + }), + }), + ); + + expect(contextAdapter.updateItems).toHaveBeenCalledWith( + 'contextId123', + expect.objectContaining({ + 'state-item2': { + value: {val: 789}, + description: 'State item number 2 - updated', + }, + }), + ); + }, + ); + }); + }); + + describe('on handler.discard()', () => { + describe('on flush', () => { + it('when handler.discard() is called after set, only one call to adapter.remove() is made', + async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const contextAdapter = adapterController.withContextId('contextId123').create(); + await aiContext.withAdapter(contextAdapter).initialize({ + 'state-item1': { + value: {val: 123}, + description: 'State item number 1', + }, + }); + await aiContext.flush(); + + // Act + const stateItemHandler = aiContext.observeState( + 'state-item2', + 'State item number 2', + {val: 456}, + )!; + + stateItemHandler.discard(); + + await waitForMilliseconds(100); + await aiContext.flush(); + + // Assert + expect(contextAdapter.removeItems).toHaveBeenCalledWith('contextId123', ['state-item2']); + }, + ); + }); + }); +}); diff --git a/specs/specs/context/js/05-observe-state-edge-cases.spec.ts b/specs/specs/context/js/05-observe-state-edge-cases.spec.ts new file mode 100644 index 00000000..9a75e886 --- /dev/null +++ b/specs/specs/context/js/05-observe-state-edge-cases.spec.ts @@ -0,0 +1,177 @@ +import {createAiContext} from '@nlux-dev/core/src/core/aiContext/aiContext'; +import {describe, expect, it, vi} from 'vitest'; +import {createContextAdapterController} from '../../../utils/contextAdapterBuilder'; +import {waitForMilliseconds} from '../../../utils/wait'; + +describe('AiContext observe state in edge cases', () => { + describe('when context is still initializing', () => { + it('should not return a valid handler and should warn', async () => { + // Arrange + const initializationDelay = 1000; + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + adapter.create = () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve({success: true, contextId: 'contextId123'}); + }, initializationDelay); + }); + }; + + // No await here on purpose + aiContext.withAdapter(adapter).initialize(); + + // Act + const warnBefore = console.warn; + console.warn = vi.fn(); + const handler = aiContext.observeState( + 'state-item', + 'The state of the context has changed', + 123, + ); + + // Assert + expect(handler).toBeUndefined(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining( + 'AiContextImpl.observeState() called while context is still initializing', + ), + ); + + // Cleanup + console.warn = warnBefore; + }); + }); + + describe('when context is destroyed', () => { + it('should not return a valid handler and should warn', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + await aiContext.withAdapter(adapter).initialize(); + await aiContext.destroy(); + + // Act + const warnBefore = console.warn; + console.warn = vi.fn(); + const handler = aiContext.observeState( + 'state-item', + 'The state of the context has changed', + 123, + ); + + // Assert + expect(handler).toBeUndefined(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('AiContextImpl.observeState() called on destroyed context'), + ); + + // Cleanup + console.warn = warnBefore; + }); + }); + + describe('when context is idle', () => { + it('should not return a valid handler and should warn', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + aiContext.withAdapter(adapter); + + // Act + const warnBefore = console.warn; + console.warn = vi.fn(); + const handler = aiContext.observeState( + 'state-item', + 'The state of the context has changed', + 123, + ); + + // Assert + expect(handler).toBeUndefined(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('AiContextImpl.observeState() called on idle context'), + ); + + // Cleanup + console.warn = warnBefore; + }); + }); + + describe('when context is syncing', () => { + it('should return a valid handler', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + await aiContext.withAdapter(adapter).initialize(); + const stateItemHandler = aiContext.observeState( + 'state-item', + 'The state of the context has changed', + 123, + ); + + // Assert + expect(stateItemHandler).toBeDefined(); + }); + }); + + describe('when context is syncing and then flushed', () => { + it('when an item update should happen while syncing, it should be handled', async () => { + // Arrange + const delayBeforeFlush = 1000; + let updateCallsCount = 0; + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + + adapter.updateItems = vi.fn().mockImplementation(async () => { + return new Promise((resolve) => { + // Delay by 200ms for the first call + const updateDelay = updateCallsCount === 0 ? delayBeforeFlush : 0; + updateCallsCount++; + + setTimeout(() => { + resolve({success: true}); + }, updateDelay); + }); + }); + + await aiContext.withAdapter(adapter).initialize(); + const stateItemHandler = aiContext.observeState( + 'state-item', + 'An item in the state', + 123, + )!; + // Flush without awaiting + aiContext.flush(); + + // Act + // An update triggered before the end of the flush + // but should be performed after the flush + stateItemHandler.setData(456); + stateItemHandler.setDescription('The same item in the state with different description'); + await aiContext.flush(); + await waitForMilliseconds(delayBeforeFlush); + + // Assert + expect(adapter.updateItems).toHaveBeenCalledTimes(2); + expect(adapter.updateItems).toHaveBeenNthCalledWith(1, 'contextId123', { + 'state-item': { + value: 123, + description: 'An item in the state', + }, + }); + + expect(adapter.updateItems).toHaveBeenNthCalledWith(2, 'contextId123', { + 'state-item': { + value: 456, + description: 'The same item in the state with different description', + }, + }); + }); + }); +}); diff --git a/specs/specs/context/js/06-register-task.spec.ts b/specs/specs/context/js/06-register-task.spec.ts new file mode 100644 index 00000000..4b91d71a --- /dev/null +++ b/specs/specs/context/js/06-register-task.spec.ts @@ -0,0 +1,317 @@ +import {createAiContext} from '@nlux-dev/core/src/core/aiContext/aiContext'; +import {describe, expect, it, vi} from 'vitest'; +import {createContextAdapterController} from '../../../utils/contextAdapterBuilder'; + +describe('AiContext register task', () => { + it('should return task handler when valid adapter is passed', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + + // Act + await aiContext.withAdapter(adapter).initialize(); + const taskHandler = aiContext.registerTask( + 'task-id', 'Task description', vi.fn(), ['param1', 'param2'], + ); + + // Assert + expect(taskHandler).toBeDefined(); + }); + + it('should not call the adapter registerTask method as soon as the task is registered', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + adapter.updateTasks = vi.fn().mockResolvedValue({success: true}); + await aiContext.withAdapter(adapter).initialize(); + const taskId = 'task-id'; + const description = 'Task description'; + const callback = vi.fn(); + const paramDescriptions = ['param1', 'param2']; + + // Act + aiContext.registerTask(taskId, description, callback, paramDescriptions); + + // Assert + expect(adapter.updateTasks).not.toHaveBeenCalled(); + }); + + it('should call the adapter registerTask method on flush', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.withContextId('context123').create(); + await aiContext.withAdapter(adapter).initialize(); + const taskId = 'task-id'; + const description = 'Task description'; + const callback = vi.fn(); + const paramDescriptions = ['param1', 'param2']; + + // Act + aiContext.registerTask(taskId, description, callback, paramDescriptions); + await aiContext.flush(); + + // Assert + expect(adapter.updateTasks).toHaveBeenCalledWith( + 'context123', + { + 'task-id': { + description: 'Task description', + paramDescriptions: ['param1', 'param2'], + }, + }, + ); + }); + + it('should log a warning if the adapter setTasks method fails', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + adapter.updateTasks = vi.fn().mockResolvedValue({success: false, error: 'The Error MSG'}); + await aiContext.withAdapter(adapter).initialize(); + const taskId = 'task-id'; + const description = 'Task description'; + const callback = vi.fn(); + const paramDescriptions = ['param1', 'param2']; + const initialWarnCallback = console.warn; + console.warn = vi.fn(); + + // Act + aiContext.registerTask(taskId, description, callback, paramDescriptions); + await aiContext.flush(); + + // Assert + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('The Error MSG')); + + // Cleanup + console.warn = initialWarnCallback; + }); + + it('should not allow calling callback if the adapter registerTask method fails', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + adapter.updateTasks = vi.fn().mockResolvedValue(Promise.resolve({ + success: false, + error: 'The Error MSG', + })); + + await aiContext.withAdapter(adapter).initialize(); + const taskId = 'task-id'; + const description = 'Task description'; + const callback = vi.fn(); + const paramDescriptions = ['param1', 'param2']; + + // Act + const taskHandler = aiContext.registerTask(taskId, description, callback, paramDescriptions); + await aiContext.flush(); + const result = await aiContext.runTask(taskId, []); + + // Assert + expect(aiContext.hasTask(taskId)).toBe(true); + expect(aiContext.hasRunnableTask(taskId)).toBe(false); + expect(callback).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: false, + error: expect.stringContaining('The task with ID \'task-id\' has no callback'), + }); + }); + + it('should store the task callback if the adapter registerTask method succeeds', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + adapter.updateTasks = vi.fn().mockResolvedValue(Promise.resolve({success: true})); + await aiContext.withAdapter(adapter).initialize(); + const taskId = 'task-id'; + const description = 'Task description'; + const callback = vi.fn(); + const paramDescriptions = ['param1', 'param2']; + + // Act + aiContext.registerTask(taskId, description, callback, paramDescriptions); + await aiContext.flush(); + + // Assert + expect(aiContext.hasTask(taskId)).toBe(true); + expect(aiContext.hasRunnableTask(taskId)).toBe(true); + }); + + it('should not register a task twice', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.withContextId('context123').create(); + adapter.updateTasks = vi.fn().mockResolvedValue({success: true}); + await aiContext.withAdapter(adapter).initialize(); + const taskId = 'task-id'; + const description = 'Task description'; + const callback = vi.fn(); + const paramDescriptions = ['param1', 'param2']; + const originalWarn = console.warn; + console.warn = vi.fn(); + + // Act + const taskHandler1 = aiContext.registerTask( + taskId, description, callback, paramDescriptions, + ); + + const taskHandler2 = aiContext.registerTask( + taskId, description, callback, paramDescriptions, + ); + + await aiContext.flush(); + + // Assert + expect(taskHandler1).toBeDefined(); + expect(taskHandler2).toBeUndefined(); + expect(adapter.updateTasks).toHaveBeenCalledTimes(1); + expect(adapter.updateTasks).toHaveBeenCalledWith( + 'context123', + { + 'task-id': { + description: 'Task description', + paramDescriptions: ['param1', 'param2'], + }, + }, + ); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('existing taskId'), + ); + + // Cleanup + console.warn = originalWarn; + }); + + it('should not call the adapter registerTask method if the task is already registered', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.withContextId('context123').create(); + adapter.updateTasks = vi.fn().mockResolvedValue({success: true}); + await aiContext.withAdapter(adapter).initialize(); + const taskId = 'task-id'; + const description1 = 'Task description 1'; + const description2 = 'Task description 2'; + const callback = vi.fn(); + const paramDescriptions = ['param1', 'param2']; + aiContext.registerTask(taskId, description1, callback, paramDescriptions); + await aiContext.flush(); + + // Act + const originalWarn = console.warn; + console.warn = vi.fn(); + aiContext.registerTask( + taskId, description2, callback, paramDescriptions, + ); + + await aiContext.flush(); + + // Assert + expect(adapter.updateTasks).toHaveBeenCalledTimes(1); + expect(adapter.updateTasks).toHaveBeenCalledWith( + 'context123', + { + 'task-id': { + description: 'Task description 1', + paramDescriptions: ['param1', 'param2'], + }, + }, + ); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('called with existing taskId'), + ); + + // Cleanup + console.warn = originalWarn; + }); + + it('should not register the task callback if the context is destroyed', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + adapter.updateTasks = vi.fn().mockResolvedValue({success: true}); + await aiContext.withAdapter(adapter).initialize(); + const taskId = 'task-id'; + const description = 'Task description'; + const callback = vi.fn(); + const paramDescriptions = ['param1', 'param2']; + await aiContext.destroy(); + + // Act + const originalWarn = console.warn; + console.warn = vi.fn(); + aiContext.registerTask(taskId, description, callback, paramDescriptions); + await aiContext.flush(); + + // Assert + expect(aiContext.hasTask(taskId)).toBe(false); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('context that has does not have tasks service'), + ); + }); + + it('should remove the task callback when context is destroyed', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + await aiContext.withAdapter(adapter).initialize(); + const taskId = 'task-id'; + const description = 'Task description'; + const callback = vi.fn(); + const paramDescriptions = ['param1', 'param2']; + aiContext.registerTask(taskId, description, callback, paramDescriptions); + await aiContext.flush(); + expect(aiContext.hasTask(taskId)).toBe(true); + + // Act + await aiContext.destroy(); + + // Assert + expect(aiContext.hasTask(taskId)).toBe(false); + }); + + it('should call the adapter resetTasks method on destroy when a task is registered', + async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.withContextId('context123').create(); + adapter.resetTasks = vi.fn().mockResolvedValue({success: true}); + await aiContext.withAdapter(adapter).initialize(); + aiContext.registerTask( + 'task-id', 'Task description', vi.fn(), ['param1', 'param2'], + ); + + // Act + await aiContext.destroy(); + + // Assert + expect(adapter.resetTasks).toHaveBeenCalledWith('context123'); + }, + ); + + it('should not call the adapter resetTasks method when context is destroyed if there are no tasks registered', + async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.withContextId('context123').create(); + adapter.resetTasks = vi.fn().mockResolvedValue({success: true}); + await aiContext.withAdapter(adapter).initialize(); + + // Act + await aiContext.destroy(); + + // Assert + expect(adapter.resetTasks).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/specs/specs/context/js/07-register-task-edge-cases.spec.ts b/specs/specs/context/js/07-register-task-edge-cases.spec.ts new file mode 100644 index 00000000..ba48e1b1 --- /dev/null +++ b/specs/specs/context/js/07-register-task-edge-cases.spec.ts @@ -0,0 +1,97 @@ +import {createAiContext} from '@nlux-dev/core/src/core/aiContext/aiContext'; +import {describe, expect, it, vi} from 'vitest'; +import {createContextAdapterController} from '../../../utils/contextAdapterBuilder'; +import {waitForMilliseconds} from '../../../utils/wait'; + +describe('AiContext register task in edge cases', () => { + describe('when context is still initializing', () => { + it('should not call the adapter setTasks method', async () => { + // Arrange + const initializationDelay = 1000; + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + adapter.updateTasks = vi.fn(); + adapter.create = () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve({success: true, contextId: 'contextId123'}); + }, initializationDelay); + }); + }; + + // No await here on purpose + aiContext.withAdapter(adapter).initialize(); + + // Act + aiContext.registerTask( + 'task-id', 'Task description', vi.fn(), [], + ); + await aiContext.flush(); + + // Assert + expect(adapter.updateTasks).not.toHaveBeenCalled(); + }); + }); + + describe('when a task is registered while the context is flushing', () => { + it('should be synced after the context finished flushing', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.withContextId('contextId123').create(); + const updateTasksDelay = 500; + adapter.updateTasks = vi.fn().mockImplementation(() => { + return new Promise((resolve) => { + setTimeout(() => { + resolve({success: true}); + }, updateTasksDelay); + }); + }); + adapter.create = () => { + return new Promise((resolve) => { + resolve({success: true, contextId: 'contextId123'}); + }); + }; + + await aiContext.withAdapter(adapter).initialize(); + aiContext.registerTask( + 'task-nb-1', 'Task description 1', vi.fn(), ['Params of task 1'], + ); + + // Act 1 + // No await here on purpose + aiContext.flush(); + await waitForMilliseconds(updateTasksDelay / 5); + expect(adapter.updateTasks).toHaveBeenCalledOnce(); + expect(adapter.updateTasks).toHaveBeenCalledWith( + 'contextId123', + { + 'task-nb-1': { + description: 'Task description 1', + paramDescriptions: ['Params of task 1'], + }, + }, + ); + + // Act 2 + aiContext.registerTask( + 'task-nb-2', 'Task description 2', vi.fn(), ['Params of task 2'], + ); + await waitForMilliseconds(updateTasksDelay); + await aiContext.flush(); + + // Assert + expect(adapter.updateTasks).toHaveBeenCalledTimes(2); + expect(adapter.updateTasks).toHaveBeenCalledWith( + 'contextId123', + { + 'task-nb-2': { + description: 'Task description 2', + paramDescriptions: ['Params of task 2'], + }, + }, + ); + }); + }); +}); diff --git a/specs/specs/context/js/08-update-task.spec.ts b/specs/specs/context/js/08-update-task.spec.ts new file mode 100644 index 00000000..b7c27bc7 --- /dev/null +++ b/specs/specs/context/js/08-update-task.spec.ts @@ -0,0 +1,136 @@ +import {createAiContext} from '@nlux-dev/core/src/core/aiContext/aiContext'; +import {describe, expect, it, vi} from 'vitest'; +import {createContextAdapterController} from '../../../utils/contextAdapterBuilder'; + +describe('AiContext update task', () => { + it('task handler should have updater methods', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + await aiContext.withAdapter(adapter).initialize(); + + // Act + const taskHandler = aiContext.registerTask( + 'task-id', 'Task description', vi.fn(), ['param1', 'param2'], + ); + + // Assert + expect(taskHandler?.setDescription).toBeDefined(); + expect(taskHandler?.setCallback).toBeDefined(); + expect(taskHandler?.setParamDescriptions).toBeDefined(); + }); + + it('should not immediately update the task description', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + await aiContext.withAdapter(adapter).initialize(); + const taskHandler = aiContext.registerTask( + 'task-id', 'Task description', vi.fn(), ['param1', 'param2'], + ); + + // Act + taskHandler?.setDescription('New description'); + + // Assert + expect(adapter.updateTasks).not.toHaveBeenCalled(); + }); + + it('should update the task description on flush', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.withContextId('contextId123').create(); + await aiContext.withAdapter(adapter).initialize(); + const taskHandler = aiContext.registerTask( + 'task-id', 'Task description', vi.fn(), ['param1', 'param2'], + ); + + // Act + taskHandler?.setDescription('New description'); + await aiContext.flush(); + + // Assert + expect(adapter.updateTasks).toHaveBeenCalledWith( + 'contextId123', { + 'task-id': { + description: 'New description', + }, + }, + ); + }); + + it('should immediately update the task callback', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + await aiContext.withAdapter(adapter).initialize(); + const taskHandler = aiContext.registerTask( + 'task-id', 'Task description', vi.fn(), ['param1', 'param2'], + ); + await aiContext.flush(); + + // Act + const newCallback = vi.fn(); + taskHandler?.setCallback(newCallback); + + // Assert + await aiContext.runTask('task-id', ['param1', 'param2']); + expect(newCallback).toHaveBeenCalled(); + }); + + it('should not immediately update the task param descriptions', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.create(); + await aiContext.withAdapter(adapter).initialize(); + const taskHandler = aiContext.registerTask( + 'task-id', 'Task description', vi.fn(), ['param1', 'param2'], + ); + await aiContext.flush(); + expect(adapter.updateTasks).toHaveBeenCalledWith( + 'contextId123', + expect.objectContaining({ + 'task-id': { + description: 'Task description', + paramDescriptions: ['param1', 'param2'], + }, + }), + ); + + // Act + taskHandler?.setParamDescriptions(['newParam1', 'newParam2']); + + // Assert + expect(adapter.updateTasks).toHaveBeenCalledOnce(); + }); + + it('should update the task param descriptions on flush', async () => { + // Arrange + const aiContext = createAiContext(); + const adapterController = createContextAdapterController(); + const adapter = adapterController.withContextId('contextId123').create(); + await aiContext.withAdapter(adapter).initialize(); + const taskHandler = aiContext.registerTask( + 'task-id', 'Task description', vi.fn(), ['param1', 'param2'], + ); + await aiContext.flush(); + + // Act + taskHandler?.setParamDescriptions(['newParam1', 'newParam2']); + await aiContext.flush(); + + // Assert + expect(adapter.updateTasks).toHaveBeenCalledWith( + 'contextId123', { + 'task-id': { + paramDescriptions: ['newParam1', 'newParam2'], + }, + }, + ); + }); +}); diff --git a/specs/utils/contextAdapterBuilder.ts b/specs/utils/contextAdapterBuilder.ts new file mode 100644 index 00000000..e1da394a --- /dev/null +++ b/specs/utils/contextAdapterBuilder.ts @@ -0,0 +1,36 @@ +import {ContextAdapter} from '@nlux-dev/core/src/types/adapters/context/contextAdapter'; +import {vi} from 'vitest'; + +export interface ContextAdapterControllerBuilder { + create(): ContextAdapter; + withContextId(contextId: string): ContextAdapterControllerBuilder; +} + +class ContextAdapterControllerBuilderImpl implements ContextAdapterControllerBuilder { + + private theContextId: string | null = 'contextId123'; + + create() { + const adapter: ContextAdapter = { + create: vi.fn().mockResolvedValue({success: true, contextId: this.theContextId}), + discard: vi.fn().mockResolvedValue({success: true}), + updateItems: vi.fn().mockResolvedValue({success: true}), + removeItems: vi.fn().mockResolvedValue({success: true}), + resetItems: vi.fn().mockResolvedValue({success: true}), + updateTasks: vi.fn().mockResolvedValue({success: true}), + removeTasks: vi.fn().mockResolvedValue({success: true}), + resetTasks: vi.fn().mockResolvedValue({success: true}), + }; + + return adapter; + } + + withContextId(contextId: string | null): ContextAdapterControllerBuilder { + this.theContextId = contextId; + return this; + } +} + +export const createContextAdapterController = (): ContextAdapterControllerBuilder => { + return new ContextAdapterControllerBuilderImpl(); +};