diff --git a/apps/backend/src/db/migrations/20240920094244_add_thread_id_to_user_details.ts b/apps/backend/src/db/migrations/20240920094244_add_thread_id_to_user_details.ts new file mode 100644 index 000000000..c9b333906 --- /dev/null +++ b/apps/backend/src/db/migrations/20240920094244_add_thread_id_to_user_details.ts @@ -0,0 +1,19 @@ +import { type Knex } from "knex"; + +const TABLE_NAME = "user_details"; + +const COLUMN_NAME = "thread_id"; + +function up(knex: Knex): Promise { + return knex.schema.alterTable(TABLE_NAME, (table) => { + table.string(COLUMN_NAME).unique(); + }); +} + +function down(knex: Knex): Promise { + return knex.schema.alterTable(TABLE_NAME, (table) => { + table.dropColumn(COLUMN_NAME); + }); +} + +export { down, up }; diff --git a/apps/backend/src/db/migrations/20240920120005_add_chat_messages_table.ts b/apps/backend/src/db/migrations/20240920120005_add_chat_messages_table.ts new file mode 100644 index 000000000..0e0bfbf0e --- /dev/null +++ b/apps/backend/src/db/migrations/20240920120005_add_chat_messages_table.ts @@ -0,0 +1,62 @@ +import { type Knex } from "knex"; + +const TableName = { + CHAT_MESSAGES: "chat_messages", + USER_DETAILS: "user_details", +} as const; + +const ColumnName = { + AUTHOR: "author", + CREATED_AT: "created_at", + ID: "id", + IS_READ: "is_read", + TASK: "task", + TEXT: "text", + THREAD_ID: "thread_id", + TYPE: "type", + UPDATED_AT: "updated_at", +} as const; + +const ChatMessageType = { + TASK: "task", + TEXT: "text", +} as const; + +const ChatMessageAuthor = { + ASSISTANT: "assistant", + USER: "user", +} as const; + +const DELETE_STRATEGY = "CASCADE"; + +function up(knex: Knex): Promise { + return knex.schema.createTable(TableName.CHAT_MESSAGES, (table) => { + table.increments(ColumnName.ID).primary(); + table.timestamp(ColumnName.CREATED_AT).defaultTo(knex.fn.now()); + table.timestamp(ColumnName.UPDATED_AT).defaultTo(knex.fn.now()); + table + .enum(ColumnName.AUTHOR, [ + ChatMessageAuthor.ASSISTANT, + ChatMessageAuthor.USER, + ]) + .notNullable(); + table.boolean(ColumnName.IS_READ).notNullable().defaultTo(false); + table + .enum(ColumnName.TYPE, [ChatMessageType.TASK, ChatMessageType.TEXT]) + .notNullable(); + table.string(ColumnName.TEXT); + table.json(ColumnName.TASK); + table + .string(ColumnName.THREAD_ID) + .notNullable() + .references(ColumnName.THREAD_ID) + .inTable(TableName.USER_DETAILS) + .onDelete(DELETE_STRATEGY); + }); +} + +function down(knex: Knex): Promise { + return knex.schema.dropTableIfExists(TableName.CHAT_MESSAGES); +} + +export { down, up }; diff --git a/apps/backend/src/db/migrations/20240922132151_update_type_of_text_in_chat_message.ts b/apps/backend/src/db/migrations/20240922132151_update_type_of_text_in_chat_message.ts new file mode 100644 index 000000000..cd95c8b26 --- /dev/null +++ b/apps/backend/src/db/migrations/20240922132151_update_type_of_text_in_chat_message.ts @@ -0,0 +1,18 @@ +import { type Knex } from "knex"; + +const TABLE_NAME = "chat_messages"; +const COLUMN_NAME = "text"; + +function up(knex: Knex): Promise { + return knex.schema.alterTable(TABLE_NAME, function (table) { + table.text(COLUMN_NAME).alter(); + }); +} + +function down(knex: Knex): Promise { + return knex.schema.alterTable(TABLE_NAME, function (table) { + table.string(COLUMN_NAME).alter(); + }); +} + +export { down, up }; diff --git a/apps/backend/src/libs/modules/database/libs/enums/database-table-name.enum.ts b/apps/backend/src/libs/modules/database/libs/enums/database-table-name.enum.ts index 1191b614a..5681c4840 100644 --- a/apps/backend/src/libs/modules/database/libs/enums/database-table-name.enum.ts +++ b/apps/backend/src/libs/modules/database/libs/enums/database-table-name.enum.ts @@ -1,5 +1,6 @@ const DatabaseTableName = { CATEGORIES: "categories", + CHAT_MESSAGES: "chat_messages", FILES: "files", MIGRATIONS: "migrations", ONBOARDING_ANSWERS: "onboarding_answers", diff --git a/apps/backend/src/modules/ai-assistant/ai-assistant.controller.ts b/apps/backend/src/modules/ai-assistant/ai-assistant.controller.ts index 7f6dded61..300c2ce29 100644 --- a/apps/backend/src/modules/ai-assistant/ai-assistant.controller.ts +++ b/apps/backend/src/modules/ai-assistant/ai-assistant.controller.ts @@ -11,14 +11,17 @@ import { type UserDto } from "~/modules/users/users.js"; import { type AIAssistantService } from "./ai-assistant.service.js"; import { AIAssistantApiPath } from "./libs/enums/enums.js"; import { + type AIAssistantAcceptTaskRequestDto, + type AIAssistantChangeTaskRequestDto, type AIAssistantCreateMultipleTasksDto, - type AIAssistantRequestDto, + type AIAssistantExplainTaskRequestDto, type AIAssistantSuggestTaskRequestDto, type ThreadMessageCreateDto, } from "./libs/types/types.js"; import { acceptMultipleTasksValidationSchema, addMessageToThreadValidationSchema, + changeTaskSuggestionRequestValidationSchema, taskActionRequestSchemaValidationSchema, taskSuggestionRequestValidationSchema, } from "./libs/validation-schemas/validation-schemas.js"; @@ -260,6 +263,17 @@ import { * type: string * description: The type of the message. * example: "text" + * + * SaveMessage: + * type: object + * properties: + * author: + * type: string + * description: The author of the message (e.g., "assistant"). + * example: "assistant" + * text: + * type: string + * description: Text of the message */ class AIAssistantController extends BaseController { @@ -286,6 +300,7 @@ class AIAssistantController extends BaseController { this.addMessageToConversation( options as APIHandlerOptions<{ body: ThreadMessageCreateDto; + user: UserDto; }>, ), method: "POST", @@ -299,14 +314,14 @@ class AIAssistantController extends BaseController { handler: (options) => this.changeTaskSuggestion( options as APIHandlerOptions<{ - body: AIAssistantRequestDto; + body: AIAssistantChangeTaskRequestDto; user: UserDto; }>, ), method: "POST", path: AIAssistantApiPath.CHAT_CHANGE_TASKS, validation: { - body: taskActionRequestSchemaValidationSchema, + body: changeTaskSuggestionRequestValidationSchema, }, }); @@ -314,14 +329,14 @@ class AIAssistantController extends BaseController { handler: (options) => this.explainTasksSuggestion( options as APIHandlerOptions<{ - body: AIAssistantRequestDto; + body: AIAssistantExplainTaskRequestDto; user: UserDto; }>, ), method: "POST", path: AIAssistantApiPath.CHAT_EXPLAIN_TASKS, validation: { - body: taskActionRequestSchemaValidationSchema, + body: changeTaskSuggestionRequestValidationSchema, }, }); @@ -359,7 +374,7 @@ class AIAssistantController extends BaseController { handler: (options) => this.acceptTask( options as APIHandlerOptions<{ - body: AIAssistantRequestDto; + body: AIAssistantAcceptTaskRequestDto; user: UserDto; }>, ), @@ -387,11 +402,12 @@ class AIAssistantController extends BaseController { * schema: * type: object * properties: - * threadId: - * type: string - * description: Identifier for the thread - * example: "thread_5kL0dVY9ADvmNz8U33P7qFX3" - * payload: + * messages: + * type: array + * description: array of text messages that should be saved in database + * items: + * $ref: '#/components/schemas/SaveMessage' + * task: * $ref: '#/components/schemas/TaskPayload' * responses: * 200: @@ -404,7 +420,7 @@ class AIAssistantController extends BaseController { private async acceptTask( options: APIHandlerOptions<{ - body: AIAssistantRequestDto; + body: AIAssistantAcceptTaskRequestDto; user: UserDto; }>, ): Promise { @@ -420,7 +436,7 @@ class AIAssistantController extends BaseController { * @swagger * /assistant/chat/accept-tasks: * post: - * summary: Accept tasks suggestions + * summary: Accept task suggestions * tags: * - AI Assistant * security: @@ -430,27 +446,28 @@ class AIAssistantController extends BaseController { * content: * application/json: * schema: - * type: object + * type: array * properties: - * threadId: - * type: string - * description: Identifier for the thread - * example: "thread_5kL0dVY9ADvmNz8U33P7qFX3" - * payload: + * messages: + * type: array + * description: array of text messages that should be saved in database + * items: + * $ref: '#/components/schemas/SaveMessage' + * tasks: * type: array - * description: Array of tasks to accept + * description: array of accepted tasks * items: * $ref: '#/components/schemas/TaskPayload' * responses: * 200: - * description: Returns the accepted tasks + * description: Returns the accepted task * content: * application/json: * schema: * type: array - * items: - * $ref: '#/components/schemas/Task' + * items: boolean */ + private async acceptTasks( options: APIHandlerOptions<{ body: AIAssistantCreateMultipleTasksDto; @@ -485,10 +502,6 @@ class AIAssistantController extends BaseController { * type: string * description: The text message to add to the thread * example: "Hello, how can I assist you?" - * threadId: - * type: string - * description: Identifier for the conversation thread - * example: "thread_abc123" * responses: * 200: * description: Indicates if the message was successfully added @@ -502,12 +515,13 @@ class AIAssistantController extends BaseController { private async addMessageToConversation( options: APIHandlerOptions<{ body: ThreadMessageCreateDto; + user: UserDto; }>, ): Promise { - const { body } = options; + const { body, user } = options; return { - payload: await this.openAIService.addMessageToThread(body), + payload: await this.openAIService.addMessageToThread(body, user), status: HTTPCode.OK, }; } @@ -528,12 +542,14 @@ class AIAssistantController extends BaseController { * schema: * type: object * properties: - * threadId: - * type: string - * description: Identifier for the thread - * example: "thread_5kL0dVY9ADvmNz8U33P7qFX3" - * payload: + * messages: + * type: array + * description: Array of chat messages containing task suggestions. + * items: + * $ref: '#/components/schemas/SaveMessage' + * tasks: * type: array + * description: array of denied tasks * items: * $ref: '#/components/schemas/TaskPayload' * responses: @@ -551,14 +567,10 @@ class AIAssistantController extends BaseController { * oneOf: * - $ref: '#/components/schemas/ChatMessageText' * - $ref: '#/components/schemas/ChatMessageTask' - * threadId: - * type: string - * description: Identifier for the chat thread. - * example: "thread_QwWiRV7jFYMz0i0YGcRvcRsU" */ private async changeTaskSuggestion( options: APIHandlerOptions<{ - body: AIAssistantRequestDto; + body: AIAssistantChangeTaskRequestDto; user: UserDto; }>, ): Promise { @@ -586,12 +598,14 @@ class AIAssistantController extends BaseController { * schema: * type: object * properties: - * threadId: - * type: string - * description: Identifier for the thread - * example: "thread_5kL0dVY9ADvmNz8U33P7qFX3" - * payload: + * messages: * type: array + * description: Array of chat messages containing task suggestions. + * items: + * $ref: '#/components/schemas/SaveMessage' + * tasks: + * type: array + * description: array of tasks which should be explained * items: * $ref: '#/components/schemas/TaskPayload' * responses: @@ -609,20 +623,17 @@ class AIAssistantController extends BaseController { * oneOf: * - $ref: '#/components/schemas/ChatMessageTextExplanation' * - $ref: '#/components/schemas/ChatMessageTask' - * threadId: - * type: string - * description: Identifier for the chat thread. - * example: "thread_QwWiRV7jFYMz0i0YGcRvcRsU" */ private async explainTasksSuggestion( options: APIHandlerOptions<{ - body: AIAssistantRequestDto; + body: AIAssistantExplainTaskRequestDto; + user: UserDto; }>, ): Promise { - const { body } = options; + const { body, user } = options; return { - payload: await this.openAIService.explainTasksSuggestion(body), + payload: await this.openAIService.explainTaskSuggestion(body, user), status: HTTPCode.OK, }; } @@ -651,10 +662,6 @@ class AIAssistantController extends BaseController { * type: array * description: Array of chat messages (initially empty). * example: [] - * threadId: - * type: string - * description: Identifier for the chat thread. - * example: "thread_QwWiRV7jFYMz0i0YGcRvcRsU" */ private async initializeNewChat( options: APIHandlerOptions<{ @@ -690,10 +697,11 @@ class AIAssistantController extends BaseController { * description: Array of selected categories for task suggestions * items: * $ref: '#/components/schemas/SelectedCategory' - * threadId: - * type: string - * description: Identifier for the thread - * example: "thread_abc123" + * messages: + * type: array + * description: Array of chat messages containing task suggestions. + * items: + * $ref: '#/components/schemas/SaveMessage' * responses: * 200: * description: Returns task suggestions for the provided categories @@ -709,10 +717,6 @@ class AIAssistantController extends BaseController { * oneOf: * - $ref: '#/components/schemas/ChatMessageText' * - $ref: '#/components/schemas/ChatMessageTask' - * threadId: - * type: string - * description: Identifier for the chat thread. - * example: "thread_QwWiRV7jFYMz0i0YGcRvcRsU" */ private async suggestTasksForCategories( options: APIHandlerOptions<{ @@ -720,10 +724,10 @@ class AIAssistantController extends BaseController { user: UserDto; }>, ): Promise { - const { body } = options; + const { body, user } = options; return { - payload: await this.openAIService.suggestTasksForCategories(body), + payload: await this.openAIService.suggestTasksForCategories(body, user), status: HTTPCode.OK, }; } diff --git a/apps/backend/src/modules/ai-assistant/ai-assistant.service.ts b/apps/backend/src/modules/ai-assistant/ai-assistant.service.ts index 20a9fa88e..d7414ffa5 100644 --- a/apps/backend/src/modules/ai-assistant/ai-assistant.service.ts +++ b/apps/backend/src/modules/ai-assistant/ai-assistant.service.ts @@ -1,8 +1,10 @@ import { type OpenAI, OpenAIRoleKey } from "~/libs/modules/open-ai/open-ai.js"; import { type UserDto } from "~/libs/types/types.js"; import { type CategoryService } from "~/modules/categories/categories.js"; +import { type ChatMessageService } from "~/modules/chat-message/chat-message.service.js"; import { type OnboardingRepository } from "~/modules/onboarding/onboarding.js"; import { type TaskService } from "~/modules/tasks/tasks.js"; +import { type UserService } from "~/modules/users/users.js"; import { generateChangeTasksSuggestionsResponse, @@ -15,46 +17,59 @@ import { runSuggestTaskByCategoryOptions, } from "./libs/helpers/helpers.js"; import { + type AIAssistantAcceptTaskRequestDto, + type AIAssistantChangeTaskRequestDto, + type AIAssistantChatInitializeResponseDto, type AIAssistantCreateMultipleTasksDto, - type AIAssistantRequestDto, + type AIAssistantExplainTaskRequestDto, type AIAssistantResponseDto, type AIAssistantSuggestTaskRequestDto, - type TaskCreateDto, + type ChatMessageDto, type TaskDto, type ThreadMessageCreateDto, } from "./libs/types/types.js"; type Constructor = { categoryService: CategoryService; + chatMessageService: ChatMessageService; onboardingRepository: OnboardingRepository; openAI: OpenAI; taskService: TaskService; + userService: UserService; }; class AIAssistantService { private categoryService: CategoryService; + private chatMessageService: ChatMessageService; private onboardingRepository: OnboardingRepository; private openAI: OpenAI; private taskService: TaskService; + private userService: UserService; public constructor({ categoryService, + chatMessageService, onboardingRepository, openAI, taskService, + userService, }: Constructor) { this.openAI = openAI; this.categoryService = categoryService; + this.chatMessageService = chatMessageService; this.onboardingRepository = onboardingRepository; this.taskService = taskService; + this.userService = userService; } public async acceptTask( user: UserDto, - body: AIAssistantRequestDto, + body: AIAssistantAcceptTaskRequestDto, ): Promise { - const { payload, threadId } = body; - const task = payload as TaskCreateDto; + const { messages, task } = body; + const threadId = user.threadId as string; + + await this.chatMessageService.saveAllTextMessages(messages, threadId); const newTask = await this.taskService.create({ categoryId: task.categoryId, @@ -76,8 +91,10 @@ class AIAssistantService { user: UserDto, body: AIAssistantCreateMultipleTasksDto, ): Promise { - const { payload, threadId } = body; - const tasks = payload; + const { messages, tasks } = body; + const threadId = user.threadId as string; + + await this.chatMessageService.saveAllTextMessages(messages, threadId); return await Promise.all( tasks.map(async (task) => { @@ -102,8 +119,10 @@ class AIAssistantService { public async addMessageToThread( body: ThreadMessageCreateDto, + user: UserDto, ): Promise { - const { text, threadId } = body; + const { text } = body; + const threadId = user.threadId as string; const prompt = { content: text, @@ -115,35 +134,78 @@ class AIAssistantService { public async changeTasksSuggestion( user: UserDto, - body: AIAssistantRequestDto, + body: AIAssistantChangeTaskRequestDto, ): Promise { - const { payload, threadId } = body; - const tasks = payload as TaskCreateDto[]; + const { messages, tasks } = body; + const threadId = user.threadId as string; + + await this.chatMessageService.saveAllTextMessages(messages, threadId); const runThreadOptions = runChangeTasksByCategoryOptions(tasks); const result = await this.openAI.runThread(threadId, runThreadOptions); - return generateChangeTasksSuggestionsResponse(result); + const response = generateChangeTasksSuggestionsResponse(result); + + if (!response) { + return null; + } + + const responseMessages: ChatMessageDto[] = []; + + for (const message of response) { + responseMessages.push(await this.chatMessageService.create(message)); + } + + return { + messages: responseMessages, + }; } - public async explainTasksSuggestion( - body: AIAssistantRequestDto, + public async explainTaskSuggestion( + body: AIAssistantExplainTaskRequestDto, + user: UserDto, ): Promise { - const { payload, threadId } = body; + const { messages, tasks } = body; + const threadId = user.threadId as string; - const tasks = payload as TaskCreateDto[]; + await this.chatMessageService.saveAllTextMessages(messages, threadId); const runThreadOptions = runExplainTaskOptions(tasks); const result = await this.openAI.runThread(threadId, runThreadOptions); - return generateExplainTasksSuggestionsResponse(result); + const response = generateExplainTasksSuggestionsResponse(result); + + if (!response) { + return null; + } + + const responseMessages: ChatMessageDto[] = []; + + for (const message of response) { + responseMessages.push(await this.chatMessageService.create(message)); + } + + return { + messages: responseMessages, + }; } public async initializeNewChat( user: UserDto, - ): Promise { + ): Promise { + if (user.threadId) { + const messages = await this.chatMessageService.findByThreadId( + user.threadId, + ); + + return { + messages, + threadId: user.threadId, + }; + } + const userQuestionsWithAnswers = await this.onboardingRepository.findUserAnswersWithQuestions(user.id); @@ -153,6 +215,7 @@ class AIAssistantService { const initPrompt = generateQuestionsAnswersPrompt(userQuestionsWithAnswers); const threadId = await this.openAI.createThread([initPrompt]); + await this.userService.saveThreadId(user.id, threadId); const userScoresPrompt = generateUserScoresPrompt(userWheelBalanceScores); await this.openAI.addMessageToThread(threadId, userScoresPrompt); @@ -164,13 +227,32 @@ class AIAssistantService { public async suggestTasksForCategories( body: AIAssistantSuggestTaskRequestDto, + user: UserDto, ): Promise { - const { categories, threadId } = body; + const { categories, messages } = body; + const threadId = user.threadId as string; + + await this.chatMessageService.saveAllTextMessages(messages, threadId); + const runThreadOptions = runSuggestTaskByCategoryOptions(categories); const result = await this.openAI.runThread(threadId, runThreadOptions); - return generateTasksSuggestionsResponse(result); + const response = generateTasksSuggestionsResponse(result); + + if (!response) { + return null; + } + + const responseMessages: ChatMessageDto[] = []; + + for (const message of response) { + responseMessages.push(await this.chatMessageService.create(message)); + } + + return { + messages: responseMessages, + }; } } diff --git a/apps/backend/src/modules/ai-assistant/ai-assistant.ts b/apps/backend/src/modules/ai-assistant/ai-assistant.ts index 7186c82be..9cc42822d 100644 --- a/apps/backend/src/modules/ai-assistant/ai-assistant.ts +++ b/apps/backend/src/modules/ai-assistant/ai-assistant.ts @@ -1,17 +1,21 @@ import { logger } from "~/libs/modules/logger/logger.js"; import { openAI } from "~/libs/modules/open-ai/open-ai.js"; import { categoryService } from "~/modules/categories/categories.js"; +import { chatMessageService } from "~/modules/chat-message/chat-messages.js"; import { onboardingRepository } from "~/modules/onboarding/onboarding.js"; import { taskService } from "~/modules/tasks/tasks.js"; +import { userService } from "~/modules/users/users.js"; import { AIAssistantController } from "./ai-assistant.controller.js"; import { AIAssistantService } from "./ai-assistant.service.js"; const aiAssistantService = new AIAssistantService({ categoryService, + chatMessageService, onboardingRepository, openAI, taskService, + userService, }); const aiAssistantController = new AIAssistantController( diff --git a/apps/backend/src/modules/ai-assistant/libs/helpers/change-tasks/generate-change-task-suggestions-response.helper.ts b/apps/backend/src/modules/ai-assistant/libs/helpers/change-tasks/generate-change-task-suggestions-response.helper.ts index 47d32b439..c4f97a7b8 100644 --- a/apps/backend/src/modules/ai-assistant/libs/helpers/change-tasks/generate-change-task-suggestions-response.helper.ts +++ b/apps/backend/src/modules/ai-assistant/libs/helpers/change-tasks/generate-change-task-suggestions-response.helper.ts @@ -13,17 +13,14 @@ import { HTTPCode, } from "../../enums/enums.js"; import { OpenAIError } from "../../exceptions/exceptions.js"; -import { - type AIAssistantResponseDto, - type ChatMessageDto, -} from "../../types/types.js"; +import { type ChatMessageCreateDto } from "../../types/types.js"; import { type changeTasksByCategory } from "./change-task.validation-schema.js"; type TaskByCategoryData = z.infer; const generateChangeTasksSuggestionsResponse = ( aiResponse: OpenAIResponseMessage, -): AIAssistantResponseDto | null => { +): ChatMessageCreateDto[] | null => { const message = aiResponse.getPaginatedItems().shift(); if (!message) { @@ -40,39 +37,34 @@ const generateChangeTasksSuggestionsResponse = ( contentText, ) as TaskByCategoryData; - const textMessage: ChatMessageDto = { + const textMessage: ChatMessageCreateDto = { author: ChatMessageAuthor.ASSISTANT, - createdAt: new Date().toISOString(), - id: FIRST_ITEM_INDEX, - isRead: false, payload: { text: resultData.message, }, + threadId: message.thread_id, type: ChatMessageType.TEXT, }; - const taskMessages: ChatMessageDto[] = resultData.tasks.map((task) => { - return { - author: ChatMessageAuthor.ASSISTANT, - createdAt: new Date().toISOString(), - id: FIRST_ITEM_INDEX, - isRead: false, - payload: { - task: { - categoryId: task.categoryId, - categoryName: task.categoryName, - description: task.description, - label: task.label, + const taskMessages: ChatMessageCreateDto[] = resultData.tasks.map( + (task) => { + return { + author: ChatMessageAuthor.ASSISTANT, + payload: { + task: { + categoryId: task.categoryId, + categoryName: task.categoryName, + description: task.description, + label: task.label, + }, }, - }, - type: ChatMessageType.TASK, - }; - }); + threadId: message.thread_id, + type: ChatMessageType.TASK, + }; + }, + ); - return { - messages: [textMessage, ...taskMessages], - threadId: message.thread_id, - }; + return [textMessage, ...taskMessages]; } catch { throw new OpenAIError({ message: OpenAIErrorMessage.WRONG_RESPONSE, diff --git a/apps/backend/src/modules/ai-assistant/libs/helpers/explain-tasks/generate-explain-tasks-suggestion-response.helper.ts b/apps/backend/src/modules/ai-assistant/libs/helpers/explain-tasks/generate-explain-tasks-suggestion-response.helper.ts index 7a360ac39..e7ed7c9a6 100644 --- a/apps/backend/src/modules/ai-assistant/libs/helpers/explain-tasks/generate-explain-tasks-suggestion-response.helper.ts +++ b/apps/backend/src/modules/ai-assistant/libs/helpers/explain-tasks/generate-explain-tasks-suggestion-response.helper.ts @@ -5,6 +5,7 @@ import { AIAssistantMessageValidationSchema, OpenAIErrorMessage, type OpenAIResponseMessage, + OpenAIRoleKey, } from "~/libs/modules/open-ai/open-ai.js"; import { @@ -13,17 +14,14 @@ import { HTTPCode, } from "../../enums/enums.js"; import { OpenAIError } from "../../exceptions/exceptions.js"; -import { - type AIAssistantResponseDto, - type ChatMessageDto, -} from "../../types/types.js"; +import { type ChatMessageCreateDto } from "../../types/types.js"; import { type explainTasks } from "./explain-tasks.validation-schema.js"; type TaskByCategoryData = z.infer; const generateExplainTasksSuggestionsResponse = ( aiResponse: OpenAIResponseMessage, -): AIAssistantResponseDto | null => { +): ChatMessageCreateDto[] | null => { const message = aiResponse.getPaginatedItems().shift(); if (!message) { @@ -39,40 +37,35 @@ const generateExplainTasksSuggestionsResponse = ( contentText, ) as TaskByCategoryData; - const textMessage: ChatMessageDto = { - author: ChatMessageAuthor.ASSISTANT, - createdAt: new Date().toISOString(), - id: FIRST_ITEM_INDEX, - isRead: false, + const textMessage: ChatMessageCreateDto = { + author: OpenAIRoleKey.ASSISTANT, payload: { text: resultData.message, }, + threadId: message.thread_id, type: ChatMessageType.TEXT, }; - const tasksMessages: ChatMessageDto[] = resultData.tasks.map((task) => { - return { - author: ChatMessageAuthor.ASSISTANT, - createdAt: new Date().toISOString(), - id: FIRST_ITEM_INDEX, - isRead: false, - payload: { - task: { - categoryId: task.categoryId, - categoryName: task.categoryName, - description: task.description, - label: task.label, + const tasksMessages: ChatMessageCreateDto[] = resultData.tasks.map( + (task) => { + return { + author: ChatMessageAuthor.ASSISTANT, + payload: { + task: { + categoryId: task.categoryId, + categoryName: task.categoryName, + description: task.description, + label: task.label, + }, + text: task.explanation, }, - text: task.explanation, - }, - type: ChatMessageType.TASK, - }; - }); + threadId: message.thread_id, + type: ChatMessageType.TASK, + }; + }, + ); - return { - messages: [textMessage, ...tasksMessages], - threadId: message.thread_id, - }; + return [textMessage, ...tasksMessages]; } catch { throw new OpenAIError({ message: OpenAIErrorMessage.WRONG_RESPONSE, diff --git a/apps/backend/src/modules/ai-assistant/libs/helpers/suggest-task-by-category/generate-suggest-task-response.helper.ts b/apps/backend/src/modules/ai-assistant/libs/helpers/suggest-task-by-category/generate-suggest-task-response.helper.ts index ab6162cdf..8517aac8b 100644 --- a/apps/backend/src/modules/ai-assistant/libs/helpers/suggest-task-by-category/generate-suggest-task-response.helper.ts +++ b/apps/backend/src/modules/ai-assistant/libs/helpers/suggest-task-by-category/generate-suggest-task-response.helper.ts @@ -13,17 +13,14 @@ import { HTTPCode, } from "../../enums/enums.js"; import { OpenAIError } from "../../exceptions/exceptions.js"; -import { - type AIAssistantResponseDto, - type ChatMessageDto, -} from "../../types/types.js"; +import { type ChatMessageCreateDto } from "../../types/types.js"; import { type taskByCategory } from "./suggest-task-by-category.validation-schema.js"; type TaskByCategoryData = z.infer; const generateTasksSuggestionsResponse = ( aiResponse: OpenAIResponseMessage, -): AIAssistantResponseDto | null => { +): ChatMessageCreateDto[] | null => { const message = aiResponse.getPaginatedItems().shift(); if (!message) { @@ -39,39 +36,34 @@ const generateTasksSuggestionsResponse = ( contentText, ) as TaskByCategoryData; - const textMessage: ChatMessageDto = { + const textMessage: ChatMessageCreateDto = { author: ChatMessageAuthor.ASSISTANT, - createdAt: new Date().toISOString(), - id: FIRST_ITEM_INDEX, - isRead: false, payload: { text: resultData.message, }, + threadId: message.thread_id, type: ChatMessageType.TEXT, }; - const taskMessages: ChatMessageDto[] = resultData.tasks.map((task) => { - return { - author: ChatMessageAuthor.ASSISTANT, - createdAt: new Date().toISOString(), - id: FIRST_ITEM_INDEX, - isRead: false, - payload: { - task: { - categoryId: task.categoryId, - categoryName: task.categoryName, - description: task.description, - label: task.label, + const taskMessages: ChatMessageCreateDto[] = resultData.tasks.map( + (task) => { + return { + author: ChatMessageAuthor.ASSISTANT, + payload: { + task: { + categoryId: task.categoryId, + categoryName: task.categoryName, + description: task.description, + label: task.label, + }, }, - }, - type: ChatMessageType.TASK, - }; - }); + threadId: message.thread_id, + type: ChatMessageType.TASK, + }; + }, + ); - return { - messages: [textMessage, ...taskMessages], - threadId: message.thread_id, - }; + return [textMessage, ...taskMessages]; } catch { throw new OpenAIError({ message: OpenAIErrorMessage.WRONG_RESPONSE, diff --git a/apps/backend/src/modules/ai-assistant/libs/types/types.ts b/apps/backend/src/modules/ai-assistant/libs/types/types.ts index a13206d49..cddf6a69e 100644 --- a/apps/backend/src/modules/ai-assistant/libs/types/types.ts +++ b/apps/backend/src/modules/ai-assistant/libs/types/types.ts @@ -1,8 +1,12 @@ export { + type AIAssistantAcceptTaskRequestDto, + type AIAssistantChangeTaskRequestDto, + type AIAssistantChatInitializeResponseDto, type AIAssistantCreateMultipleTasksDto, - type AIAssistantRequestDto, + type AIAssistantExplainTaskRequestDto, type AIAssistantResponseDto, type AIAssistantSuggestTaskRequestDto, + type ChatMessageCreateDto, type ChatMessageDto, type SelectedCategory, type TaskCreateDto, diff --git a/apps/backend/src/modules/chat-message/chat-message.entity.ts b/apps/backend/src/modules/chat-message/chat-message.entity.ts new file mode 100644 index 000000000..ea14c6c3c --- /dev/null +++ b/apps/backend/src/modules/chat-message/chat-message.entity.ts @@ -0,0 +1,175 @@ +import { type Entity, type ValueOf } from "~/libs/types/types.js"; + +import { type ChatMessageAuthor, ChatMessageType } from "./libs/enums/enums.js"; +import { + type TaskCreateDto, + type TaskDto, + type TaskMessage, + type TextMessage, +} from "./libs/types/types.js"; + +class ChatMessageEntity implements Entity { + private author: ValueOf; + + private createdAt: string; + + private id: null | number; + + private isRead: boolean; + + private payload: TaskMessage | TextMessage; + + private threadId: string; + + private type: ValueOf; + + private updatedAt: string; + + private constructor({ + author, + createdAt, + id, + isRead, + payload, + threadId, + type, + updatedAt, + }: { + author: ValueOf; + createdAt: string; + id: null | number; + isRead: boolean; + payload: TaskMessage | TextMessage; + threadId: string; + type: ValueOf; + updatedAt: string; + }) { + this.author = author; + this.id = id; + this.createdAt = createdAt; + this.isRead = isRead; + this.payload = payload; + this.threadId = threadId; + this.type = type; + this.updatedAt = updatedAt; + } + + public static initialize({ + author, + createdAt, + id, + isRead, + task, + text, + threadId, + type, + updatedAt, + }: { + author: ValueOf; + createdAt: string; + id: null | number; + isRead: boolean; + task: null | TaskCreateDto | TaskDto; + text: null | string; + threadId: string; + type: ValueOf; + updatedAt: string; + }): ChatMessageEntity { + if (type === ChatMessageType.TASK) { + const payload: TaskMessage = { task: task as TaskCreateDto }; + + if (text) { + payload.text = text; + } + + return new ChatMessageEntity({ + author, + createdAt, + id, + isRead, + payload, + threadId, + type, + updatedAt, + }); + } + + return new ChatMessageEntity({ + author, + createdAt, + id, + isRead, + payload: { text: text as string }, + threadId, + type, + updatedAt, + }); + } + + public static initializeNew({ + author, + payload, + threadId, + type, + }: { + author: ValueOf; + payload: TaskMessage | TextMessage; + threadId: string; + type: ValueOf; + }): ChatMessageEntity { + return new ChatMessageEntity({ + author, + createdAt: "", + id: null, + isRead: false, + payload, + threadId, + type, + updatedAt: "", + }); + } + + public toNewObject(): { + author: ValueOf; + createdAt: string; + isRead: boolean; + payload: TaskMessage | TextMessage; + threadId: string; + type: ValueOf; + updatedAt: string; + } { + return { + author: this.author, + createdAt: this.createdAt, + isRead: this.isRead, + payload: this.payload, + threadId: this.threadId, + type: this.type, + updatedAt: this.updatedAt, + }; + } + + public toObject(): { + author: ValueOf; + createdAt: string; + id: null | number; + isRead: boolean; + payload: TaskMessage | TextMessage; + threadId: string; + type: ValueOf; + updatedAt: string; + } { + return { + author: this.author, + createdAt: this.createdAt, + id: this.id as number, + isRead: this.isRead, + payload: this.payload, + threadId: this.threadId, + type: this.type, + updatedAt: this.updatedAt, + }; + } +} + +export { ChatMessageEntity }; diff --git a/apps/backend/src/modules/chat-message/chat-message.model.ts b/apps/backend/src/modules/chat-message/chat-message.model.ts new file mode 100644 index 000000000..484d70430 --- /dev/null +++ b/apps/backend/src/modules/chat-message/chat-message.model.ts @@ -0,0 +1,47 @@ +import { Model, type RelationMappings } from "objection"; + +import { + AbstractModel, + DatabaseTableName, +} from "~/libs/modules/database/database.js"; +import { type ValueOf } from "~/libs/types/types.js"; +import { UserDetailsModel } from "~/modules/users/user-details.model.js"; + +import { + type ChatMessageAuthor, + type ChatMessageType, +} from "./libs/enums/enums.js"; +import { type TaskCreateDto, type TaskDto } from "./libs/types/types.js"; + +class ChatMessageModel extends AbstractModel { + public author!: ValueOf; + + public isRead!: boolean; + + public task!: null | TaskCreateDto | TaskDto; + + public text!: null | string; + + public threadId!: string; + + public type!: ValueOf; + + static get relationMappings(): RelationMappings { + return { + userDetails: { + join: { + from: `${DatabaseTableName.CHAT_MESSAGES}.threadId`, + to: `${DatabaseTableName.USER_DETAILS}.threadId`, + }, + modelClass: UserDetailsModel, + relation: Model.HasOneRelation, + }, + }; + } + + public static override get tableName(): string { + return DatabaseTableName.CHAT_MESSAGES; + } +} + +export { ChatMessageModel }; diff --git a/apps/backend/src/modules/chat-message/chat-message.repository.ts b/apps/backend/src/modules/chat-message/chat-message.repository.ts new file mode 100644 index 000000000..cc2bec265 --- /dev/null +++ b/apps/backend/src/modules/chat-message/chat-message.repository.ts @@ -0,0 +1,141 @@ +import { type Repository } from "~/libs/types/types.js"; + +import { ChatMessageEntity } from "./chat-message.entity.js"; +import { type ChatMessageModel } from "./chat-message.model.js"; +import { ChatMessageType, SortOrder } from "./libs/enums/enums.js"; +import { type TaskMessage, type TextMessage } from "./libs/types/types.js"; + +class ChatMessageRepository implements Repository { + private chatMessageModel: typeof ChatMessageModel; + + constructor(chatMessageModel: typeof ChatMessageModel) { + this.chatMessageModel = chatMessageModel; + } + + public async create(entity: ChatMessageEntity): Promise { + const { author, isRead, payload, threadId, type } = entity.toObject(); + + const insertData: Partial = { + author, + isRead, + threadId, + type, + }; + + if (type === ChatMessageType.TASK) { + const { task, text } = payload as TaskMessage; + insertData.task = task; + + if (text) { + insertData.text = text; + } + } + + if (type === ChatMessageType.TEXT) { + insertData.text = (payload as TextMessage).text; + } + + const chatMessage = await this.chatMessageModel + .query() + .insert(insertData) + .returning("*"); + + return ChatMessageEntity.initialize({ + author: chatMessage.author, + createdAt: chatMessage.createdAt, + id: chatMessage.id, + isRead: chatMessage.isRead, + task: chatMessage.task, + text: chatMessage.text, + threadId: chatMessage.threadId, + type: chatMessage.type, + updatedAt: chatMessage.updatedAt, + }); + } + + public async delete(id: number): Promise { + const rowsDeleted = await this.chatMessageModel.query().deleteById(id); + + return Boolean(rowsDeleted); + } + + public async find(id: number): Promise { + const chatMessage = await this.chatMessageModel.query().findById(id); + + return chatMessage + ? ChatMessageEntity.initialize({ + author: chatMessage.author, + createdAt: chatMessage.createdAt, + id: chatMessage.id, + isRead: chatMessage.isRead, + task: chatMessage.task, + text: chatMessage.text, + threadId: chatMessage.threadId, + type: chatMessage.type, + updatedAt: chatMessage.updatedAt, + }) + : null; + } + + public async findAll(): Promise { + const chatMessages = await this.chatMessageModel.query(); + + return chatMessages.map((chatMessage) => { + return ChatMessageEntity.initialize({ + author: chatMessage.author, + createdAt: chatMessage.createdAt, + id: chatMessage.id, + isRead: chatMessage.isRead, + task: chatMessage.task, + text: chatMessage.text, + threadId: chatMessage.threadId, + type: chatMessage.type, + updatedAt: chatMessage.updatedAt, + }); + }); + } + + public async findByThreadId(threadId: string): Promise { + const chatMessages = await this.chatMessageModel + .query() + .where({ threadId }) + .orderBy("id", SortOrder.ASC); + + return chatMessages.map((chatMessage) => { + return ChatMessageEntity.initialize({ + author: chatMessage.author, + createdAt: chatMessage.createdAt, + id: chatMessage.id, + isRead: chatMessage.isRead, + task: chatMessage.task, + text: chatMessage.text, + threadId: chatMessage.threadId, + type: chatMessage.type, + updatedAt: chatMessage.updatedAt, + }); + }); + } + + public async update( + id: number, + payload: Partial, + ): Promise { + const chatMessage = await this.chatMessageModel + .query() + .patchAndFetchById(id, payload); + + return ChatMessageEntity.initialize({ + author: chatMessage.author, + createdAt: chatMessage.createdAt, + id: chatMessage.id, + isRead: chatMessage.isRead, + task: chatMessage.task, + text: chatMessage.text, + threadId: chatMessage.threadId, + type: chatMessage.type, + updatedAt: chatMessage.updatedAt, + }); + } +} + +export { ChatMessageRepository }; diff --git a/apps/backend/src/modules/chat-message/chat-message.service.ts b/apps/backend/src/modules/chat-message/chat-message.service.ts new file mode 100644 index 000000000..445a7a16a --- /dev/null +++ b/apps/backend/src/modules/chat-message/chat-message.service.ts @@ -0,0 +1,88 @@ +import { type SaveTextMessageDto } from "shared/src/modules/ai-assistant/libs/types/ai-assistant-save-text-message-dto.type.js"; + +import { type Service } from "~/libs/types/types.js"; + +import { ChatMessageEntity } from "./chat-message.entity.js"; +import { type ChatMessageRepository } from "./chat-message.repository.js"; +import { ChatMessageType } from "./libs/enums/enums.js"; +import { + type ChatMessageCreateDto, + type ChatMessageDto, +} from "./libs/types/types.js"; + +class ChatMessageService implements Service { + private chatMessageRepository: ChatMessageRepository; + + public constructor(chatMessageRepository: ChatMessageRepository) { + this.chatMessageRepository = chatMessageRepository; + } + + public async create(payload: ChatMessageCreateDto): Promise { + const chatMessage = await this.chatMessageRepository.create( + ChatMessageEntity.initializeNew({ + author: payload.author, + payload: payload.payload, + threadId: payload.threadId, + type: payload.type, + }), + ); + + return chatMessage.toObject() as ChatMessageDto; + } + + public async delete(id: number): Promise { + return await this.chatMessageRepository.delete(id); + } + + public async find(id: number): Promise { + const chatMessage = await this.chatMessageRepository.find(id); + + return chatMessage?.toObject() as ChatMessageDto; + } + + public async findAll(): Promise<{ items: ChatMessageDto[] }> { + const chatMessages = await this.chatMessageRepository.findAll(); + + return { + items: chatMessages.map((chatMessage) => { + return chatMessage.toObject() as ChatMessageDto; + }), + }; + } + + public async findByThreadId(threadId: string): Promise { + const chatMessages = + await this.chatMessageRepository.findByThreadId(threadId); + + return chatMessages.map((chatMessage) => { + return chatMessage.toObject() as ChatMessageDto; + }); + } + + public async saveAllTextMessages( + messages: SaveTextMessageDto[], + threadId: string, + ): Promise { + for (const message of messages) { + await this.create({ + author: message.author, + payload: { + text: message.text, + }, + threadId, + type: ChatMessageType.TEXT, + }); + } + } + + public async update( + id: number, + payload: Partial, + ): Promise { + const chatMessage = await this.chatMessageRepository.update(id, payload); + + return chatMessage.toObject() as ChatMessageDto; + } +} + +export { ChatMessageService }; diff --git a/apps/backend/src/modules/chat-message/chat-messages.ts b/apps/backend/src/modules/chat-message/chat-messages.ts new file mode 100644 index 000000000..63503c099 --- /dev/null +++ b/apps/backend/src/modules/chat-message/chat-messages.ts @@ -0,0 +1,11 @@ +import { ChatMessageModel } from "./chat-message.model.js"; +import { ChatMessageRepository } from "./chat-message.repository.js"; +import { ChatMessageService } from "./chat-message.service.js"; + +const chatMessageRepository = new ChatMessageRepository(ChatMessageModel); +const chatMessageService = new ChatMessageService(chatMessageRepository); + +export { chatMessageService }; +export { ChatMessageEntity } from "./chat-message.entity.js"; +export { ChatMessageModel } from "./chat-message.model.js"; +export { type ChatMessageService } from "./chat-message.service.js"; diff --git a/apps/backend/src/modules/chat-message/libs/enums/enums.ts b/apps/backend/src/modules/chat-message/libs/enums/enums.ts new file mode 100644 index 000000000..271de4cab --- /dev/null +++ b/apps/backend/src/modules/chat-message/libs/enums/enums.ts @@ -0,0 +1 @@ +export { ChatMessageAuthor, ChatMessageType, SortOrder } from "shared"; diff --git a/apps/backend/src/modules/chat-message/libs/types/types.ts b/apps/backend/src/modules/chat-message/libs/types/types.ts new file mode 100644 index 000000000..524e70a8f --- /dev/null +++ b/apps/backend/src/modules/chat-message/libs/types/types.ts @@ -0,0 +1,8 @@ +export { + type ChatMessageCreateDto, + type ChatMessageDto, + type TaskCreateDto, + type TaskDto, + type TaskMessage, + type TextMessage, +} from "shared"; diff --git a/apps/backend/src/modules/users/user-details.model.ts b/apps/backend/src/modules/users/user-details.model.ts index 1c436d8fb..b1218ca83 100644 --- a/apps/backend/src/modules/users/user-details.model.ts +++ b/apps/backend/src/modules/users/user-details.model.ts @@ -17,6 +17,8 @@ class UserDetailsModel extends AbstractModel { public notificationFrequency!: ValueOf; + public threadId!: string; + public userId!: number; static get relationMappings(): RelationMappings { diff --git a/apps/backend/src/modules/users/user.entity.ts b/apps/backend/src/modules/users/user.entity.ts index 44c74fbe4..6dd42c911 100644 --- a/apps/backend/src/modules/users/user.entity.ts +++ b/apps/backend/src/modules/users/user.entity.ts @@ -27,6 +27,8 @@ class UserEntity implements Entity { private passwordSalt: string; + private threadId: null | string; + private updatedAt: string; private userTaskDays: null | number[]; @@ -44,6 +46,7 @@ class UserEntity implements Entity { notificationFrequency, passwordHash, passwordSalt, + threadId, updatedAt, userTaskDays, }: { @@ -59,6 +62,7 @@ class UserEntity implements Entity { notificationFrequency: ValueOf; passwordHash: string; passwordSalt: string; + threadId: null | string; updatedAt: string; userTaskDays: null | number[]; }) { @@ -76,6 +80,7 @@ class UserEntity implements Entity { this.completionTasksPercentage = completionTasksPercentage; this.updatedAt = updatedAt; this.userTaskDays = userTaskDays; + this.threadId = threadId; } public static initialize({ @@ -91,6 +96,7 @@ class UserEntity implements Entity { notificationFrequency, passwordHash, passwordSalt, + threadId, updatedAt, userTaskDays, }: { @@ -106,6 +112,7 @@ class UserEntity implements Entity { notificationFrequency?: ValueOf; passwordHash: string; passwordSalt: string; + threadId: null | string; updatedAt: string; userTaskDays?: number[]; }): UserEntity { @@ -124,6 +131,7 @@ class UserEntity implements Entity { notificationFrequency ?? NotificationFrequency.NONE, passwordHash, passwordSalt, + threadId, updatedAt, userTaskDays: userTaskDays ?? null, }); @@ -153,6 +161,7 @@ class UserEntity implements Entity { notificationFrequency: NotificationFrequency.NONE, passwordHash, passwordSalt, + threadId: null, updatedAt: "", userTaskDays: null, }); @@ -170,6 +179,7 @@ class UserEntity implements Entity { notificationFrequency: ValueOf; passwordHash: string; passwordSalt: string; + threadId: null | string; updatedAt: string; userTaskDays: number[]; } { @@ -186,6 +196,7 @@ class UserEntity implements Entity { notificationFrequency: this.notificationFrequency, passwordHash: this.passwordHash, passwordSalt: this.passwordSalt, + threadId: this.threadId, updatedAt: this.updatedAt, userTaskDays: this.userTaskDays ?? [], }; @@ -202,6 +213,7 @@ class UserEntity implements Entity { id: number; name: string; notificationFrequency: ValueOf; + threadId: null | string; updatedAt: string; userTaskDays: number[]; } { @@ -217,6 +229,7 @@ class UserEntity implements Entity { id: this.id as number, name: this.name, notificationFrequency: this.notificationFrequency, + threadId: this.threadId, updatedAt: this.updatedAt, userTaskDays: this.userTaskDays ?? [], }; diff --git a/apps/backend/src/modules/users/user.repository.ts b/apps/backend/src/modules/users/user.repository.ts index 8d2a13dba..8cca77669 100644 --- a/apps/backend/src/modules/users/user.repository.ts +++ b/apps/backend/src/modules/users/user.repository.ts @@ -64,6 +64,7 @@ class UserRepository implements Repository { notificationFrequency: "all", passwordHash: user.passwordHash, passwordSalt: user.passwordSalt, + threadId: null, updatedAt: user.updatedAt, userTaskDays: [], }); @@ -108,6 +109,7 @@ class UserRepository implements Repository { notificationFrequency: user.userDetails.notificationFrequency, passwordHash: user.passwordHash, passwordSalt: user.passwordSalt, + threadId: user.userDetails.threadId, updatedAt: user.updatedAt, userTaskDays: user.userTaskDays.map( (taskDay: UserTaskDay) => taskDay.dayOfWeek, @@ -139,6 +141,7 @@ class UserRepository implements Repository { notificationFrequency: user.userDetails.notificationFrequency, passwordHash: user.passwordHash, passwordSalt: user.passwordSalt, + threadId: user.userDetails.threadId, updatedAt: user.updatedAt, userTaskDays: user.userTaskDays.map( (taskDay: UserTaskDay) => taskDay.dayOfWeek, @@ -172,6 +175,7 @@ class UserRepository implements Repository { notificationFrequency: user.userDetails.notificationFrequency, passwordHash: user.passwordHash, passwordSalt: user.passwordSalt, + threadId: user.userDetails.threadId, updatedAt: user.updatedAt, userTaskDays: user.userTaskDays.map( (taskDay: UserTaskDay) => taskDay.dayOfWeek, @@ -215,6 +219,7 @@ class UserRepository implements Repository { notificationFrequency: updatedUserDetails.notificationFrequency, passwordHash: user.passwordHash, passwordSalt: user.passwordSalt, + threadId: updatedUserDetails.threadId, updatedAt: user.updatedAt, userTaskDays: user.userTaskDays.map( (taskDay: UserTaskDay) => taskDay.dayOfWeek, @@ -257,10 +262,11 @@ class UserRepository implements Repository { user.onboardingAnswers.length > ZERO_INDEX, hasAnsweredQuizQuestions: user.quizAnswers.length > ZERO_INDEX, id: user.id, - name: user.userDetails.name, + name: userDetails.name, notificationFrequency: userDetails.notificationFrequency, passwordHash: user.passwordHash, passwordSalt: user.passwordSalt, + threadId: user.userDetails.threadId, updatedAt: user.updatedAt, userTaskDays: user.userTaskDays.map( (taskDay: UserTaskDay) => taskDay.dayOfWeek, @@ -296,6 +302,7 @@ class UserRepository implements Repository { notificationFrequency: user.userDetails.notificationFrequency, passwordHash: user.passwordHash, passwordSalt: user.passwordSalt, + threadId: user.userDetails.threadId, updatedAt: user.updatedAt, userTaskDays: user.userTaskDays.map( (taskDay: UserTaskDay) => taskDay.dayOfWeek, diff --git a/apps/backend/src/modules/users/user.service.ts b/apps/backend/src/modules/users/user.service.ts index 7841618e1..0595757fa 100644 --- a/apps/backend/src/modules/users/user.service.ts +++ b/apps/backend/src/modules/users/user.service.ts @@ -98,6 +98,10 @@ class UserService implements Service { return user?.toObject() ?? null; } + public async saveThreadId(id: number, threadId: string): Promise { + await this.userRepository.update(id, { threadId }); + } + public async update( id: number, payload: UserUpdateRequestDto, diff --git a/apps/frontend/src/libs/components/header/styles.module.css b/apps/frontend/src/libs/components/header/styles.module.css index a6bdb0f03..78dca1a22 100644 --- a/apps/frontend/src/libs/components/header/styles.module.css +++ b/apps/frontend/src/libs/components/header/styles.module.css @@ -1,7 +1,7 @@ .header { position: sticky; top: 0; - z-index: var(--z-index-surface); + z-index: var(--z-index-high); display: flex; align-items: center; justify-content: flex-end; diff --git a/apps/frontend/src/modules/chat/chat-api.ts b/apps/frontend/src/modules/chat/chat-api.ts index 4087f9d60..f566bcf32 100644 --- a/apps/frontend/src/modules/chat/chat-api.ts +++ b/apps/frontend/src/modules/chat/chat-api.ts @@ -4,8 +4,9 @@ import { type APIConfiguration } from "~/libs/types/types.js"; import { AIAssistantApiPath } from "./libs/enums/enums.js"; import { + type AIAssistantChangeTaskRequestDto, + type AIAssistantChatInitializeResponseDto, type AIAssistantCreateMultipleTasksDto, - type AIAssistantRequestDto, type AIAssistantResponseDto, type AIAssistantSuggestTaskRequestDto, } from "./libs/types/types.js"; @@ -16,7 +17,7 @@ class ChatApi extends BaseHTTPApi { } public async changeTasksSuggestion( - payload: AIAssistantRequestDto, + payload: AIAssistantChangeTaskRequestDto, ): Promise { const response = await this.load( this.getFullEndpoint(AIAssistantApiPath.CHAT_CHANGE_TASKS, {}), @@ -63,7 +64,7 @@ class ChatApi extends BaseHTTPApi { return await response.json(); } - public async initiateConversation(): Promise { + public async initiateConversation(): Promise { const response = await this.load( this.getFullEndpoint(AIAssistantApiPath.CHAT_INITIALIZE, {}), { @@ -74,7 +75,7 @@ class ChatApi extends BaseHTTPApi { }, ); - return await response.json(); + return await response.json(); } } diff --git a/apps/frontend/src/modules/chat/libs/types/types.ts b/apps/frontend/src/modules/chat/libs/types/types.ts index bcf4ebe37..0e1e2a693 100644 --- a/apps/frontend/src/modules/chat/libs/types/types.ts +++ b/apps/frontend/src/modules/chat/libs/types/types.ts @@ -1,6 +1,7 @@ export { + type AIAssistantChangeTaskRequestDto, + type AIAssistantChatInitializeResponseDto, type AIAssistantCreateMultipleTasksDto, - type AIAssistantRequestDto, type AIAssistantResponseDto, type AIAssistantSuggestTaskRequestDto, type ChangeTasksSuggestionPayload, diff --git a/apps/frontend/src/modules/chat/slices/actions.ts b/apps/frontend/src/modules/chat/slices/actions.ts index 8799541e3..96b149c8d 100644 --- a/apps/frontend/src/modules/chat/slices/actions.ts +++ b/apps/frontend/src/modules/chat/slices/actions.ts @@ -4,8 +4,8 @@ import { type AsyncThunkConfig } from "~/libs/types/types.js"; import { processMessages } from "../libs/helpers/helpers.js"; import { + type AIAssistantChatInitializeResponseDto, type AIAssistantCreateMultipleTasksDto, - type AIAssistantResponseDto, type AIAssistantSuggestTaskRequestDto, type ChangeTasksSuggestionPayload, type ProcessedMessagesAndSuggestions, @@ -13,7 +13,7 @@ import { import { name as sliceName } from "./chat.slice.js"; const initConversation = createAsyncThunk< - AIAssistantResponseDto, + AIAssistantChatInitializeResponseDto, undefined, AsyncThunkConfig >(`${sliceName}/init-conversation`, async (_, { extra }) => { diff --git a/apps/frontend/src/modules/chat/slices/chat.slice.ts b/apps/frontend/src/modules/chat/slices/chat.slice.ts index 075003a8d..84bb80702 100644 --- a/apps/frontend/src/modules/chat/slices/chat.slice.ts +++ b/apps/frontend/src/modules/chat/slices/chat.slice.ts @@ -1,13 +1,13 @@ import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; -import { DataStatus } from "~/libs/enums/enums.js"; +import { DataStatus, NumericalValue } from "~/libs/enums/enums.js"; import { type ValueOf } from "~/libs/types/types.js"; import { type SelectedCategory } from "~/modules/categories/categories.js"; import { ChatMessageAuthor, ChatMessageType } from "~/modules/chat/chat.js"; import { type TaskCreateDto } from "~/modules/tasks/tasks.js"; import { ButtonsModeOption } from "~/pages/chat/libs/enums/enums.js"; -import { type ChatMessage } from "../libs/types/types.js"; +import { type ChatMessage, type TaskMessage } from "../libs/types/types.js"; import { changeTasksSuggestion, createTasksFromSuggestions, @@ -51,10 +51,40 @@ const { actions, name, reducer } = createSlice({ state.dataStatus = DataStatus.PENDING; }) .addCase(initConversation.fulfilled, (state, action) => { - state.threadId = action.payload.threadId; - state.buttonsMode = ButtonsModeOption.SUGGESTIONS_CREATION; state.dataStatus = DataStatus.FULFILLED; + state.threadId = action.payload.threadId; + state.messages = []; + + const { messages } = action.payload; + let taskBuffer: TaskMessage[] = []; + + for (const message of messages) { + if (message.type === ChatMessageType.TASK) { + taskBuffer.push(message.payload as TaskMessage); + } else { + if (taskBuffer.length > NumericalValue.ZERO) { + state.messages.push({ + author: ChatMessageAuthor.ASSISTANT, + isRead: true, + payload: taskBuffer, + type: ChatMessageType.TASK, + }); + } + + taskBuffer = []; + state.messages.push(message as ChatMessage); + } + } + + if (taskBuffer.length > NumericalValue.ZERO) { + state.messages.push({ + author: ChatMessageAuthor.ASSISTANT, + isRead: true, + payload: taskBuffer, + type: ChatMessageType.TASK, + }); + } }) .addCase(initConversation.rejected, (state) => { state.dataStatus = DataStatus.REJECTED; diff --git a/apps/frontend/src/pages/chat/libs/components/buttons-controller/libs/components/categories-selector/categories-selector.tsx b/apps/frontend/src/pages/chat/libs/components/buttons-controller/libs/components/categories-selector/categories-selector.tsx index cce5eaaed..8c78cf243 100644 --- a/apps/frontend/src/pages/chat/libs/components/buttons-controller/libs/components/categories-selector/categories-selector.tsx +++ b/apps/frontend/src/pages/chat/libs/components/buttons-controller/libs/components/categories-selector/categories-selector.tsx @@ -4,7 +4,10 @@ import { useAppSelector, useCallback, } from "~/libs/hooks/hooks.js"; -import { actions as chatActions } from "~/modules/chat/chat.js"; +import { + actions as chatActions, + ChatMessageAuthor, +} from "~/modules/chat/chat.js"; import { ButtonsModeOption } from "~/pages/chat/libs/enums/enums.js"; import { @@ -14,9 +17,8 @@ import { import styles from "./styles.module.css"; const CategoriesSelector: React.FC = () => { - const { quizCategories, threadId } = useAppSelector((state) => ({ + const { quizCategories } = useAppSelector((state) => ({ quizCategories: state.categories.items, - threadId: state.chat.threadId, })); const dispatch = useAppDispatch(); @@ -30,24 +32,33 @@ const CategoriesSelector: React.FC = () => { })); dispatch(chatActions.updateSelectedCategories(newSelectedCategories)); + const text = `Suggest tasks for ${newSelectedCategories + .map((category) => { + return category.name; + }) + .join(", ")} categories`; + + dispatch(chatActions.setButtonsMode(ButtonsModeOption.NONE)); + dispatch(chatActions.addAssistantTextMessage(CATEGORIES_SELECTOR_TEXT)); + dispatch(chatActions.addUserTextMessage(text)); const taskPayload = { categories: newSelectedCategories, - threadId: threadId ?? "", + messages: [ + { + author: ChatMessageAuthor.ASSISTANT, + text: CATEGORIES_SELECTOR_TEXT, + }, + { + author: ChatMessageAuthor.USER, + text, + }, + ], }; - let namesOfSelectedCategories = ""; - - for (const category of newSelectedCategories) { - namesOfSelectedCategories += `${category.name}\n`; - } - - dispatch(chatActions.setButtonsMode(ButtonsModeOption.NONE)); - dispatch(chatActions.addAssistantTextMessage(CATEGORIES_SELECTOR_TEXT)); - dispatch(chatActions.addUserTextMessage(namesOfSelectedCategories)); await dispatch(chatActions.getTasksForCategories(taskPayload)); }, - [dispatch, quizCategories, threadId], + [dispatch, quizCategories], ); const handleFormSubmit = useCallback( diff --git a/apps/frontend/src/pages/chat/libs/components/buttons-controller/libs/components/dislike-suggetsions-options/dislike-suggestions-options.tsx b/apps/frontend/src/pages/chat/libs/components/buttons-controller/libs/components/dislike-suggetsions-options/dislike-suggestions-options.tsx index f6d2a79ec..3ca5f8795 100644 --- a/apps/frontend/src/pages/chat/libs/components/buttons-controller/libs/components/dislike-suggetsions-options/dislike-suggestions-options.tsx +++ b/apps/frontend/src/pages/chat/libs/components/buttons-controller/libs/components/dislike-suggetsions-options/dislike-suggestions-options.tsx @@ -4,18 +4,24 @@ import { useAppSelector, useCallback, } from "~/libs/hooks/hooks.js"; -import { actions as chatActions } from "~/modules/chat/chat.js"; +import { + actions as chatActions, + ChatMessageAuthor, +} from "~/modules/chat/chat.js"; import { ButtonsModeOption } from "~/pages/chat/libs/enums/enums.js"; +import { + SuggestionsManipulationButtonLabel, + SuggestionsManipulationMessage, +} from "../suggestions-manipulation-options/libs/enums/enums.js"; import { RegenerateSuggestionButton } from "./libs/components/components.js"; import { DISLIKE_ALL_SUGGESTIONS_BUTTON_LABEL } from "./libs/constants/constants.js"; import { DislikeSuggestionMessage } from "./libs/enums/enums.js"; import styles from "./styles.module.css"; const DislikeSuggestionsOptions: React.FC = () => { - const { taskSuggestions, threadId } = useAppSelector((state) => ({ + const { taskSuggestions } = useAppSelector((state) => ({ taskSuggestions: state.chat.taskSuggestions, - threadId: state.chat.threadId, })); const dispatch = useAppDispatch(); @@ -32,10 +38,34 @@ const DislikeSuggestionsOptions: React.FC = () => { dispatch(chatActions.setButtonsMode(ButtonsModeOption.NONE)); void dispatch( chatActions.changeTasksSuggestion({ - APIPayload: { payload: taskSuggestions, threadId: threadId as string }, + APIPayload: { + messages: [ + { + author: ChatMessageAuthor.ASSISTANT, + text: SuggestionsManipulationMessage.MAIN_MESSAGE, + }, + { + author: ChatMessageAuthor.USER, + text: SuggestionsManipulationButtonLabel.DISLIKE_TASKS, + }, + { + author: ChatMessageAuthor.ASSISTANT, + text: DislikeSuggestionMessage.MAIN, + }, + { + author: ChatMessageAuthor.USER, + text: DISLIKE_ALL_SUGGESTIONS_BUTTON_LABEL, + }, + { + author: ChatMessageAuthor.ASSISTANT, + text: DislikeSuggestionMessage.WAIT, + }, + ], + tasks: taskSuggestions, + }, }), ); - }, [dispatch, threadId, taskSuggestions]); + }, [dispatch, taskSuggestions]); return (
@@ -56,7 +86,6 @@ const DislikeSuggestionsOptions: React.FC = () => { label={`${suggestion.categoryName} sector task`} oldSuggestions={taskSuggestions} suggestion={suggestion} - threadId={threadId as string} /> ); })} diff --git a/apps/frontend/src/pages/chat/libs/components/buttons-controller/libs/components/dislike-suggetsions-options/libs/components/regenarate-suggestion-button/regenerate-suggestion-button.tsx b/apps/frontend/src/pages/chat/libs/components/buttons-controller/libs/components/dislike-suggetsions-options/libs/components/regenarate-suggestion-button/regenerate-suggestion-button.tsx index 38bd5bef3..8f04dc0e4 100644 --- a/apps/frontend/src/pages/chat/libs/components/buttons-controller/libs/components/dislike-suggetsions-options/libs/components/regenarate-suggestion-button/regenerate-suggestion-button.tsx +++ b/apps/frontend/src/pages/chat/libs/components/buttons-controller/libs/components/dislike-suggetsions-options/libs/components/regenarate-suggestion-button/regenerate-suggestion-button.tsx @@ -1,23 +1,28 @@ import { Button } from "~/libs/components/components.js"; import { useAppDispatch, useCallback } from "~/libs/hooks/hooks.js"; -import { actions as chatActions } from "~/modules/chat/chat.js"; +import { + actions as chatActions, + ChatMessageAuthor, +} from "~/modules/chat/chat.js"; import { type TaskCreateDto } from "~/modules/tasks/tasks.js"; import { ButtonsModeOption } from "~/pages/chat/libs/enums/enums.js"; +import { + SuggestionsManipulationButtonLabel, + SuggestionsManipulationMessage, +} from "../../../../suggestions-manipulation-options/libs/enums/enums.js"; import { DislikeSuggestionMessage } from "../../enums/enums.js"; type Properties = { label: string; oldSuggestions: TaskCreateDto[]; suggestion: TaskCreateDto; - threadId: string; }; const RegenerateSuggestionButton: React.FC = ({ label, oldSuggestions, suggestion, - threadId, }: Properties) => { const dispatch = useAppDispatch(); @@ -32,11 +37,35 @@ const RegenerateSuggestionButton: React.FC = ({ dispatch(chatActions.setButtonsMode(ButtonsModeOption.NONE)); void dispatch( chatActions.changeTasksSuggestion({ - APIPayload: { payload: [suggestion], threadId }, + APIPayload: { + messages: [ + { + author: ChatMessageAuthor.ASSISTANT, + text: SuggestionsManipulationMessage.MAIN_MESSAGE, + }, + { + author: ChatMessageAuthor.USER, + text: SuggestionsManipulationButtonLabel.DISLIKE_TASKS, + }, + { + author: ChatMessageAuthor.ASSISTANT, + text: DislikeSuggestionMessage.MAIN, + }, + { + author: ChatMessageAuthor.USER, + text: label, + }, + { + author: ChatMessageAuthor.ASSISTANT, + text: DislikeSuggestionMessage.WAIT, + }, + ], + tasks: [suggestion], + }, oldSuggestions, }), ); - }, [dispatch, threadId, label, suggestion, oldSuggestions]); + }, [dispatch, label, suggestion, oldSuggestions]); return (