use oenAi chat

This commit is contained in:
morteza-mortezai
2025-11-29 20:48:54 +03:30
parent 10f925d6b2
commit f955bf7d78
9 changed files with 417 additions and 211 deletions
+4 -1
View File
@@ -31,7 +31,6 @@
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.691.0", "@aws-sdk/client-s3": "^3.691.0",
"@faker-js/faker": "^7.6.0", "@faker-js/faker": "^7.6.0",
"openai": "^4.0.0",
"@nestjs/mapped-types": "^2.0.6", "@nestjs/mapped-types": "^2.0.6",
"ansi-colors": "^4.1.3", "ansi-colors": "^4.1.3",
"axios": "^1.7.7", "axios": "^1.7.7",
@@ -40,6 +39,7 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"compression": "^1.7.5", "compression": "^1.7.5",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"csv": "^6.3.11", "csv": "^6.3.11",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
@@ -60,6 +60,7 @@
"multer-s3": "^3.0.1", "multer-s3": "^3.0.1",
"node-cache": "^5.1.2", "node-cache": "^5.1.2",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",
"openai": "^4.0.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
@@ -67,6 +68,7 @@
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"strip-ansi": "6.0.1", "strip-ansi": "6.0.1",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"ulid": "^3.0.1",
"winston": "^3.17.0" "winston": "^3.17.0"
}, },
"devDependencies": { "devDependencies": {
@@ -74,6 +76,7 @@
"@commitlint/config-conventional": "^19.5.0", "@commitlint/config-conventional": "^19.5.0",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/compression": "^1.7.5", "@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.7",
+33
View File
@@ -38,6 +38,9 @@ importers:
compression: compression:
specifier: ^1.7.5 specifier: ^1.7.5
version: 1.7.5 version: 1.7.5
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
cors: cors:
specifier: ^2.8.5 specifier: ^2.8.5
version: 2.8.5 version: 2.8.5
@@ -122,6 +125,9 @@ importers:
swagger-ui-express: swagger-ui-express:
specifier: ^5.0.1 specifier: ^5.0.1
version: 5.0.1(express@4.21.1) version: 5.0.1(express@4.21.1)
ulid:
specifier: ^3.0.1
version: 3.0.1
winston: winston:
specifier: ^3.17.0 specifier: ^3.17.0
version: 3.17.0 version: 3.17.0
@@ -138,6 +144,9 @@ importers:
'@types/compression': '@types/compression':
specifier: ^1.7.5 specifier: ^1.7.5
version: 1.7.5 version: 1.7.5
'@types/cookie-parser':
specifier: ^1.4.10
version: 1.4.10(@types/express@4.17.21)
'@types/cors': '@types/cors':
specifier: ^2.8.17 specifier: ^2.8.17
version: 2.8.17 version: 2.8.17
@@ -1017,6 +1026,11 @@ packages:
'@types/conventional-commits-parser@5.0.0': '@types/conventional-commits-parser@5.0.0':
resolution: {integrity: sha512-loB369iXNmAZglwWATL+WRe+CRMmmBPtpolYzIebFaX4YA3x+BEfLqhUAV9WanycKI3TG1IMr5bMJDajDKLlUQ==} resolution: {integrity: sha512-loB369iXNmAZglwWATL+WRe+CRMmmBPtpolYzIebFaX4YA3x+BEfLqhUAV9WanycKI3TG1IMr5bMJDajDKLlUQ==}
'@types/cookie-parser@1.4.10':
resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==}
peerDependencies:
'@types/express': '*'
'@types/cookie@0.4.1': '@types/cookie@0.4.1':
resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==}
@@ -1528,6 +1542,10 @@ packages:
engines: {node: '>=16'} engines: {node: '>=16'}
hasBin: true hasBin: true
cookie-parser@1.4.7:
resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==}
engines: {node: '>= 0.8.0'}
cookie-signature@1.0.6: cookie-signature@1.0.6:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
@@ -3505,6 +3523,10 @@ packages:
resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==}
engines: {node: '>=8'} engines: {node: '>=8'}
ulid@3.0.1:
resolution: {integrity: sha512-dPJyqPzx8preQhqq24bBG1YNkvigm87K8kVEHCD+ruZg24t6IFEFv00xMWfxcC4djmFtiTLdFuADn4+DOz6R7Q==}
hasBin: true
unbox-primitive@1.0.2: unbox-primitive@1.0.2:
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
@@ -4987,6 +5009,10 @@ snapshots:
dependencies: dependencies:
'@types/node': 20.17.6 '@types/node': 20.17.6
'@types/cookie-parser@1.4.10(@types/express@4.17.21)':
dependencies:
'@types/express': 4.17.21
'@types/cookie@0.4.1': {} '@types/cookie@0.4.1': {}
'@types/cors@2.8.17': '@types/cors@2.8.17':
@@ -5610,6 +5636,11 @@ snapshots:
meow: 12.1.1 meow: 12.1.1
split2: 4.2.0 split2: 4.2.0
cookie-parser@1.4.7:
dependencies:
cookie: 0.7.2
cookie-signature: 1.0.6
cookie-signature@1.0.6: {} cookie-signature@1.0.6: {}
cookie@0.7.1: {} cookie@0.7.1: {}
@@ -7755,6 +7786,8 @@ snapshots:
dependencies: dependencies:
'@lukeed/csprng': 1.1.0 '@lukeed/csprng': 1.1.0
ulid@3.0.1: {}
unbox-primitive@1.0.2: unbox-primitive@1.0.2:
dependencies: dependencies:
call-bind: 1.0.7 call-bind: 1.0.7
+6
View File
@@ -1,4 +1,5 @@
import compression from "compression"; import compression from "compression";
import cookieParser from "cookie-parser";
import cors from "cors"; import cors from "cors";
import express, { Application } from "express"; import express, { Application } from "express";
import { Container } from "inversify"; import { Container } from "inversify";
@@ -121,6 +122,11 @@ class App {
*/ */
app.use(compression()); app.use(compression());
/**
* configure cookie parser
*/
app.use(cookieParser());
/** /**
* configure body parser * configure body parser
*/ */
@@ -1,17 +0,0 @@
import { Expose } from "class-transformer";
import { IsOptional, IsString, MaxLength } from "class-validator";
import { ApiProperty } from "../../../common/decorator/swggerDocs";
export class CreateChatSessionDto {
@Expose()
@IsString()
@MaxLength(255)
@ApiProperty({ type: "string", description: "Title for the chat session", example: "Help with payment issues" })
title!: string;
@Expose()
@IsOptional()
@ApiProperty({ type: "object", description: "Initial context for the chat", example: { user: "John Doe", orderId: "123456" }, required: false })
context?: Record<string, any>;
}
+4 -5
View File
@@ -1,9 +1,7 @@
import { Expose } from "class-transformer"; import { Expose } from "class-transformer";
import { IsOptional, IsString, MaxLength } from "class-validator"; import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
import { ApiProperty } from "../../../common/decorator/swggerDocs"; import { ApiProperty } from "../../../common/decorator/swggerDocs";
import { IsValidId } from "../../../common/decorator/validation.decorator";
import { ChatSessionModel } from "../models/chat-session.model";
export class SendMessageDto { export class SendMessageDto {
@Expose() @Expose()
@@ -13,8 +11,9 @@ export class SendMessageDto {
content!: string; content!: string;
@Expose() @Expose()
@IsValidId([ChatSessionModel]) @IsNotEmpty()
@ApiProperty({ type: "string", description: "Chat session ID" }) @IsString()
@ApiProperty({ type: "string", description: "Chat session ID (ULID)" })
sessionId!: string; sessionId!: string;
@Expose() @Expose()
@@ -1,14 +1,12 @@
import { Expose } from "class-transformer"; import { Expose } from "class-transformer";
import { IsNotEmpty } from "class-validator"; import { IsNotEmpty, IsString } from "class-validator";
import { ApiProperty } from "../../../common/decorator/swggerDocs"; import { ApiProperty } from "../../../common/decorator/swggerDocs";
import { IsValidId } from "../../../common/decorator/validation.decorator";
import { ChatSessionModel } from "../models/chat-session.model";
export class SessionIdParamDto { export class SessionIdParamDto {
@Expose() @Expose()
@IsNotEmpty({ message: "Session ID is required" }) @IsNotEmpty({ message: "Session ID is required" })
@IsValidId([ChatSessionModel]) @IsString()
@ApiProperty({ type: "string", description: "Session id of the entity", example: "66eff8c0c6ad5530b996c432" }) @ApiProperty({ type: "string", description: "Session id (ULID)", example: "01ARZ3NDEKTSV4RRFFQ69G5FAV" })
sessionId!: string; sessionId!: string;
} }
+36 -38
View File
@@ -2,18 +2,16 @@ import { Request, Response } from "express";
import { inject } from "inversify"; import { inject } from "inversify";
import { controller, httpGet, httpPost, httpPut, queryParam, request, requestBody, requestParam, response } from "inversify-express-utils"; import { controller, httpGet, httpPost, httpPut, queryParam, request, requestBody, requestParam, response } from "inversify-express-utils";
import { CreateChatSessionDto } from "./DTO/create-chat-session.dto";
import { SendMessageDto } from "./DTO/send-message.dto"; import { SendMessageDto } from "./DTO/send-message.dto";
import { SessionIdParamDto } from "./DTO/session-id.param.dto"; import { SessionIdParamDto } from "./DTO/session-id.param.dto";
import { ChatbotService } from "./providers/chatbot.service"; import { ChatbotService } from "./providers/chatbot.service";
import { getOrCreateChatbotUlid } from "./utils/ulid.util";
import { BaseController } from "../../common/base/controller"; import { BaseController } from "../../common/base/controller";
import { ApiAuth, ApiModel, ApiOperation, ApiParam, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; import { ApiModel, ApiOperation, ApiParam, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs";
import { PaginationDTO } from "../../common/dto/pagination.dto"; import { PaginationDTO } from "../../common/dto/pagination.dto";
import { HttpStatus } from "../../common/enums/httpStatus.enum"; import { HttpStatus } from "../../common/enums/httpStatus.enum";
import { Guard } from "../../core/middlewares/guard.middleware";
import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; import { ValidationMiddleware } from "../../core/middlewares/validator.middleware";
import { IOCTYPES } from "../../IOC/ioc.types"; import { IOCTYPES } from "../../IOC/ioc.types";
import { IUser } from "../user/models/Abstraction/IUser";
@controller("/chatbot") @controller("/chatbot")
@ApiTags("Chatbot") @ApiTags("Chatbot")
@@ -22,61 +20,55 @@ class ChatbotController extends BaseController {
@ApiOperation("Create a new chat session") @ApiOperation("Create a new chat session")
@ApiResponse("Chat session created successfully", HttpStatus.Created) @ApiResponse("Chat session created successfully", HttpStatus.Created)
@ApiModel(CreateChatSessionDto) @httpPost("/sessions")
@ApiAuth() public async createSession(@request() req: Request, @response() res: Response) {
@httpPost("/sessions", Guard.authUser(), ValidationMiddleware.validateInput(CreateChatSessionDto)) const ulid = getOrCreateChatbotUlid(req, res);
public async createSession(@request() req: Request, @requestBody() createDto: CreateChatSessionDto) { const data = await this.chatbotService.createChatSession(ulid);
const user = req.user as IUser;
const data = await this.chatbotService.createChatSession(user._id.toString(), createDto);
return this.response({ data }, HttpStatus.Created); return this.response({ data }, HttpStatus.Created);
} }
@ApiOperation("Get user's chat sessions") @ApiOperation("Get user's chat sessions")
@ApiResponse("Chat sessions retrieved successfully") @ApiResponse("Chat sessions retrieved successfully")
@ApiAuth() @httpGet("/sessions", ValidationMiddleware.validateQuery(PaginationDTO))
@httpGet("/sessions", Guard.authUser(), ValidationMiddleware.validateQuery(PaginationDTO)) public async getUserSessions(@request() req: Request, @response() res: Response, @queryParam() queryDto: PaginationDTO) {
public async getUserSessions(@request() req: Request, @queryParam() queryDto: PaginationDTO) { const ulid = getOrCreateChatbotUlid(req, res);
const user = req.user as IUser;
const limit = queryDto.limit || 10; const limit = queryDto.limit || 10;
const data = await this.chatbotService.getUserChatSessions(user._id.toString(), limit); const data = await this.chatbotService.getUserChatSessions(ulid, limit);
return this.response({ data }); return this.response({ data });
} }
@ApiOperation("Get a specific chat session with messages") @ApiOperation("Get a specific chat session with messages")
@ApiResponse("Chat session retrieved successfully") @ApiResponse("Chat session retrieved successfully")
@ApiParam("sessionId", "Session ID", true) @ApiParam("sessionId", "Session ID", true)
@ApiAuth() @httpGet("/sessions/:sessionId", ValidationMiddleware.validateParameter(SessionIdParamDto))
@httpGet("/sessions/:sessionId", Guard.authUser(), ValidationMiddleware.validateParameter(SessionIdParamDto)) public async getSession(@request() req: Request, @response() res: Response, @requestParam() param: SessionIdParamDto) {
public async getSession(@request() req: Request, @requestParam() param: SessionIdParamDto) { const ulid = getOrCreateChatbotUlid(req, res);
const user = req.user as IUser; const data = await this.chatbotService.getChatSession(param.sessionId, ulid);
const data = await this.chatbotService.getChatSession(param.sessionId, user._id.toString());
return this.response({ data }); return this.response({ data });
} }
@ApiOperation("Send a message in a chat session") @ApiOperation("Send a message in a chat session")
@ApiResponse("Message sent successfully", HttpStatus.Created) @ApiResponse("Message sent successfully", HttpStatus.Created)
@ApiModel(SendMessageDto) @ApiModel(SendMessageDto)
@ApiAuth() @httpPost("/messages", ValidationMiddleware.validateInput(SendMessageDto))
@httpPost("/messages", Guard.authUser(), ValidationMiddleware.validateInput(SendMessageDto)) public async sendMessage(@request() req: Request, @response() res: Response, @requestBody() sendDto: SendMessageDto) {
public async sendMessage(@request() req: Request, @requestBody() sendDto: SendMessageDto) { const ulid = getOrCreateChatbotUlid(req, res);
const user = req.user as IUser; const data = await this.chatbotService.sendMessage(ulid, sendDto);
const data = await this.chatbotService.sendMessage(user._id.toString(), sendDto);
return this.response({ data }, HttpStatus.Created); return this.response({ data }, HttpStatus.Created);
} }
@ApiOperation("Send a message and get streaming response") @ApiOperation("Send a message and get streaming response")
@ApiResponse("Streaming response initiated") @ApiResponse("Streaming response initiated")
@ApiModel(SendMessageDto) @ApiModel(SendMessageDto)
@ApiAuth() @httpPost("/messages/stream", ValidationMiddleware.validateInput(SendMessageDto))
@httpPost("/messages/stream", Guard.authUser(), ValidationMiddleware.validateInput(SendMessageDto))
public async sendMessageStream( public async sendMessageStream(
@request() req: Request, @request() req: Request,
@requestBody() sendDto: SendMessageDto, @requestBody() sendDto: SendMessageDto,
@response() res: Response, @response() res: Response,
): Promise<void> { ): Promise<void> {
const user = req.user as IUser; const ulid = getOrCreateChatbotUlid(req, res);
try { try {
const { userMessage, streamGenerator } = await this.chatbotService.sendMessageStream(user._id.toString(), sendDto); const { userMessage, streamGenerator } = await this.chatbotService.sendMessageStream(ulid, sendDto);
// Set headers for Server-Sent Events // Set headers for Server-Sent Events
res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Content-Type", "text/event-stream");
@@ -90,13 +82,20 @@ class ChatbotController extends BaseController {
// Start streaming bot response // Start streaming bot response
res.write(`data: ${JSON.stringify({ type: "bot_response_start" })}\n\n`); res.write(`data: ${JSON.stringify({ type: "bot_response_start" })}\n\n`);
let fullResponse = "";
const stream = await streamGenerator(); const stream = await streamGenerator();
for await (const chunk of stream) { for await (const chunk of stream) {
if (chunk) { if (chunk) {
fullResponse += chunk;
res.write(`data: ${JSON.stringify({ type: "bot_response_chunk", data: chunk })}\n\n`); res.write(`data: ${JSON.stringify({ type: "bot_response_chunk", data: chunk })}\n\n`);
} }
} }
// Save bot response to cache after streaming completes
if (fullResponse) {
await this.chatbotService.saveStreamedBotResponse(sendDto.sessionId, userMessage.id, fullResponse, ulid);
}
// End streaming // End streaming
res.write(`data: ${JSON.stringify({ type: "bot_response_end" })}\n\n`); res.write(`data: ${JSON.stringify({ type: "bot_response_end" })}\n\n`);
res.end(); res.end();
@@ -110,26 +109,25 @@ class ChatbotController extends BaseController {
@ApiOperation("Close a chat session") @ApiOperation("Close a chat session")
@ApiResponse("Chat session closed successfully") @ApiResponse("Chat session closed successfully")
@ApiParam("sessionId", "Session ID", true) @ApiParam("sessionId", "Session ID", true)
@ApiAuth() @httpPut("/sessions/:sessionId/close", ValidationMiddleware.validateParameter(SessionIdParamDto))
@httpPut("/sessions/:sessionId/close", Guard.authUser(), ValidationMiddleware.validateParameter(SessionIdParamDto)) public async closeSession(@request() req: Request, @response() res: Response, @requestParam() param: SessionIdParamDto) {
public async closeSession(@request() req: Request, @requestParam() param: SessionIdParamDto) { const ulid = getOrCreateChatbotUlid(req, res);
const user = req.user as IUser; const data = await this.chatbotService.closeChatSession(param.sessionId, ulid);
const data = await this.chatbotService.closeChatSession(param.sessionId, user._id.toString());
return this.response({ data }); return this.response({ data });
} }
@ApiOperation("Mark messages as read") @ApiOperation("Mark messages as read")
@ApiResponse("Messages marked as read successfully") @ApiResponse("Messages marked as read successfully")
@ApiParam("sessionId", "Session ID", true) @ApiParam("sessionId", "Session ID", true)
@ApiAuth() @httpPut("/sessions/:sessionId/messages/read", ValidationMiddleware.validateParameter(SessionIdParamDto))
@httpPut("/sessions/:sessionId/messages/read", Guard.authUser(), ValidationMiddleware.validateParameter(SessionIdParamDto))
public async markMessagesAsRead( public async markMessagesAsRead(
@request() req: Request, @request() req: Request,
@response() res: Response,
@requestParam() param: SessionIdParamDto, @requestParam() param: SessionIdParamDto,
@requestBody() body: { messageIds: string[] }, @requestBody() body: { messageIds: string[] },
) { ) {
const user = req.user as IUser; const ulid = getOrCreateChatbotUlid(req, res);
const data = await this.chatbotService.markMessagesAsRead(param.sessionId, user._id.toString(), body.messageIds); const data = await this.chatbotService.markMessagesAsRead(param.sessionId, ulid, body.messageIds);
return this.response({ data }); return this.response({ data });
} }
} }
+301 -143
View File
@@ -1,161 +1,234 @@
import { inject, injectable } from "inversify"; import { inject, injectable } from "inversify";
import { isValidObjectId } from "mongoose"; import * as ulidLib from "ulid";
import { LLMService } from "./llm.service"; import { LLMService } from "./llm.service";
import { UserModel } from "../../user/models/user.model";
import { CreateChatSessionDto } from "../DTO/create-chat-session.dto";
import { SendMessageDto } from "../DTO/send-message.dto";
import { ChatMessageModel, MessageStatus, MessageType } from "../models/chat-message.model";
import { ChatSessionModel, ChatSessionStatus } from "../models/chat-session.model";
import { IChatContext } from "../interfaces/chatbot.interface";
import { IChatMessage } from "../models/Abstraction/IChatMessage";
import { IChatSession } from "../models/Abstraction/IChatSession";
import { ChatMessageRepository } from "../repositories/chat-message.repository";
import { ChatSessionRepository } from "../repositories/chat-session.repository";
import { BadRequestError, NotFoundError } from "../../../core/app/app.errors"; import { BadRequestError, NotFoundError } from "../../../core/app/app.errors";
import { Logger } from "../../../core/logging/logger"; import { Logger } from "../../../core/logging/logger";
import { IOCTYPES } from "../../../IOC/ioc.types"; import { IOCTYPES } from "../../../IOC/ioc.types";
import { CacheService } from "../../../utils/cache.service";
import { SendMessageDto } from "../DTO/send-message.dto";
import { IChatContext } from "../interfaces/chatbot.interface";
import { MessageStatus, MessageType } from "../models/chat-message.model";
import { ChatSessionStatus } from "../models/chat-session.model";
interface CachedSession {
id: string;
ulid: string;
status: ChatSessionStatus;
lastMessageAt?: string;
createdAt: string;
}
interface CachedMessage {
id: string;
content: string;
type: MessageType;
status: MessageStatus;
sessionId: string;
metadata?: Record<string, any>;
responseToId?: string;
tokensUsed: number;
createdAt: string;
}
@injectable() @injectable()
export class ChatbotService { export class ChatbotService {
private readonly logger: Logger; private readonly logger: Logger;
private readonly CACHE_TTL_SECONDS = 15 * 60; // 15 minutes
constructor( constructor(
@inject(IOCTYPES.ChatbotLLMService) private llmService: LLMService, @inject(IOCTYPES.ChatbotLLMService) private llmService: LLMService,
@inject(IOCTYPES.ChatbotChatSessionRepository) private chatSessionRepo: ChatSessionRepository, @inject(IOCTYPES.CacheService) private cacheService: CacheService,
@inject(IOCTYPES.ChatbotChatMessageRepository) private chatMessageRepo: ChatMessageRepository,
) { ) {
this.logger = new Logger("ChatbotService"); this.logger = new Logger("ChatbotService");
this.logger.info("Using OpenAI as LLM provider"); this.logger.info("Using OpenAI as LLM provider");
} }
async createChatSession(userId: string, createDto: CreateChatSessionDto) { private getSessionCacheKey(ulid: string, sessionId: string): string {
if (!isValidObjectId(userId)) { return `chatbot:session:${ulid}:${sessionId}`;
throw new BadRequestError("Invalid user ID");
} }
const user = await UserModel.findById(userId); private getSessionsListCacheKey(ulid: string): string {
if (!user) { return `chatbot:sessions:${ulid}`;
throw new NotFoundError("User not found");
} }
const session = await ChatSessionModel.create({ private getMessagesCacheKey(sessionId: string): string {
title: createDto.title, return `chatbot:messages:${sessionId}`;
user: userId, }
context: createDto.context || {},
lastMessageAt: new Date(), async createChatSession(ulid: string) {
const sessionId = ulidLib.ulid();
const now = new Date().toISOString();
const session: CachedSession = {
id: sessionId,
ulid,
status: ChatSessionStatus.ACTIVE, status: ChatSessionStatus.ACTIVE,
}); lastMessageAt: now,
createdAt: now,
};
// Save session to cache
await this.cacheService.setAsync(this.getSessionCacheKey(ulid, sessionId), JSON.stringify(session), this.CACHE_TTL_SECONDS);
// Add to sessions list
const sessionsListKey = this.getSessionsListCacheKey(ulid);
const existingSessionsJson = await this.cacheService.getAsync(sessionsListKey);
const sessionsList: string[] = existingSessionsJson ? JSON.parse(existingSessionsJson) : [];
sessionsList.push(sessionId);
await this.cacheService.setAsync(sessionsListKey, JSON.stringify(sessionsList), this.CACHE_TTL_SECONDS);
// Initialize messages array for this session
await this.cacheService.setAsync(this.getMessagesCacheKey(sessionId), JSON.stringify([]), this.CACHE_TTL_SECONDS);
return this.mapSessionToDto(session); return this.mapSessionToDto(session);
} }
async getUserChatSessions(userId: string, limit = 10) { async getUserChatSessions(ulid: string, limit = 10) {
if (!isValidObjectId(userId)) { const sessionsListKey = this.getSessionsListCacheKey(ulid);
throw new BadRequestError("Invalid user ID"); const sessionsListJson = await this.cacheService.getAsync(sessionsListKey);
if (!sessionsListJson) {
return [];
}
const sessionIds: string[] = JSON.parse(sessionsListJson);
const sessions: CachedSession[] = [];
// Get sessions in reverse order (newest first)
for (let i = sessionIds.length - 1; i >= 0 && sessions.length < limit; i--) {
const sessionId = sessionIds[i];
const sessionKey = this.getSessionCacheKey(ulid, sessionId);
const sessionJson = await this.cacheService.getAsync(sessionKey);
if (sessionJson) {
const session: CachedSession = JSON.parse(sessionJson);
// Get messages for this session
const messagesJson = await this.cacheService.getAsync(this.getMessagesCacheKey(sessionId));
const messages: CachedMessage[] = messagesJson ? JSON.parse(messagesJson) : [];
sessions.push({ ...session, messages } as any);
}
} }
const sessions = await this.chatSessionRepo.findByUserWithMessages(userId, limit);
return sessions.map((session) => this.mapSessionToDto(session)); return sessions.map((session) => this.mapSessionToDto(session));
} }
async getChatSession(sessionId: string, userId: string) { async getChatSession(sessionId: string, ulid: string) {
if (!isValidObjectId(sessionId) || !isValidObjectId(userId)) { const sessionKey = this.getSessionCacheKey(ulid, sessionId);
throw new BadRequestError("Invalid session or user ID"); const sessionJson = await this.cacheService.getAsync(sessionKey);
}
const session = await this.chatSessionRepo.findByUserAndId(sessionId, userId); if (!sessionJson) {
if (!session) {
throw new NotFoundError("Chat session not found"); throw new NotFoundError("Chat session not found");
} }
return this.mapSessionToDto(session); const session: CachedSession = JSON.parse(sessionJson);
// Get messages for this session
const messagesJson = await this.cacheService.getAsync(this.getMessagesCacheKey(sessionId));
const messages: CachedMessage[] = messagesJson ? JSON.parse(messagesJson) : [];
return this.mapSessionToDto({ ...session, messages } as any);
} }
async sendMessage(userId: string, sendDto: SendMessageDto) { async sendMessage(ulid: string, sendDto: SendMessageDto) {
if (!isValidObjectId(sendDto.sessionId) || !isValidObjectId(userId)) { const sessionKey = this.getSessionCacheKey(ulid, sendDto.sessionId);
throw new BadRequestError("Invalid session or user ID"); const sessionJson = await this.cacheService.getAsync(sessionKey);
}
const session = await ChatSessionModel.findOne({ _id: sendDto.sessionId, user: userId }).populate("messages").lean(); if (!sessionJson) {
if (!session) {
throw new NotFoundError("Chat session not found"); throw new NotFoundError("Chat session not found");
} }
const session: CachedSession = JSON.parse(sessionJson);
if (session.status !== ChatSessionStatus.ACTIVE) { if (session.status !== ChatSessionStatus.ACTIVE) {
throw new BadRequestError("Cannot send message to inactive session"); throw new BadRequestError("Cannot send message to inactive session");
} }
const user = await UserModel.findById(userId);
if (!user) {
throw new NotFoundError("User not found");
}
// Create user message // Create user message
const userMessage = await ChatMessageModel.create({ const messageId = ulidLib.ulid();
const now = new Date().toISOString();
const userMessage: CachedMessage = {
id: messageId,
content: sendDto.content, content: sendDto.content,
type: MessageType.USER, type: MessageType.USER,
session: sendDto.sessionId, status: MessageStatus.SENT,
sender: userId, sessionId: sendDto.sessionId,
responseToId: sendDto.responseToId, responseToId: sendDto.responseToId,
metadata: sendDto.metadata, metadata: sendDto.metadata,
}); tokensUsed: 0,
createdAt: now,
};
// Save message to cache
const messagesKey = this.getMessagesCacheKey(sendDto.sessionId);
const messagesJson = await this.cacheService.getAsync(messagesKey);
const messages: CachedMessage[] = messagesJson ? JSON.parse(messagesJson) : [];
messages.push(userMessage);
await this.cacheService.setAsync(messagesKey, JSON.stringify(messages), this.CACHE_TTL_SECONDS);
// Update session last message time // Update session last message time
await this.chatSessionRepo.updateLastMessageTime(sendDto.sessionId); session.lastMessageAt = now;
await this.cacheService.setAsync(sessionKey, JSON.stringify(session), this.CACHE_TTL_SECONDS);
// Generate bot response asynchronously using OpenAI service // Generate bot response asynchronously using OpenAI service
this.generateBotResponse(sendDto.sessionId, userMessage._id.toString(), userId); this.generateBotResponse(sendDto.sessionId, messageId, ulid);
return this.mapMessageToDto(userMessage); return this.mapMessageToDto(userMessage);
} }
async sendMessageStream(userId: string, sendDto: SendMessageDto) { async sendMessageStream(ulid: string, sendDto: SendMessageDto) {
if (!isValidObjectId(sendDto.sessionId) || !isValidObjectId(userId)) { const sessionKey = this.getSessionCacheKey(ulid, sendDto.sessionId);
throw new BadRequestError("Invalid session or user ID"); const sessionJson = await this.cacheService.getAsync(sessionKey);
}
const session = await ChatSessionModel.findOne({ _id: sendDto.sessionId, user: userId }).lean(); if (!sessionJson) {
if (!session) {
throw new NotFoundError("Chat session not found"); throw new NotFoundError("Chat session not found");
} }
const session: CachedSession = JSON.parse(sessionJson);
if (session.status !== ChatSessionStatus.ACTIVE) { if (session.status !== ChatSessionStatus.ACTIVE) {
throw new BadRequestError("Cannot send message to inactive session"); throw new BadRequestError("Cannot send message to inactive session");
} }
const user = await UserModel.findById(userId);
if (!user) {
throw new NotFoundError("User not found");
}
// Create user message // Create user message
const userMessage = await ChatMessageModel.create({ const messageId = ulidLib.ulid();
const now = new Date().toISOString();
const userMessage: CachedMessage = {
id: messageId,
content: sendDto.content, content: sendDto.content,
type: MessageType.USER, type: MessageType.USER,
session: sendDto.sessionId, status: MessageStatus.SENT,
sender: userId, sessionId: sendDto.sessionId,
responseToId: sendDto.responseToId, responseToId: sendDto.responseToId,
metadata: sendDto.metadata, metadata: sendDto.metadata,
}); tokensUsed: 0,
createdAt: now,
};
// Save message to cache
const messagesKey = this.getMessagesCacheKey(sendDto.sessionId);
const messagesJson = await this.cacheService.getAsync(messagesKey);
const messages: CachedMessage[] = messagesJson ? JSON.parse(messagesJson) : [];
messages.push(userMessage);
await this.cacheService.setAsync(messagesKey, JSON.stringify(messages), this.CACHE_TTL_SECONDS);
// Update session last message time // Update session last message time
await this.chatSessionRepo.updateLastMessageTime(sendDto.sessionId); session.lastMessageAt = now;
await this.cacheService.setAsync(sessionKey, JSON.stringify(session), this.CACHE_TTL_SECONDS);
// Get conversation history for context // Get conversation history for context
const history = await this.chatMessageRepo.getConversationHistory(sendDto.sessionId, 20); const history = messages.slice(-20).map((msg) => ({
content: msg.content,
type: msg.type as "user" | "bot" | "system",
timestamp: new Date(msg.createdAt),
metadata: msg.metadata,
}));
// Build context for LLM // Build context for LLM
const context: IChatContext = { const context: IChatContext = {
userId, userId: ulid,
sessionId: sendDto.sessionId, sessionId: sendDto.sessionId,
conversationHistory: history.reverse().map((msg) => ({ conversationHistory: history,
content: msg.content,
type: msg.type as "user" | "bot" | "system",
timestamp: msg.createdAt,
metadata: msg.metadata,
})),
userPreferences: session.context,
}; };
// Return a function that generates the stream using LLM service // Return a function that generates the stream using LLM service
@@ -167,42 +240,63 @@ export class ChatbotService {
}; };
} }
private async generateBotResponse(sessionId: string, userMessageId: string, userId: string): Promise<void> { private async generateBotResponse(sessionId: string, userMessageId: string, ulid: string): Promise<void> {
try { try {
// Get conversation history // Get messages from cache
const history = await this.chatMessageRepo.getConversationHistory(sessionId, 20); const messagesKey = this.getMessagesCacheKey(sessionId);
const messagesJson = await this.cacheService.getAsync(messagesKey);
const session = await ChatSessionModel.findById(sessionId).lean(); if (!messagesJson) {
if (!session) {
throw new NotFoundError("Session not found"); throw new NotFoundError("Session not found");
} }
// Build context for LLM const messages: CachedMessage[] = JSON.parse(messagesJson);
const context: IChatContext = { const userMessage = messages.find((msg) => msg.id === userMessageId);
userId,
sessionId,
conversationHistory: history.reverse().map((msg) => ({
content: msg.content,
type: msg.type as "user" | "bot" | "system",
timestamp: msg.createdAt,
metadata: msg.metadata,
})),
userPreferences: session.context,
};
const userMessage = await ChatMessageModel.findById(userMessageId);
if (!userMessage) { if (!userMessage) {
throw new NotFoundError("User message not found"); throw new NotFoundError("User message not found");
} }
// Get session
const sessionsListKey = this.getSessionsListCacheKey(ulid);
const sessionsListJson = await this.cacheService.getAsync(sessionsListKey);
if (!sessionsListJson) {
throw new NotFoundError("Session not found");
}
const sessionKey = this.getSessionCacheKey(ulid, sessionId);
const sessionJson = await this.cacheService.getAsync(sessionKey);
if (!sessionJson) {
throw new NotFoundError("Session not found");
}
const session: CachedSession = JSON.parse(sessionJson);
// Get conversation history
const history = messages.slice(-20).map((msg) => ({
content: msg.content,
type: msg.type as "user" | "bot" | "system",
timestamp: new Date(msg.createdAt),
metadata: msg.metadata,
}));
// Build context for LLM
const context: IChatContext = {
userId: ulid,
sessionId,
conversationHistory: history,
};
// Generate response using LLM // Generate response using LLM
const llmResponse = await this.llmService.generateResponse(userMessage.content, context); const llmResponse = await this.llmService.generateResponse(userMessage.content, context);
// Create bot message // Create bot message
await ChatMessageModel.create({ const botMessageId = ulidLib.ulid();
const now = new Date().toISOString();
const botMessage: CachedMessage = {
id: botMessageId,
content: llmResponse.message, content: llmResponse.message,
type: MessageType.BOT, type: MessageType.BOT,
session: sessionId, status: MessageStatus.SENT,
sessionId,
responseToId: userMessageId, responseToId: userMessageId,
tokensUsed: llmResponse.tokensUsed, tokensUsed: llmResponse.tokensUsed,
metadata: { metadata: {
@@ -210,83 +304,147 @@ export class ChatbotService {
sources: llmResponse.sources, sources: llmResponse.sources,
llmContext: llmResponse.context, llmContext: llmResponse.context,
}, },
}); createdAt: now,
};
// Save bot message to cache
messages.push(botMessage);
await this.cacheService.setAsync(messagesKey, JSON.stringify(messages), this.CACHE_TTL_SECONDS);
// Update session last message time // Update session last message time
await this.chatSessionRepo.updateLastMessageTime(sessionId); session.lastMessageAt = now;
await this.cacheService.setAsync(sessionKey, JSON.stringify(session), this.CACHE_TTL_SECONDS);
this.logger.info(`Generated bot response for session ${sessionId}`); this.logger.info(`Generated bot response for session ${sessionId}`);
} catch (error) { } catch (error) {
this.logger.error(`Failed to generate bot response for session ${sessionId}`, error); this.logger.error(`Failed to generate bot response for session ${sessionId}`, error);
// Create error message // Create error message
await ChatMessageModel.create({ const messagesKey = this.getMessagesCacheKey(sessionId);
const messagesJson = await this.cacheService.getAsync(messagesKey);
const messages: CachedMessage[] = messagesJson ? JSON.parse(messagesJson) : [];
const errorMessageId = ulidLib.ulid();
const now = new Date().toISOString();
const errorMessage: CachedMessage = {
id: errorMessageId,
content: "متأسفم، در حال حاضر مشکلی در پاسخگویی دارم. لطفاً دوباره تلاش کنید. 🙏", content: "متأسفم، در حال حاضر مشکلی در پاسخگویی دارم. لطفاً دوباره تلاش کنید. 🙏",
type: MessageType.BOT, type: MessageType.BOT,
session: sessionId,
responseToId: userMessageId,
status: MessageStatus.FAILED, status: MessageStatus.FAILED,
}); sessionId,
responseToId: userMessageId,
tokensUsed: 0,
createdAt: now,
};
messages.push(errorMessage);
await this.cacheService.setAsync(messagesKey, JSON.stringify(messages), this.CACHE_TTL_SECONDS);
} }
} }
async closeChatSession(sessionId: string, userId: string) { async closeChatSession(sessionId: string, ulid: string) {
if (!isValidObjectId(sessionId) || !isValidObjectId(userId)) { const sessionKey = this.getSessionCacheKey(ulid, sessionId);
throw new BadRequestError("Invalid session or user ID"); const sessionJson = await this.cacheService.getAsync(sessionKey);
}
const session = await ChatSessionModel.findOneAndUpdate( if (!sessionJson) {
{ _id: sessionId, user: userId },
{ status: ChatSessionStatus.CLOSED },
{ new: true },
);
if (!session) {
throw new NotFoundError("Chat session not found"); throw new NotFoundError("Chat session not found");
} }
const session: CachedSession = JSON.parse(sessionJson);
session.status = ChatSessionStatus.CLOSED;
await this.cacheService.setAsync(sessionKey, JSON.stringify(session), this.CACHE_TTL_SECONDS);
return { message: "Chat session closed successfully" }; return { message: "Chat session closed successfully" };
} }
async markMessagesAsRead(sessionId: string, userId: string, messageIds: string[]) { async markMessagesAsRead(sessionId: string, ulid: string, messageIds: string[]) {
if (!isValidObjectId(sessionId) || !isValidObjectId(userId)) { const sessionKey = this.getSessionCacheKey(ulid, sessionId);
throw new BadRequestError("Invalid session or user ID"); const sessionJson = await this.cacheService.getAsync(sessionKey);
}
const session = await ChatSessionModel.findOne({ _id: sessionId, user: userId }); if (!sessionJson) {
if (!session) {
throw new NotFoundError("Chat session not found"); throw new NotFoundError("Chat session not found");
} }
await this.chatMessageRepo.markAsRead(messageIds); // Get messages and update their status
const messagesKey = this.getMessagesCacheKey(sessionId);
const messagesJson = await this.cacheService.getAsync(messagesKey);
if (messagesJson) {
const messages: CachedMessage[] = JSON.parse(messagesJson);
messages.forEach((msg) => {
if (messageIds.includes(msg.id)) {
msg.status = MessageStatus.READ;
}
});
await this.cacheService.setAsync(messagesKey, JSON.stringify(messages), this.CACHE_TTL_SECONDS);
}
return { message: "Messages marked as read successfully" }; return { message: "Messages marked as read successfully" };
} }
private mapSessionToDto(session: IChatSession | any) { async saveStreamedBotResponse(sessionId: string, userMessageId: string, botResponse: string, ulid: string): Promise<void> {
const sessionObj = session.toObject ? session.toObject() : session; try {
const messagesKey = this.getMessagesCacheKey(sessionId);
const messagesJson = await this.cacheService.getAsync(messagesKey);
if (!messagesJson) {
this.logger.warn(`Cannot save bot response: session ${sessionId} not found in cache`);
return;
}
const messages: CachedMessage[] = JSON.parse(messagesJson);
const botMessageId = ulidLib.ulid();
const now = new Date().toISOString();
const botMessage: CachedMessage = {
id: botMessageId,
content: botResponse,
type: MessageType.BOT,
status: MessageStatus.SENT,
sessionId,
responseToId: userMessageId,
tokensUsed: 0, // Could estimate tokens if needed
createdAt: now,
};
messages.push(botMessage);
await this.cacheService.setAsync(messagesKey, JSON.stringify(messages), this.CACHE_TTL_SECONDS);
// Update session last message time
const sessionKey = this.getSessionCacheKey(ulid, sessionId);
const sessionJson = await this.cacheService.getAsync(sessionKey);
if (sessionJson) {
const session: CachedSession = JSON.parse(sessionJson);
session.lastMessageAt = now;
await this.cacheService.setAsync(sessionKey, JSON.stringify(session), this.CACHE_TTL_SECONDS);
}
this.logger.info(`Saved streamed bot response for session ${sessionId}`);
} catch (error) {
this.logger.error(`Failed to save streamed bot response for session ${sessionId}`, error);
}
}
private mapSessionToDto(session: CachedSession | any) {
return { return {
id: sessionObj._id.toString(), id: session.id,
title: sessionObj.title, status: session.status,
status: sessionObj.status, createdAt: session.createdAt,
createdAt: sessionObj.createdAt, lastMessageAt: session.lastMessageAt,
lastMessageAt: sessionObj.lastMessageAt, messages: session.messages ? session.messages.map((msg: CachedMessage) => this.mapMessageToDto(msg)) : [],
context: sessionObj.context,
messages: sessionObj.messages ? sessionObj.messages.map((msg: any) => this.mapMessageToDto(msg)) : [],
}; };
} }
private mapMessageToDto(message: IChatMessage | any) { private mapMessageToDto(message: CachedMessage) {
const messageObj = message.toObject ? message.toObject() : message;
return { return {
id: messageObj._id.toString(), id: message.id,
content: messageObj.content, content: message.content,
type: messageObj.type, type: message.type,
status: messageObj.status, status: message.status,
createdAt: messageObj.createdAt, createdAt: message.createdAt,
responseToId: messageObj.responseToId, responseToId: message.responseToId,
metadata: messageObj.metadata, metadata: message.metadata,
tokensUsed: messageObj.tokensUsed, tokensUsed: message.tokensUsed,
}; };
} }
} }
+28
View File
@@ -0,0 +1,28 @@
import { Request, Response } from "express";
import * as ulidLib from "ulid";
const CHATBOT_ULID_COOKIE_NAME = "chatbot_session_id";
/**
* Get or create ULID for chatbot session from cookies
* @param req Express request object
* @param res Express response object
* @returns ULID string
*/
export function getOrCreateChatbotUlid(req: Request, res: Response): string {
let ulidValue = req.cookies?.[CHATBOT_ULID_COOKIE_NAME];
if (!ulidValue) {
ulidValue = ulidLib.ulid();
// Set cookie with 1 year expiration (or adjust as needed)
res.cookie(CHATBOT_ULID_COOKIE_NAME, ulidValue, {
maxAge: 365 * 24 * 60 * 60 * 1000, // 1 year
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
});
}
return ulidValue;
}