chore: chat bot
This commit is contained in:
+43
-33
@@ -30,80 +30,90 @@
|
|||||||
"seed:run": "mikro-orm seeder:run --config=database/mikro-orm.config.ts"
|
"seed:run": "mikro-orm seeder:run --config=database/mikro-orm.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.806.0",
|
"@aws-sdk/client-s3": "^3.826.0",
|
||||||
"@fastify/static": "^8.1.1",
|
"@fastify/static": "^8.2.0",
|
||||||
|
"@google/generative-ai": "^0.24.1",
|
||||||
"@keyv/redis": "^4.4.0",
|
"@keyv/redis": "^4.4.0",
|
||||||
"@mikro-orm/cli": "^6.4.15",
|
"@langchain/community": "^0.3.45",
|
||||||
"@mikro-orm/core": "^6.4.15",
|
"@langchain/core": "^0.3.57",
|
||||||
|
"@langchain/google-genai": "^0.2.10",
|
||||||
|
"@langchain/openai": "^0.5.12",
|
||||||
|
"@mikro-orm/cli": "^6.4.16",
|
||||||
|
"@mikro-orm/core": "^6.4.16",
|
||||||
"@mikro-orm/nestjs": "^6.1.1",
|
"@mikro-orm/nestjs": "^6.1.1",
|
||||||
"@mikro-orm/postgresql": "^6.4.15",
|
"@mikro-orm/postgresql": "^6.4.16",
|
||||||
"@mikro-orm/seeder": "^6.4.15",
|
"@mikro-orm/seeder": "^6.4.16",
|
||||||
"@nest-lab/fastify-multer": "^1.3.0",
|
"@nest-lab/fastify-multer": "^1.3.0",
|
||||||
"@nest-lab/throttler-storage-redis": "^1.1.0",
|
"@nest-lab/throttler-storage-redis": "^1.1.0",
|
||||||
"@nestjs-modules/mailer": "^2.0.2",
|
"@nestjs-modules/mailer": "^2.0.2",
|
||||||
"@nestjs/axios": "^4.0.0",
|
"@nestjs/axios": "^4.0.0",
|
||||||
"@nestjs/bullmq": "^11.0.2",
|
"@nestjs/bullmq": "^11.0.2",
|
||||||
"@nestjs/cache-manager": "^3.0.1",
|
"@nestjs/cache-manager": "^3.0.1",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.1.3",
|
||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.1.3",
|
||||||
"@nestjs/jwt": "^11.0.0",
|
"@nestjs/jwt": "^11.0.0",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-fastify": "^11.1.0",
|
"@nestjs/platform-fastify": "^11.1.3",
|
||||||
|
"@nestjs/platform-socket.io": "^11.1.3",
|
||||||
"@nestjs/swagger": "^11.2.0",
|
"@nestjs/swagger": "^11.2.0",
|
||||||
"@nestjs/throttler": "^6.4.0",
|
"@nestjs/throttler": "^6.4.0",
|
||||||
|
"@nestjs/websockets": "^11.1.3",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bullmq": "^5.52.2",
|
"bullmq": "^5.53.2",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"decimal.js": "^10.5.0",
|
"decimal.js": "^10.5.0",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"fastify": "^5.3.2",
|
"fastify": "^5.3.3",
|
||||||
|
"langchain": "^0.3.27",
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.3",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.2",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^19.8.0",
|
"@commitlint/cli": "^19.8.1",
|
||||||
"@commitlint/config-conventional": "^19.8.0",
|
"@commitlint/config-conventional": "^19.8.1",
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.28.0",
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.7",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.5",
|
||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.1.3",
|
||||||
"@swc/cli": "^0.6.0",
|
"@swc/cli": "^0.6.0",
|
||||||
"@swc/core": "^1.10.7",
|
"@swc/core": "^1.11.31",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.15.30",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.31.0",
|
"@typescript-eslint/eslint-plugin": "^8.33.1",
|
||||||
"@typescript-eslint/parser": "^8.31.0",
|
"@typescript-eslint/parser": "^8.33.1",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.28.0",
|
||||||
"eslint-config-prettier": "^10.1.2",
|
"eslint-config-prettier": "^10.1.5",
|
||||||
"eslint-import-resolver-typescript": "^3.10.1",
|
"eslint-import-resolver-typescript": "^3.10.1",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"eslint-plugin-prettier": "^5.2.6",
|
"eslint-plugin-prettier": "^5.4.1",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.2.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"lint-staged": "^15.5.1",
|
"lint-staged": "^15.5.2",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.1.1",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.3.4",
|
||||||
"ts-loader": "^9.5.2",
|
"ts-loader": "^9.5.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "^8.31.0"
|
"typescript-eslint": "^8.33.1"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
|
|||||||
Generated
+3341
-1638
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ import { HTTPLogger } from "./core/middlewares/logger.middleware";
|
|||||||
import { AnnouncementsModule } from "./modules/announcements/announcement.module";
|
import { AnnouncementsModule } from "./modules/announcements/announcement.module";
|
||||||
import { AuthModule } from "./modules/auth/auth.module";
|
import { AuthModule } from "./modules/auth/auth.module";
|
||||||
import { BusinessesModule } from "./modules/businesses/businesses.module";
|
import { BusinessesModule } from "./modules/businesses/businesses.module";
|
||||||
|
import { ChatbotModule } from "./modules/chatbot/chatbot.module";
|
||||||
import { CompaniesModule } from "./modules/companies/companies.module";
|
import { CompaniesModule } from "./modules/companies/companies.module";
|
||||||
import { CriticismModule } from "./modules/criticisms/criticisms.module";
|
import { CriticismModule } from "./modules/criticisms/criticisms.module";
|
||||||
import { IndustriesModule } from "./modules/industries/industries.module";
|
import { IndustriesModule } from "./modules/industries/industries.module";
|
||||||
@@ -61,6 +62,7 @@ import { UtilsModule } from "./modules/utils/utils.module";
|
|||||||
CriticismModule,
|
CriticismModule,
|
||||||
PaymentsModule,
|
PaymentsModule,
|
||||||
SlidersModule,
|
SlidersModule,
|
||||||
|
ChatbotModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { JwtModuleAsyncOptions } from "@nestjs/jwt";
|
|||||||
export function jwtConfig(): JwtModuleAsyncOptions {
|
export function jwtConfig(): JwtModuleAsyncOptions {
|
||||||
return {
|
return {
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
|
global: true,
|
||||||
useFactory: (configService: ConfigService) => {
|
useFactory: (configService: ConfigService) => {
|
||||||
return {
|
return {
|
||||||
secret: configService.getOrThrow<string>("JWT_SECRET"),
|
secret: configService.getOrThrow<string>("JWT_SECRET"),
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ArgumentsHost, Catch, ExceptionFilter, Logger, UnauthorizedException } from "@nestjs/common";
|
||||||
|
import { WsException } from "@nestjs/websockets";
|
||||||
|
import { Socket } from "socket.io";
|
||||||
|
|
||||||
|
import { CHATBOT_CONSTANTS } from "../../modules/chatbot/constants/chatbot.constants";
|
||||||
|
|
||||||
|
@Catch(WsException)
|
||||||
|
export class WsExceptionFilter implements ExceptionFilter {
|
||||||
|
private readonly logger = new Logger(WsExceptionFilter.name);
|
||||||
|
|
||||||
|
catch(exception: WsException, host: ArgumentsHost) {
|
||||||
|
const client = host.switchToWs().getClient<Socket>();
|
||||||
|
const data = host.switchToWs().getData();
|
||||||
|
|
||||||
|
this.logger.error(`WebSocket Error: ${exception.message}`);
|
||||||
|
this.logger.error(`Client ID: ${client.id}`);
|
||||||
|
this.logger.error(`Data: ${JSON.stringify(data)}`);
|
||||||
|
|
||||||
|
if (exception instanceof UnauthorizedException) {
|
||||||
|
client.emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.UNAUTHORIZED, {
|
||||||
|
message: CHATBOT_CONSTANTS.ERROR_MESSAGES.INVALID_TOKEN,
|
||||||
|
});
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorResponse = {
|
||||||
|
status: "error",
|
||||||
|
message: exception.message || CHATBOT_CONSTANTS.ERROR_MESSAGES.WEBSOCKET_ERROR,
|
||||||
|
};
|
||||||
|
|
||||||
|
client.emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.WEBSOCKET_ERROR, errorResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Body, Controller, HttpCode, HttpStatus, Patch, Post, UseGuards, UseInterceptors } from "@nestjs/common";
|
import { Body, Controller, HttpCode, HttpStatus, Patch, Post, UseGuards, UseInterceptors } from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiHeader, ApiOperation } from "@nestjs/swagger";
|
||||||
import { Throttle, ThrottlerGuard } from "@nestjs/throttler";
|
import { Throttle, ThrottlerGuard } from "@nestjs/throttler";
|
||||||
|
|
||||||
import { ChangePasswordDto } from "./DTO/change-password.dto";
|
import { ChangePasswordDto } from "./DTO/change-password.dto";
|
||||||
@@ -16,8 +16,8 @@ import { UserDec } from "../../common/decorators/user.decorator";
|
|||||||
import { BusinessInterceptor } from "../../core/interceptors/business.interceptor";
|
import { BusinessInterceptor } from "../../core/interceptors/business.interceptor";
|
||||||
import { Business } from "../businesses/entities/business.entity";
|
import { Business } from "../businesses/entities/business.entity";
|
||||||
|
|
||||||
@ApiTags("Auth")
|
|
||||||
@Controller("auth")
|
@Controller("auth")
|
||||||
|
@ApiHeader({ name: "x-business-id", description: "Business ID" })
|
||||||
@Throttle({ default: { limit: AUTH_THROTTLE_LIMIT, ttl: AUTH_THROTTLE_TTL } })
|
@Throttle({ default: { limit: AUTH_THROTTLE_LIMIT, ttl: AUTH_THROTTLE_TTL } })
|
||||||
@UseGuards(ThrottlerGuard)
|
@UseGuards(ThrottlerGuard)
|
||||||
@UseInterceptors(BusinessInterceptor)
|
@UseInterceptors(BusinessInterceptor)
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { MessageStatus, MessageType } from "../entities/chat-message.entity";
|
||||||
|
import { ChatSessionStatus } from "../entities/chat-session.entity";
|
||||||
|
|
||||||
|
export class ChatMessageResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: MessageType })
|
||||||
|
type!: MessageType;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: MessageStatus })
|
||||||
|
status!: MessageStatus;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
responseToId?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
tokensUsed!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChatSessionResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ChatSessionStatus })
|
||||||
|
status!: ChatSessionStatus;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
lastMessageAt?: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [ChatMessageResponseDto] })
|
||||||
|
messages!: ChatMessageResponseDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
context?: Record<string, any>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsOptional, IsString, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
export class CreateChatSessionDto {
|
||||||
|
@ApiProperty({ description: "Title for the chat session", example: "Help with payment issues" })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({ description: "Initial context for the chat", example: { user: "John Doe", orderId: "123456" } })
|
||||||
|
context?: Record<string, any>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsOptional, IsString, IsUUID, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
export class SendMessageDto {
|
||||||
|
@ApiProperty({ description: "Message content", example: "How can I cancel my subscription?" })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Chat session ID" })
|
||||||
|
@IsUUID()
|
||||||
|
sessionId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "ID of message being replied to", required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
responseToId?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Additional metadata for the message", required: false })
|
||||||
|
@IsOptional()
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { IsNotEmpty, IsOptional, IsString, IsUUID, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
export class AuthenticateDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
token!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateSessionDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
title!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JoinChatDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsUUID()
|
||||||
|
sessionId!: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
userId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LeaveChatDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsUUID()
|
||||||
|
sessionId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SendMessageWebSocketDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsUUID()
|
||||||
|
sessionId!: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(4000)
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
responseToId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TypingDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsUUID()
|
||||||
|
sessionId!: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
userId!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseIntPipe, ParseUUIDPipe, Post, Put, Query, Res } from "@nestjs/common";
|
||||||
|
import { ApiOperation, ApiResponse } from "@nestjs/swagger";
|
||||||
|
import { FastifyReply } from "fastify";
|
||||||
|
|
||||||
|
import { ChatMessageResponseDto, ChatSessionResponseDto } from "./DTO/chat-response.dto";
|
||||||
|
import { CreateChatSessionDto } from "./DTO/create-chat-session.dto";
|
||||||
|
import { SendMessageDto } from "./DTO/send-message.dto";
|
||||||
|
import { ChatbotService } from "./providers/chatbot.service";
|
||||||
|
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
|
||||||
|
import { UserDec } from "../../common/decorators/user.decorator";
|
||||||
|
|
||||||
|
@Controller("chatbot")
|
||||||
|
@AuthGuards()
|
||||||
|
export class ChatbotController {
|
||||||
|
constructor(private chatbotService: ChatbotService) {}
|
||||||
|
|
||||||
|
@Post("sessions")
|
||||||
|
@ApiOperation({ summary: "Create a new chat session" })
|
||||||
|
@ApiResponse({ status: 201, description: "Chat session created successfully", type: ChatSessionResponseDto })
|
||||||
|
async createSession(@UserDec("id") userId: string, @Body() createDto: CreateChatSessionDto): Promise<ChatSessionResponseDto> {
|
||||||
|
return this.chatbotService.createChatSession(userId, createDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("sessions")
|
||||||
|
@ApiOperation({ summary: "Get user's chat sessions" })
|
||||||
|
@ApiResponse({ status: 200, description: "Chat sessions retrieved successfully", type: [ChatSessionResponseDto] })
|
||||||
|
async getUserSessions(
|
||||||
|
@UserDec("id") userId: string,
|
||||||
|
@Query("limit", new ParseIntPipe({ optional: true })) limit = 10,
|
||||||
|
): Promise<ChatSessionResponseDto[]> {
|
||||||
|
return this.chatbotService.getUserChatSessions(userId, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("sessions/:sessionId")
|
||||||
|
@ApiOperation({ summary: "Get a specific chat session with messages" })
|
||||||
|
@ApiResponse({ status: 200, description: "Chat session retrieved successfully", type: ChatSessionResponseDto })
|
||||||
|
async getSession(@UserDec("id") userId: string, @Param("sessionId", ParseUUIDPipe) sessionId: string): Promise<ChatSessionResponseDto> {
|
||||||
|
return this.chatbotService.getChatSession(sessionId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("messages")
|
||||||
|
@ApiOperation({ summary: "Send a message in a chat session (Enhanced with LangChain + Fallback)" })
|
||||||
|
@ApiResponse({ status: 201, description: "Message sent successfully with enhanced AI", type: ChatMessageResponseDto })
|
||||||
|
async sendMessage(@UserDec("id") userId: string, @Body() sendDto: SendMessageDto): Promise<ChatMessageResponseDto> {
|
||||||
|
// Now uses LangChain by default with automatic Gemini fallback
|
||||||
|
return this.chatbotService.sendMessage(userId, sendDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("messages/stream")
|
||||||
|
@ApiOperation({ summary: "Send a message and get streaming response (Enhanced with LangChain + Fallback)" })
|
||||||
|
@ApiResponse({ status: 200, description: "Enhanced streaming response initiated" })
|
||||||
|
async sendMessageStream(@UserDec("id") userId: string, @Body() sendDto: SendMessageDto, @Res() res: FastifyReply): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Now uses LangChain by default with automatic Gemini fallback
|
||||||
|
const { userMessage, streamGenerator } = await this.chatbotService.sendMessageStream(userId, sendDto);
|
||||||
|
|
||||||
|
// Set headers for Server-Sent Events
|
||||||
|
res.header("Content-Type", "text/event-stream");
|
||||||
|
res.header("Cache-Control", "no-cache");
|
||||||
|
res.header("Connection", "keep-alive");
|
||||||
|
res.header("Access-Control-Allow-Origin", "*");
|
||||||
|
|
||||||
|
// Send initial user message
|
||||||
|
res.raw.write(`data: ${JSON.stringify({ type: "user_message", data: userMessage, enhanced: true })}\n\n`);
|
||||||
|
|
||||||
|
// Start streaming bot response
|
||||||
|
res.raw.write(`data: ${JSON.stringify({ type: "bot_response_start", provider: "enhanced" })}\n\n`);
|
||||||
|
|
||||||
|
const stream = await streamGenerator();
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
if (chunk) {
|
||||||
|
res.raw.write(`data: ${JSON.stringify({ type: "bot_response_chunk", data: chunk })}\n\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// End streaming
|
||||||
|
res.raw.write(`data: ${JSON.stringify({ type: "bot_response_end" })}\n\n`);
|
||||||
|
res.raw.end();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.raw.write(
|
||||||
|
`data: ${JSON.stringify({ type: "error", data: "خطا در تولید پاسخ هوشمند. LangChain با خطا مواجه شد و به Gemini بازگشت ⚠️" })}\n\n`,
|
||||||
|
);
|
||||||
|
res.raw.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("messages/langchain")
|
||||||
|
@ApiOperation({ summary: "Send a message using LangChain with enhanced RAG capabilities" })
|
||||||
|
@ApiResponse({ status: 201, description: "Message sent successfully with LangChain", type: ChatMessageResponseDto })
|
||||||
|
async sendMessageWithLangChain(@UserDec("id") userId: string, @Body() sendDto: SendMessageDto): Promise<ChatMessageResponseDto> {
|
||||||
|
return this.chatbotService.sendMessageWithLangChain(userId, sendDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("messages/langchain/stream")
|
||||||
|
@ApiOperation({ summary: "Send a message and get streaming response using LangChain" })
|
||||||
|
@ApiResponse({ status: 200, description: "Streaming response initiated with LangChain" })
|
||||||
|
async sendMessageStreamWithLangChain(@UserDec("id") userId: string, @Body() sendDto: SendMessageDto, @Res() res: FastifyReply): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { userMessage, streamGenerator } = await this.chatbotService.sendMessageStreamWithLangChain(userId, sendDto);
|
||||||
|
|
||||||
|
// Set headers for Server-Sent Events
|
||||||
|
res.header("Content-Type", "text/event-stream");
|
||||||
|
res.header("Cache-Control", "no-cache");
|
||||||
|
res.header("Connection", "keep-alive");
|
||||||
|
res.header("Access-Control-Allow-Origin", "*");
|
||||||
|
|
||||||
|
// Send initial user message
|
||||||
|
res.raw.write(`data: ${JSON.stringify({ type: "user_message", data: userMessage })}\n\n`);
|
||||||
|
|
||||||
|
// Start streaming bot response
|
||||||
|
res.raw.write(`data: ${JSON.stringify({ type: "bot_response_start", provider: "langchain" })}\n\n`);
|
||||||
|
|
||||||
|
const stream = await streamGenerator();
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
if (chunk) {
|
||||||
|
res.raw.write(`data: ${JSON.stringify({ type: "bot_response_chunk", data: chunk })}\n\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// End streaming
|
||||||
|
res.raw.write(`data: ${JSON.stringify({ type: "bot_response_end" })}\n\n`);
|
||||||
|
res.raw.end();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.raw.write(`data: ${JSON.stringify({ type: "error", data: "خطا در تولید پاسخ با LangChain. لطفاً دوباره تلاش کنید. ⚠️" })}\n\n`);
|
||||||
|
res.raw.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("training/refresh")
|
||||||
|
@ApiOperation({ summary: "Refresh LangChain training data from database" })
|
||||||
|
@ApiResponse({ status: 200, description: "Training data refreshed successfully" })
|
||||||
|
async refreshTrainingData(@UserDec("id") _userId: string): Promise<{ message: string }> {
|
||||||
|
await this.chatbotService.refreshLangChainData();
|
||||||
|
return { message: "دادههای آموزشی با موفقیت بهروزرسانی شدند 🔄" };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put("sessions/:sessionId/close")
|
||||||
|
@ApiOperation({ summary: "Close a chat session" })
|
||||||
|
@ApiResponse({ status: 200, description: "Chat session closed successfully" })
|
||||||
|
async closeSession(@UserDec("id") userId: string, @Param("sessionId", ParseUUIDPipe) sessionId: string): Promise<{ message: string }> {
|
||||||
|
await this.chatbotService.closeChatSession(sessionId, userId);
|
||||||
|
return { message: "جلسه چت با موفقیت بسته شد ✅" };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put("sessions/:sessionId/messages/read")
|
||||||
|
@ApiOperation({ summary: "Mark messages as read" })
|
||||||
|
@ApiResponse({ status: 200, description: "Messages marked as read successfully" })
|
||||||
|
async markMessagesAsRead(
|
||||||
|
@UserDec("id") userId: string,
|
||||||
|
@Param("sessionId", ParseUUIDPipe) sessionId: string,
|
||||||
|
@Body() body: { messageIds: string[] },
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
await this.chatbotService.markMessagesAsRead(sessionId, userId, body.messageIds);
|
||||||
|
return { message: "پیامها با موفقیت به عنوان خوانده شده علامتگذاری شدند ✅" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
import { Logger, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
|
||||||
|
import { JwtService } from "@nestjs/jwt";
|
||||||
|
import {
|
||||||
|
ConnectedSocket,
|
||||||
|
MessageBody,
|
||||||
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
|
OnGatewayInit,
|
||||||
|
SubscribeMessage,
|
||||||
|
WebSocketGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
} from "@nestjs/websockets";
|
||||||
|
import { Server, Socket } from "socket.io";
|
||||||
|
|
||||||
|
import { CHATBOT_CONSTANTS } from "./constants/chatbot.constants";
|
||||||
|
import { AuthenticateDto, CreateSessionDto, JoinChatDto, LeaveChatDto, SendMessageWebSocketDto, TypingDto } from "./DTO/websocket-events.dto";
|
||||||
|
import { WebSocketAuthGuard } from "./guards/websocket-auth.guard";
|
||||||
|
import { ChatbotService } from "./providers/chatbot.service";
|
||||||
|
import { extractTokenFromClient } from "../utils/providers/extract-token.utils";
|
||||||
|
|
||||||
|
@WebSocketGateway({
|
||||||
|
cors: { origin: "*", credentials: true },
|
||||||
|
namespace: "/chat",
|
||||||
|
})
|
||||||
|
@UseGuards(WebSocketAuthGuard)
|
||||||
|
// @UseFilters(WsExceptionFilter)
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true }))
|
||||||
|
export class ChatbotGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
||||||
|
@WebSocketServer()
|
||||||
|
server: Server;
|
||||||
|
|
||||||
|
private readonly logger = new Logger(ChatbotGateway.name);
|
||||||
|
private connectedUsers = new Map<string, { userId: string; sessionId?: string }>(); // socketId -> user info
|
||||||
|
private userSessions = new Map<string, Set<string>>(); // userId -> Set of socketIds
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private chatbotService: ChatbotService,
|
||||||
|
private jwtService: JwtService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
afterInit(server: Server) {
|
||||||
|
this.logger.log("WebSocket Gateway initialized");
|
||||||
|
this.logger.log(`Server instance: ${server ? "exists" : "null"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection Handlers - Authentication happens here
|
||||||
|
async handleConnection(@ConnectedSocket() client: Socket) {
|
||||||
|
this.logger.log("=== New Connection ===");
|
||||||
|
this.logger.log(`Client ID: ${client.id}`);
|
||||||
|
this.logger.log(`Client IP: ${client.handshake.address}`);
|
||||||
|
this.logger.log("====================");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extract token from handshake
|
||||||
|
const token = extractTokenFromClient(client);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
this.logger.warn("No token provided in WebSocket connection");
|
||||||
|
client.emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.UNAUTHORIZED, {
|
||||||
|
message: CHATBOT_CONSTANTS.ERROR_MESSAGES.AUTHENTICATION_REQUIRED,
|
||||||
|
});
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JWT token
|
||||||
|
const payload = await this.jwtService.verifyAsync(token);
|
||||||
|
|
||||||
|
// Store user data in client
|
||||||
|
client.data.user = payload;
|
||||||
|
|
||||||
|
this.connectedUsers.set(client.id, { userId: payload.id });
|
||||||
|
|
||||||
|
if (!this.userSessions.has(payload?.id)) {
|
||||||
|
this.userSessions.set(payload?.id, new Set());
|
||||||
|
}
|
||||||
|
this.userSessions.get(payload.id)!.add(client.id);
|
||||||
|
|
||||||
|
this.logger.log(`Client connected successfully: ${client.id} for user: ${payload.id}`);
|
||||||
|
|
||||||
|
client.emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.AUTHENTICATED, {
|
||||||
|
message: CHATBOT_CONSTANTS.SUCCESS_MESSAGES.AUTHENTICATED,
|
||||||
|
userId: payload.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Connection authentication failed:", error);
|
||||||
|
client.emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.UNAUTHORIZED, {
|
||||||
|
message: CHATBOT_CONSTANTS.ERROR_MESSAGES.INVALID_TOKEN,
|
||||||
|
});
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDisconnect(@ConnectedSocket() client: Socket) {
|
||||||
|
this.logger.log("=== Client Disconnected ===");
|
||||||
|
this.logger.log(`Client ID: ${client.id}`);
|
||||||
|
this.logger.log("==========================");
|
||||||
|
|
||||||
|
const userInfo = this.connectedUsers.get(client.id);
|
||||||
|
|
||||||
|
if (userInfo) {
|
||||||
|
this.logger.log(`Client disconnected: ${client.id} for user: ${userInfo.userId}`);
|
||||||
|
|
||||||
|
// Remove from user sessions
|
||||||
|
const userSockets = this.userSessions.get(userInfo.userId);
|
||||||
|
if (userSockets) {
|
||||||
|
userSockets.delete(client.id);
|
||||||
|
if (userSockets.size === 0) {
|
||||||
|
this.userSessions.delete(userInfo.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave session room if joined
|
||||||
|
if (userInfo.sessionId) {
|
||||||
|
client.leave(`session_${userInfo.sessionId}`);
|
||||||
|
|
||||||
|
// Notify others in session
|
||||||
|
this.server.to(`session_${userInfo.sessionId}`).emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.USER_LEFT, {
|
||||||
|
userId: userInfo.userId,
|
||||||
|
sessionId: userInfo.sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connectedUsers.delete(client.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication Handler
|
||||||
|
@SubscribeMessage(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.AUTHENTICATE)
|
||||||
|
@UseGuards(WebSocketAuthGuard)
|
||||||
|
async handleAuthenticate(@ConnectedSocket() client: Socket, @MessageBody() _data: AuthenticateDto) {
|
||||||
|
try {
|
||||||
|
// This is handled in connection, but we can re-authenticate if needed
|
||||||
|
client.emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.AUTHENTICATED, {
|
||||||
|
message: CHATBOT_CONSTANTS.SUCCESS_MESSAGES.AUTHENTICATED,
|
||||||
|
userId: client.data.user?.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Authentication failed:", error);
|
||||||
|
client.emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.UNAUTHORIZED, {
|
||||||
|
message: CHATBOT_CONSTANTS.ERROR_MESSAGES.INVALID_TOKEN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session Management
|
||||||
|
@SubscribeMessage(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.CREATE_SESSION)
|
||||||
|
@UseGuards(WebSocketAuthGuard)
|
||||||
|
async handleCreateSession(@ConnectedSocket() client: Socket, @MessageBody() data: CreateSessionDto) {
|
||||||
|
try {
|
||||||
|
const userId = client.data.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error("User not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await this.chatbotService.createChatSession(userId, { title: data.title });
|
||||||
|
|
||||||
|
client.emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.SESSION_CREATED, {
|
||||||
|
message: CHATBOT_CONSTANTS.SUCCESS_MESSAGES.SESSION_CREATED,
|
||||||
|
session: session,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Session created: ${session.id} for user: ${userId}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Session creation failed:", error);
|
||||||
|
client.emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.SESSION_ERROR, {
|
||||||
|
message: CHATBOT_CONSTANTS.ERROR_MESSAGES.SESSION_CREATION_FAILED,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.JOIN_CHAT)
|
||||||
|
@UseGuards(WebSocketAuthGuard)
|
||||||
|
async handleJoinChat(@ConnectedSocket() client: Socket, @MessageBody() data: JoinChatDto) {
|
||||||
|
try {
|
||||||
|
const userId = client.data.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error("User not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify session ownership
|
||||||
|
await this.chatbotService.getChatSession(data.sessionId, userId);
|
||||||
|
|
||||||
|
// Leave previous session if any
|
||||||
|
const userInfo = this.connectedUsers.get(client.id);
|
||||||
|
if (userInfo?.sessionId) {
|
||||||
|
client.leave(`session_${userInfo.sessionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join new session room
|
||||||
|
await client.join(`session_${data.sessionId}`);
|
||||||
|
|
||||||
|
// Update user info
|
||||||
|
this.connectedUsers.set(client.id, {
|
||||||
|
userId,
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`User ${userId} joined chat session ${data.sessionId}`);
|
||||||
|
|
||||||
|
// Notify client
|
||||||
|
client.emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.CHAT_JOINED, {
|
||||||
|
message: CHATBOT_CONSTANTS.SUCCESS_MESSAGES.CHAT_JOINED,
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify others in session
|
||||||
|
client.to(`session_${data.sessionId}`).emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.USER_JOINED, {
|
||||||
|
userId,
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Failed to join chat:", error);
|
||||||
|
client.emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.ERROR, {
|
||||||
|
message: CHATBOT_CONSTANTS.ERROR_MESSAGES.SESSION_NOT_FOUND,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.LEAVE_CHAT)
|
||||||
|
@UseGuards(WebSocketAuthGuard)
|
||||||
|
async handleLeaveChat(@ConnectedSocket() client: Socket, @MessageBody() data: LeaveChatDto) {
|
||||||
|
try {
|
||||||
|
const userId = client.data.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error("User not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.leave(`session_${data.sessionId}`);
|
||||||
|
|
||||||
|
// Update user info
|
||||||
|
const userInfo = this.connectedUsers.get(client.id);
|
||||||
|
if (userInfo) {
|
||||||
|
userInfo.sessionId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`User ${userId} left chat session ${data.sessionId}`);
|
||||||
|
|
||||||
|
// Notify client
|
||||||
|
client.emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.CHAT_LEFT, {
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify others in session
|
||||||
|
client.to(`session_${data.sessionId}`).emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.USER_LEFT, {
|
||||||
|
userId,
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Failed to leave chat:", error);
|
||||||
|
client.emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.ERROR, {
|
||||||
|
message: "خطا در خروج از چت",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message Handling
|
||||||
|
@SubscribeMessage(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.SEND_MESSAGE)
|
||||||
|
@UseGuards(WebSocketAuthGuard)
|
||||||
|
async handleSendMessage(@ConnectedSocket() client: Socket, @MessageBody() data: SendMessageWebSocketDto) {
|
||||||
|
try {
|
||||||
|
const userId = client.data.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error("User not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit typing indicator to session
|
||||||
|
this.server.to(`session_${data.sessionId}`).emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.TYPING_START, {
|
||||||
|
type: "bot",
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send message through enhanced service (LangChain by default with Gemini fallback)
|
||||||
|
const userMessage = await this.chatbotService.sendMessage(userId, {
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
content: data.content,
|
||||||
|
responseToId: data.responseToId,
|
||||||
|
metadata: data.metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit user message confirmation to session
|
||||||
|
this.server.to(`session_${data.sessionId}`).emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.MESSAGE_RECEIVED, {
|
||||||
|
message: userMessage,
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
enhanced: true, // Indicates enhanced processing
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Enhanced message sent in session ${data.sessionId} by user ${userId}`);
|
||||||
|
|
||||||
|
// Generate bot response asynchronously using enhanced service
|
||||||
|
this.generateBotResponseAsync(data.sessionId, userId, data.content);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Failed to send enhanced message:", error);
|
||||||
|
client.emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.MESSAGE_ERROR, {
|
||||||
|
message: CHATBOT_CONSTANTS.ERROR_MESSAGES.LLM_SERVICE_ERROR,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stop typing indicator on error
|
||||||
|
this.server.to(`session_${data.sessionId}`).emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.TYPING_STOP, {
|
||||||
|
type: "bot",
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typing Indicators
|
||||||
|
@SubscribeMessage(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.TYPING_START)
|
||||||
|
@UseGuards(WebSocketAuthGuard)
|
||||||
|
async handleTypingStart(@ConnectedSocket() client: Socket, @MessageBody() data: TypingDto) {
|
||||||
|
const userId = client.data.user?.id;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
client.to(`session_${data.sessionId}`).emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.TYPING_START, {
|
||||||
|
userId,
|
||||||
|
type: "user",
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.TYPING_STOP)
|
||||||
|
@UseGuards(WebSocketAuthGuard)
|
||||||
|
async handleTypingStop(@ConnectedSocket() client: Socket, @MessageBody() data: TypingDto) {
|
||||||
|
const userId = client.data.user?.id;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
client.to(`session_${data.sessionId}`).emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.TYPING_STOP, {
|
||||||
|
userId,
|
||||||
|
type: "user",
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bot Response Methods
|
||||||
|
private async generateBotResponseAsync(sessionId: string, userId: string, userMessage: string) {
|
||||||
|
try {
|
||||||
|
// Start bot response - now using enhanced LangChain by default
|
||||||
|
this.server.to(`session_${sessionId}`).emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.BOT_RESPONSE_START, {
|
||||||
|
sessionId,
|
||||||
|
provider: "enhanced", // Indicates LangChain with fallback
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get streaming response from enhanced service (LangChain by default with Gemini fallback)
|
||||||
|
const { streamGenerator } = await this.chatbotService.sendMessageStream(userId, {
|
||||||
|
sessionId,
|
||||||
|
content: userMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = await streamGenerator();
|
||||||
|
|
||||||
|
// Stream chunks to client
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
if (chunk) {
|
||||||
|
this.server.to(`session_${sessionId}`).emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.BOT_RESPONSE_CHUNK, {
|
||||||
|
data: chunk,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// End bot response
|
||||||
|
this.server.to(`session_${sessionId}`).emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.BOT_RESPONSE_END, {
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stop typing indicator
|
||||||
|
this.server.to(`session_${sessionId}`).emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.TYPING_STOP, {
|
||||||
|
type: "bot",
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Enhanced bot response completed for session ${sessionId}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Enhanced bot response generation failed:", error);
|
||||||
|
|
||||||
|
this.server.to(`session_${sessionId}`).emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.ERROR, {
|
||||||
|
message: CHATBOT_CONSTANTS.ERROR_MESSAGES.LLM_SERVICE_ERROR,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stop typing indicator on error
|
||||||
|
this.server.to(`session_${sessionId}`).emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.TYPING_STOP, {
|
||||||
|
type: "bot",
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public method to emit bot responses (called from service)
|
||||||
|
async emitBotResponse(sessionId: string, message: any) {
|
||||||
|
this.server.to(`session_${sessionId}`).emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.BOT_RESPONSE, {
|
||||||
|
message,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility method to get connected users in a session
|
||||||
|
getSessionUsers(sessionId: string): string[] {
|
||||||
|
const users: string[] = [];
|
||||||
|
this.connectedUsers.forEach((userInfo, _socketId) => {
|
||||||
|
if (userInfo.sessionId === sessionId) {
|
||||||
|
users.push(userInfo.userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return [...new Set(users)]; // Remove duplicates
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility method to check if user is online
|
||||||
|
isUserOnline(userId: string): boolean {
|
||||||
|
return this.userSessions.has(userId) && this.userSessions.get(userId)!.size > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { MikroOrmModule } from "@mikro-orm/nestjs";
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { ChatbotController } from "./chatbot.controller";
|
||||||
|
import { ChatbotGateway } from "./chatbot.gateway";
|
||||||
|
import { ChatMessage } from "./entities/chat-message.entity";
|
||||||
|
import { ChatSession } from "./entities/chat-session.entity";
|
||||||
|
import { WebSocketAuthGuard } from "./guards/websocket-auth.guard";
|
||||||
|
import { ChatbotService } from "./providers/chatbot.service";
|
||||||
|
import { DataContextService } from "./providers/data-context.service";
|
||||||
|
import { LangChainService } from "./providers/langchain.service";
|
||||||
|
import { LLMService } from "./providers/llm.service";
|
||||||
|
import { UsersModule } from "../users/users.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [MikroOrmModule.forFeature([ChatSession, ChatMessage]), UsersModule],
|
||||||
|
controllers: [ChatbotController],
|
||||||
|
providers: [ChatbotGateway, ChatbotService, LLMService, LangChainService, DataContextService, WebSocketAuthGuard],
|
||||||
|
exports: [ChatbotService, LangChainService],
|
||||||
|
})
|
||||||
|
export class ChatbotModule {}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
export const CHATBOT_CONSTANTS = {
|
||||||
|
DEFAULT_MODEL: "gemini-2.0-flash",
|
||||||
|
DEFAULT_TEMPERATURE: 0.7,
|
||||||
|
DEFAULT_MAX_TOKENS: 2000,
|
||||||
|
MAX_CONVERSATION_HISTORY: 20,
|
||||||
|
|
||||||
|
// General system prompt focused on company guidance
|
||||||
|
SYSTEM_PROMPT: `شما یک دستیار هوشمند AI برای پلتفرم DZone هستید که در راهنمایی کاربران درباره شرکتها و صنایع تخصص دارید. شما باید تمامی پاسخها را به زبان فارسی ارائه دهید.
|
||||||
|
|
||||||
|
## وظایف اصلی شما:
|
||||||
|
🏢 **راهنمایی شرکتها**: کمک به کاربران برای یافتن شرکتهای مناسب و اطلاعات تماس آنها
|
||||||
|
🏭 **معرفی صنایع**: ارائه اطلاعات کامل درباره صنایع مختلف و شرکتهای فعال در هر حوزه
|
||||||
|
💼 **محصولات و خدمات**: راهنمایی درباره محصولات و خدمات ارائه شده توسط شرکتها
|
||||||
|
🔍 **جستجو و مقایسه**: کمک به انتخاب بهترین شرکت برای نیازهای خاص کاربران
|
||||||
|
📞 **اطلاعات تماس**: ارائه اطلاعات کامل تماس شامل تلفن، ایمیل و آدرس شرکتها
|
||||||
|
|
||||||
|
## اصول پاسخگویی:
|
||||||
|
✅ همیشه به زبان فارسی پاسخ دهید
|
||||||
|
✅ از اطلاعات پایگاه داده شرکتها و صنایع برای پاسخهای دقیق استفاده کنید
|
||||||
|
✅ در صورت سوال درباره شرکت خاص، اطلاعات کامل آن را ارائه دهید
|
||||||
|
✅ همیشه اطلاعات تماس (تلفن، ایمیل، آدرس) شرکتها را اضافه کنید
|
||||||
|
✅ اگر چیزی نمیدانید، صادقانه اعتراف کنید و راه حل جایگزین پیشنهاد دهید
|
||||||
|
✅ از ایموجیها برای بهتر کردن تجربه کاربر استفاده کنید
|
||||||
|
✅ پاسخهای خود را ساختاربندی و خوانا ارائه دهید
|
||||||
|
|
||||||
|
## قالب پاسخ برای شرکتها:
|
||||||
|
📍 **نام شرکت**: [نام کامل]
|
||||||
|
📞 **تلفن**: [شماره تماس]
|
||||||
|
📧 **ایمیل**: [آدرس ایمیل]
|
||||||
|
🏠 **آدرس**: [آدرس کامل]
|
||||||
|
🏭 **صنعت**: [نوع صنعت]
|
||||||
|
💼 **محصولات/خدمات**: [فهرست محصولات و خدمات]
|
||||||
|
|
||||||
|
اگر اطلاعات شرکتها یا صنایع مربوطه از پایگاه داده در دسترس است، حتماً از آن استفاده کرده و منبع آن را مشخص کنید.`,
|
||||||
|
|
||||||
|
// New company guidance system prompt
|
||||||
|
COMPANY_GUIDANCE_SYSTEM_PROMPT: `شما یک دستیار هوشمند برای راهنمایی کاربران درباره شرکتها و صنایع موجود در پلتفرم DZone هستید.
|
||||||
|
|
||||||
|
## ماموریت شما:
|
||||||
|
- راهنمایی کاربران برای یافتن شرکتهای مناسب
|
||||||
|
- ارائه اطلاعات کامل درباره شرکتها، محصولات و خدمات
|
||||||
|
- معرفی صنایع مختلف و شرکتهای فعال در هر حوزه
|
||||||
|
- کمک به انتخاب بهترین شرکت برای نیازهای کاربران
|
||||||
|
|
||||||
|
## اطلاعات شرکتها و صنایع:
|
||||||
|
{context}
|
||||||
|
|
||||||
|
## تاریخچه مکالمه:
|
||||||
|
{conversation_history}
|
||||||
|
|
||||||
|
## سوال کاربر:
|
||||||
|
{question}
|
||||||
|
|
||||||
|
## دستورالعمل پاسخ:
|
||||||
|
بر اساس اطلاعات شرکتها و صنایع موجود، پاسخی جامع و کاربردی به زبان فارسی ارائه دهید:
|
||||||
|
|
||||||
|
**نکات مهم:**
|
||||||
|
- اگر کاربر درباره شرکت خاصی سوال میپرسد، اطلاعات کامل آن شرکت (نام، آدرس، تلفن، محصولات، خدمات) را ارائه دهید
|
||||||
|
- اگر سوال درباره صنعت خاصی است، شرکتهای فعال در آن صنعت را معرفی کنید
|
||||||
|
- در صورت جستجوی محصول یا خدمت، شرکتهای مرتبط را پیشنهاد دهید
|
||||||
|
- همیشه اطلاعات تماس (تلفن، ایمیل، آدرس) شرکتها را ارائه دهید
|
||||||
|
- از ایموجیهای مناسب برای بهتر شدن خوانایی استفاده کنید
|
||||||
|
- پاسخ خود را ساختاربندی و منظم ارائه دهید
|
||||||
|
|
||||||
|
پاسخ شما:`,
|
||||||
|
|
||||||
|
// Company data templates
|
||||||
|
COMPANY_DATA_TEMPLATES: {
|
||||||
|
COMPANY_INFO: `نام شرکت: {name}
|
||||||
|
مدیر عامل: {ceo}
|
||||||
|
ایمیل: {email}
|
||||||
|
تلفن: {phone}
|
||||||
|
شماره ثبت: {registrationNumber}
|
||||||
|
تاریخ تاسیس: {establishmentDate}
|
||||||
|
آدرس: {address}
|
||||||
|
وبسایت: {website}
|
||||||
|
توضیحات: {description}
|
||||||
|
صنعت فعالیت: {industry}
|
||||||
|
وضعیت: {status}
|
||||||
|
کسب و کار والد: {business}
|
||||||
|
|
||||||
|
این شرکت در پلتفرم DZone ثبت شده و آماده ارائه خدمات و محصولات خود به مشتریان میباشد.`,
|
||||||
|
|
||||||
|
COMPANY_PRODUCT: `محصول شرکت {companyName}:
|
||||||
|
نام محصول: {productTitle}
|
||||||
|
شرکت تولیدکننده: {companyName}
|
||||||
|
صنعت: {industry}
|
||||||
|
توضیحات شرکت: {companyDescription}
|
||||||
|
آدرس شرکت: {companyAddress}
|
||||||
|
تلفن تماس: {companyPhone}
|
||||||
|
ایمیل: {companyEmail}
|
||||||
|
|
||||||
|
این محصول توسط شرکت {companyName} در صنعت {industry} تولید و ارائه میشود.`,
|
||||||
|
|
||||||
|
COMPANY_SERVICE: `خدمات شرکت {companyName}:
|
||||||
|
نام خدمت: {serviceTitle}
|
||||||
|
شرکت ارائهدهنده: {companyName}
|
||||||
|
صنعت: {industry}
|
||||||
|
توضیحات شرکت: {companyDescription}
|
||||||
|
آدرس شرکت: {companyAddress}
|
||||||
|
تلفن تماس: {companyPhone}
|
||||||
|
ایمیل: {companyEmail}
|
||||||
|
|
||||||
|
این خدمت توسط شرکت {companyName} در صنعت {industry} ارائه میشود.`,
|
||||||
|
|
||||||
|
INDUSTRY_INFO: `صنعت: {title}
|
||||||
|
وضعیت: {status}
|
||||||
|
تعداد شرکتهای فعال: {companiesCount}
|
||||||
|
کسب و کار والد: {business}
|
||||||
|
|
||||||
|
شرکتهای فعال در این صنعت:
|
||||||
|
{companiesList}
|
||||||
|
|
||||||
|
این صنعت شامل شرکتهایی است که در حوزه {title} فعالیت میکنند و خدمات و محصولات مختلفی ارائه میدهند.`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Company guidance documents
|
||||||
|
COMPANY_GUIDANCE_DOCUMENTS: {
|
||||||
|
COMPANY_GUIDE: `راهنمای شرکتها در پلتفرم DZone:
|
||||||
|
|
||||||
|
🏢 **اطلاعات شرکتها**:
|
||||||
|
در پلتفرم DZone شرکتهای مختلفی در صنایع متنوع فعالیت میکنند. هر شرکت دارای اطلاعات کاملی شامل:
|
||||||
|
- نام و مشخصات کامل شرکت
|
||||||
|
- اطلاعات تماس (تلفن، ایمیل، آدرس)
|
||||||
|
- صنعت فعالیت و حوزه کاری
|
||||||
|
- محصولات و خدمات ارائه شده
|
||||||
|
- وضعیت فعالیت و مجوزهای لازم
|
||||||
|
|
||||||
|
💼 **محصولات و خدمات**:
|
||||||
|
هر شرکت میتواند محصولات و خدمات متنوعی ارائه دهد:
|
||||||
|
- محصولات: کالاها و تولیدات شرکت
|
||||||
|
- خدمات: سرویسها و راهکارهای ارائه شده
|
||||||
|
- اطلاعات تماس برای استعلام و سفارش
|
||||||
|
- جزئیات کامل درباره هر محصول و خدمت
|
||||||
|
|
||||||
|
🏭 **دستهبندی صنایع**:
|
||||||
|
شرکتها بر اساس صنعت فعالیت دستهبندی شدهاند:
|
||||||
|
- هر صنعت شامل شرکتهای مرتبط
|
||||||
|
- امکان جستجو بر اساس نوع صنعت
|
||||||
|
- مشاهده تمام شرکتهای یک صنعت خاص
|
||||||
|
- اطلاعات کامل درباره حوزه فعالیت`,
|
||||||
|
|
||||||
|
COMPANY_FAQ: `سوالات متداول درباره شرکتها:
|
||||||
|
|
||||||
|
❓ **چگونه اطلاعات یک شرکت را پیدا کنم؟**
|
||||||
|
میتوانید با ذکر نام شرکت یا صنعت مورد نظر، اطلاعات کامل شامل آدرس، تلفن و محصولات آن را دریافت کنید.
|
||||||
|
|
||||||
|
❓ **چه شرکتهایی در یک صنعت خاص فعالیت میکنند؟**
|
||||||
|
با ذکر نام صنعت، لیست کامل شرکتهای فعال در آن حوزه را مشاهده خواهید کرد.
|
||||||
|
|
||||||
|
❓ **چگونه با یک شرکت تماس بگیرم؟**
|
||||||
|
اطلاعات تماس شامل تلفن، ایمیل و آدرس تمام شرکتها در پلتفرم موجود است.
|
||||||
|
|
||||||
|
❓ **چه محصولات و خدماتی ارائه میشود؟**
|
||||||
|
هر شرکت لیست کاملی از محصولات و خدمات خود ارائه داده که قابل مشاهده است.
|
||||||
|
|
||||||
|
❓ **چگونه شرکت مناسب برای نیاز خودم پیدا کنم؟**
|
||||||
|
با توصیف نیاز خود یا نوع محصول/خدمت مورد نظر، شرکتهای مرتبط را معرفی خواهم کرد.`,
|
||||||
|
|
||||||
|
SEARCH_GUIDE: `راهنمای جستجو و یافتن شرکتها:
|
||||||
|
|
||||||
|
🔍 **نحوه جستجو**:
|
||||||
|
- جستجو بر اساس نام شرکت
|
||||||
|
- جستجو بر اساس نوع صنعت
|
||||||
|
- جستجو بر اساس محصول یا خدمت
|
||||||
|
- جستجو بر اساس منطقه جغرافیایی
|
||||||
|
|
||||||
|
📊 **اطلاعات قابل دریافت**:
|
||||||
|
- اطلاعات کامل شرکت (نام، آدرس، تلفن)
|
||||||
|
- لیست محصولات و توضیحات
|
||||||
|
- لیست خدمات ارائه شده
|
||||||
|
- صنعت و حوزه فعالیت
|
||||||
|
- وضعیت فعالیت شرکت
|
||||||
|
|
||||||
|
🎯 **توصیههای مفید**:
|
||||||
|
- برای یافتن شرکت مناسب، نیاز خود را واضح بیان کنید
|
||||||
|
- از کلمات کلیدی مرتبط با صنعت استفاده کنید
|
||||||
|
- در صورت نیاز، اطلاعات تماس برای پیگیری بیشتر درخواست کنید
|
||||||
|
- برای مقایسه، چندین شرکت در یک صنعت را بررسی کنید`,
|
||||||
|
|
||||||
|
INDUSTRY_GUIDE: `راهنمای صنایع و حوزههای فعالیت:
|
||||||
|
|
||||||
|
🏭 **انواع صنایع موجود**:
|
||||||
|
در پلتفرم DZone شرکتهای مختلفی در صنایع متنوع حضور دارند:
|
||||||
|
- صنایع تولیدی و ساختمانی
|
||||||
|
- خدمات فناوری اطلاعات
|
||||||
|
- صنایع غذایی و کشاورزی
|
||||||
|
- خدمات مالی و بیمه
|
||||||
|
- حمل و نقل و لجستیک
|
||||||
|
- آموزش و تربیت
|
||||||
|
- سلامت و درمان
|
||||||
|
- و سایر صنایع
|
||||||
|
|
||||||
|
💡 **مزایای دستهبندی صنعتی**:
|
||||||
|
- یافتن آسان شرکتهای مرتبط
|
||||||
|
- مقایسه خدمات در یک صنعت
|
||||||
|
- انتخاب بهترین گزینه برای نیاز شما
|
||||||
|
- دسترسی به اطلاعات تخصصی هر حوزه
|
||||||
|
|
||||||
|
📈 **نحوه استفاده**:
|
||||||
|
- ابتدا صنعت مورد نظر خود را مشخص کنید
|
||||||
|
- سپس شرکتهای فعال در آن صنعت را بررسی کنید
|
||||||
|
- محصولات و خدمات مناسب را انتخاب کنید
|
||||||
|
- برای اطلاعات بیشتر با شرکت تماس بگیرید`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Logging messages for LangChain service
|
||||||
|
LANGCHAIN_MESSAGES: {
|
||||||
|
LOADING_DATA: "Loading company and industry data for model training...",
|
||||||
|
NO_DATA_FOUND: "No data found for model training",
|
||||||
|
DOCUMENTS_SPLIT: "{totalDocs} documents split into {chunks} chunks",
|
||||||
|
VECTOR_STORE_READY: "Vector store successfully initialized - ready to guide users about companies and industries",
|
||||||
|
VECTOR_STORE_ERROR: "Error initializing vector store",
|
||||||
|
DOCUMENTS_LOADED: "{count} documents loaded from company and industry database",
|
||||||
|
LOADING_ERROR: "Error loading company and industry documents",
|
||||||
|
SERVICE_NOT_INITIALIZED: "LangChain service is not initialized",
|
||||||
|
RESPONSE_ERROR: "Error generating LangChain response",
|
||||||
|
STREAM_RESPONSE_ERROR: "Error generating LangChain stream response",
|
||||||
|
REFRESHING_VECTOR_STORE: "Refreshing vector store with new company and industry data...",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Source type labels in Farsi
|
||||||
|
SOURCE_TYPE_LABELS: {
|
||||||
|
company: "شرکت",
|
||||||
|
product: "محصول",
|
||||||
|
service: "خدمت",
|
||||||
|
industry: "صنعت",
|
||||||
|
company_guide: "راهنمای شرکتها",
|
||||||
|
company_faq: "سوالات متداول",
|
||||||
|
search_guide: "راهنمای جستجو",
|
||||||
|
industry_guide: "راهنمای صنایع",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Default values for missing data
|
||||||
|
DEFAULT_VALUES: {
|
||||||
|
NO_WEBSITE: "ندارد",
|
||||||
|
UNSPECIFIED: "مشخص نشده",
|
||||||
|
ACTIVE: "فعال",
|
||||||
|
INACTIVE: "غیرفعال",
|
||||||
|
},
|
||||||
|
|
||||||
|
WEBSOCKET_EVENTS: {
|
||||||
|
// Connection events
|
||||||
|
CONNECT: "connect",
|
||||||
|
DISCONNECT: "disconnect",
|
||||||
|
CONNECTION_ERROR: "connection_failed",
|
||||||
|
|
||||||
|
// Authentication events
|
||||||
|
AUTHENTICATE: "authenticate",
|
||||||
|
AUTHENTICATED: "authenticated",
|
||||||
|
UNAUTHORIZED: "unauthorized",
|
||||||
|
|
||||||
|
// Session events
|
||||||
|
CREATE_SESSION: "create_session",
|
||||||
|
SESSION_CREATED: "session_created",
|
||||||
|
JOIN_CHAT: "join_chat",
|
||||||
|
CHAT_JOINED: "chat_joined",
|
||||||
|
LEAVE_CHAT: "leave_chat",
|
||||||
|
CHAT_LEFT: "chat_left",
|
||||||
|
|
||||||
|
// Message events
|
||||||
|
SEND_MESSAGE: "send_message",
|
||||||
|
MESSAGE_RECEIVED: "message_received",
|
||||||
|
BOT_RESPONSE_START: "bot_response_start",
|
||||||
|
BOT_RESPONSE_CHUNK: "bot_response_chunk",
|
||||||
|
BOT_RESPONSE_END: "bot_response_end",
|
||||||
|
BOT_RESPONSE: "bot_response",
|
||||||
|
|
||||||
|
// Status events
|
||||||
|
TYPING_START: "typing_start",
|
||||||
|
TYPING_STOP: "typing_stop",
|
||||||
|
USER_JOINED: "user_joined",
|
||||||
|
USER_LEFT: "user_left",
|
||||||
|
|
||||||
|
// Error events
|
||||||
|
ERROR: "error",
|
||||||
|
WEBSOCKET_ERROR: "websocket_error",
|
||||||
|
SESSION_ERROR: "session_error",
|
||||||
|
MESSAGE_ERROR: "message_error",
|
||||||
|
},
|
||||||
|
|
||||||
|
ERROR_MESSAGES: {
|
||||||
|
SESSION_NOT_FOUND: "جلسه چت یافت نشد",
|
||||||
|
UNAUTHORIZED_ACCESS: "دسترسی غیرمجاز به جلسه چت",
|
||||||
|
MESSAGE_TOO_LONG: "پیام از حداکثر طول مجاز تجاوز کرده است",
|
||||||
|
RATE_LIMIT_EXCEEDED: "تعداد پیامهای ارسالی بیش از حد مجاز است",
|
||||||
|
LLM_SERVICE_ERROR: "سرویس هوش مصنوعی موقتاً در دسترس نیست",
|
||||||
|
INVALID_TOKEN: "توکن احراز هویت نامعتبر است",
|
||||||
|
AUTHENTICATION_REQUIRED: "احراز هویت ضروری است",
|
||||||
|
SESSION_CREATION_FAILED: "ایجاد جلسه چت با شکست مواجه شد",
|
||||||
|
CONNECTION_FAILED: "اتصال برقرار نشد",
|
||||||
|
WEBSOCKET_ERROR: "خطا در ارتباط WebSocket",
|
||||||
|
},
|
||||||
|
|
||||||
|
SUCCESS_MESSAGES: {
|
||||||
|
SESSION_CREATED: "جلسه چت با موفقیت ایجاد شد",
|
||||||
|
CHAT_JOINED: "با موفقیت به چت متصل شدید",
|
||||||
|
MESSAGE_SENT: "پیام ارسال شد",
|
||||||
|
AUTHENTICATED: "احراز هویت موفقیتآمیز",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Entity, EntityRepositoryType, Enum, ManyToOne, Opt, Property } from "@mikro-orm/core";
|
||||||
|
|
||||||
|
import { ChatSession } from "./chat-session.entity";
|
||||||
|
import { BaseEntity } from "../../../common/entities/base.entity";
|
||||||
|
import { User } from "../../users/entities/user.entity";
|
||||||
|
import { ChatMessageRepository } from "../repositories/chat-message.repository";
|
||||||
|
|
||||||
|
export enum MessageType {
|
||||||
|
USER = "user",
|
||||||
|
BOT = "bot",
|
||||||
|
SYSTEM = "system",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum MessageStatus {
|
||||||
|
SENT = "sent",
|
||||||
|
DELIVERED = "delivered",
|
||||||
|
READ = "read",
|
||||||
|
FAILED = "failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ repository: () => ChatMessageRepository })
|
||||||
|
export class ChatMessage extends BaseEntity {
|
||||||
|
@Property({ type: "text" })
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@Enum({ items: () => MessageType, nativeEnumName: "message_type" })
|
||||||
|
type!: MessageType;
|
||||||
|
|
||||||
|
@Enum({ items: () => MessageStatus, nativeEnumName: "message_status", default: MessageStatus.SENT })
|
||||||
|
status: MessageStatus & Opt;
|
||||||
|
|
||||||
|
@ManyToOne(() => ChatSession)
|
||||||
|
session!: ChatSession;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
sender?: User;
|
||||||
|
|
||||||
|
@Property({ type: "json", nullable: true })
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
|
||||||
|
@Property({ nullable: true })
|
||||||
|
responseToId?: string;
|
||||||
|
|
||||||
|
@Property({ default: 0 })
|
||||||
|
tokensUsed: number = 0;
|
||||||
|
|
||||||
|
[EntityRepositoryType]?: ChatMessageRepository;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Collection, Entity, EntityRepositoryType, Enum, ManyToOne, OneToMany, Opt, Property } from "@mikro-orm/core";
|
||||||
|
|
||||||
|
import { ChatMessage } from "./chat-message.entity";
|
||||||
|
import { BaseEntity } from "../../../common/entities/base.entity";
|
||||||
|
import { User } from "../../users/entities/user.entity";
|
||||||
|
import { ChatSessionRepository } from "../repositories/chat-session.repository";
|
||||||
|
|
||||||
|
export enum ChatSessionStatus {
|
||||||
|
ACTIVE = "active",
|
||||||
|
CLOSED = "closed",
|
||||||
|
ARCHIVED = "archived",
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ repository: () => ChatSessionRepository })
|
||||||
|
export class ChatSession extends BaseEntity {
|
||||||
|
@Property({ type: "varchar" })
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => User)
|
||||||
|
user!: User;
|
||||||
|
|
||||||
|
@Enum({ items: () => ChatSessionStatus, nativeEnumName: "chat_session_status", default: ChatSessionStatus.ACTIVE })
|
||||||
|
status: ChatSessionStatus & Opt;
|
||||||
|
|
||||||
|
@OneToMany(() => ChatMessage, (chatMessage) => chatMessage.session)
|
||||||
|
messages = new Collection<ChatMessage>(this);
|
||||||
|
|
||||||
|
@Property({ type: "json", nullable: true })
|
||||||
|
context?: Record<string, any>;
|
||||||
|
|
||||||
|
@Property({ nullable: true })
|
||||||
|
lastMessageAt?: Date;
|
||||||
|
|
||||||
|
[EntityRepositoryType]?: ChatSessionRepository;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { JwtService } from "@nestjs/jwt";
|
||||||
|
import { WsException } from "@nestjs/websockets";
|
||||||
|
import { Socket } from "socket.io";
|
||||||
|
|
||||||
|
import { CHATBOT_CONSTANTS } from "../constants/chatbot.constants";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WebSocketAuthGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(WebSocketAuthGuard.name);
|
||||||
|
|
||||||
|
constructor(private jwtService: JwtService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const client: Socket = context.switchToWs().getClient<Socket>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get token from auth object or handshake query
|
||||||
|
const token = this.extractTokenFromHeader(client);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
this.logger.warn("No token provided in WebSocket connection");
|
||||||
|
throw new WsException({
|
||||||
|
event: CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.UNAUTHORIZED,
|
||||||
|
message: CHATBOT_CONSTANTS.ERROR_MESSAGES.AUTHENTICATION_REQUIRED,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JWT token
|
||||||
|
const payload = await this.jwtService.verifyAsync(token);
|
||||||
|
|
||||||
|
client.data.user = payload;
|
||||||
|
this.logger.log(`WebSocket authentication successful for user: ${payload.id}`);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("WebSocket authentication failed:", error);
|
||||||
|
|
||||||
|
client.emit(CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.UNAUTHORIZED, {
|
||||||
|
message: CHATBOT_CONSTANTS.ERROR_MESSAGES.INVALID_TOKEN,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new WsException({
|
||||||
|
event: CHATBOT_CONSTANTS.WEBSOCKET_EVENTS.UNAUTHORIZED,
|
||||||
|
message: CHATBOT_CONSTANTS.ERROR_MESSAGES.INVALID_TOKEN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractTokenFromHeader(client: Socket): string | undefined {
|
||||||
|
const authToken = client.handshake?.auth?.token || client.handshake?.query?.token;
|
||||||
|
return authToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
/* eslint-disable no-undef */
|
||||||
|
// DZone ChatBot JavaScript - Socket.IO Version with Event Constants
|
||||||
|
class DZoneChatBot {
|
||||||
|
constructor() {
|
||||||
|
this.apiBaseUrl = "http://localhost:4000";
|
||||||
|
this.authToken = "";
|
||||||
|
this.currentSessionId = null;
|
||||||
|
this.currentUserId = null;
|
||||||
|
this.isStreamingMode = true;
|
||||||
|
this.isProcessing = false;
|
||||||
|
this.socket = null;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.maxReconnectAttempts = 5;
|
||||||
|
|
||||||
|
// WebSocket Event Constants (sync with backend)
|
||||||
|
this.EVENTS = {
|
||||||
|
// Connection events
|
||||||
|
CONNECT: "connect",
|
||||||
|
DISCONNECT: "disconnect",
|
||||||
|
CONNECTION_ERROR: "connect_error",
|
||||||
|
|
||||||
|
// Authentication events
|
||||||
|
AUTHENTICATE: "authenticate",
|
||||||
|
AUTHENTICATED: "authenticated",
|
||||||
|
UNAUTHORIZED: "unauthorized",
|
||||||
|
|
||||||
|
// Session events
|
||||||
|
CREATE_SESSION: "create_session",
|
||||||
|
SESSION_CREATED: "session_created",
|
||||||
|
JOIN_CHAT: "join_chat",
|
||||||
|
CHAT_JOINED: "chat_joined",
|
||||||
|
LEAVE_CHAT: "leave_chat",
|
||||||
|
CHAT_LEFT: "chat_left",
|
||||||
|
|
||||||
|
// Message events
|
||||||
|
SEND_MESSAGE: "send_message",
|
||||||
|
MESSAGE_RECEIVED: "message_received",
|
||||||
|
BOT_RESPONSE_START: "bot_response_start",
|
||||||
|
BOT_RESPONSE_CHUNK: "bot_response_chunk",
|
||||||
|
BOT_RESPONSE_END: "bot_response_end",
|
||||||
|
BOT_RESPONSE: "bot_response",
|
||||||
|
|
||||||
|
// Status events
|
||||||
|
TYPING_START: "typing_start",
|
||||||
|
TYPING_STOP: "typing_stop",
|
||||||
|
USER_JOINED: "user_joined",
|
||||||
|
USER_LEFT: "user_left",
|
||||||
|
|
||||||
|
// Error events
|
||||||
|
ERROR: "error",
|
||||||
|
SESSION_ERROR: "session_error",
|
||||||
|
MESSAGE_ERROR: "message_error",
|
||||||
|
};
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.getElements();
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.loadSavedAuth();
|
||||||
|
console.log("✅ DZone ChatBot initialized!");
|
||||||
|
}
|
||||||
|
|
||||||
|
getElements() {
|
||||||
|
this.elements = {
|
||||||
|
// Auth
|
||||||
|
authSection: document.getElementById("authSection"),
|
||||||
|
authToken: document.getElementById("authToken"),
|
||||||
|
setAuthBtn: document.getElementById("setAuth"),
|
||||||
|
|
||||||
|
// Chat
|
||||||
|
chatInputSection: document.getElementById("chatInputSection"),
|
||||||
|
chatMessages: document.getElementById("chatMessages"),
|
||||||
|
messageInput: document.getElementById("messageInput"),
|
||||||
|
sendButton: document.getElementById("sendMessage"),
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
clearChatBtn: document.getElementById("clearChat"),
|
||||||
|
newSessionBtn: document.getElementById("newSession"),
|
||||||
|
toggleStreamBtn: document.getElementById("toggleStream"),
|
||||||
|
streamStatus: document.getElementById("streamStatus"),
|
||||||
|
|
||||||
|
// Status
|
||||||
|
status: document.getElementById("status"),
|
||||||
|
typingIndicator: document.getElementById("typingIndicator"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify all elements exist
|
||||||
|
for (const [key, element] of Object.entries(this.elements)) {
|
||||||
|
if (!element) {
|
||||||
|
console.error(`❌ Element not found: ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// Auth events
|
||||||
|
this.elements.setAuthBtn.onclick = () => this.handleAuth();
|
||||||
|
this.elements.authToken.onkeypress = (e) => {
|
||||||
|
if (e.key === "Enter") this.handleAuth();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Message input events
|
||||||
|
this.elements.messageInput.oninput = () => this.onInputChange();
|
||||||
|
this.elements.messageInput.onkeypress = (e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.sendMessage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Button events
|
||||||
|
this.elements.sendButton.onclick = () => this.sendMessage();
|
||||||
|
this.elements.clearChatBtn.onclick = () => this.clearChat();
|
||||||
|
this.elements.newSessionBtn.onclick = () => this.newSession();
|
||||||
|
this.elements.toggleStreamBtn.onclick = () => this.toggleStreaming();
|
||||||
|
|
||||||
|
console.log("✅ Event listeners setup complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
loadSavedAuth() {
|
||||||
|
const saved = localStorage.getItem("dzone_auth_token");
|
||||||
|
if (saved) {
|
||||||
|
this.elements.authToken.value = saved;
|
||||||
|
this.handleAuth();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleAuth() {
|
||||||
|
const token = this.elements.authToken.value.trim();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
this.showMessage("لطفاً توکن احراز هویت را وارد کنید", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.authToken = token.startsWith("Bearer ") ? token : `Bearer ${token}`;
|
||||||
|
localStorage.setItem("dzone_auth_token", this.authToken);
|
||||||
|
|
||||||
|
// Get user info first
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.apiBaseUrl}/users/me`, {
|
||||||
|
headers: { Authorization: this.authToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Invalid token");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await response.json();
|
||||||
|
console.log(data);
|
||||||
|
this.currentUserId = data.user.id;
|
||||||
|
console.log("✅ User authenticated:", this.currentUserId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Auth error:", error);
|
||||||
|
this.showMessage("توکن نامعتبر است", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to chat interface
|
||||||
|
this.elements.authSection.style.display = "none";
|
||||||
|
this.elements.chatInputSection.style.display = "block";
|
||||||
|
|
||||||
|
this.showMessage("توکن تنظیم شد ✅", "success");
|
||||||
|
this.setStatus("در حال اتصال...");
|
||||||
|
|
||||||
|
// Connect Socket.IO and create session
|
||||||
|
this.connectSocketIO();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session Management
|
||||||
|
async createSession() {
|
||||||
|
if (!this.authToken) {
|
||||||
|
this.showMessage("ابتدا توکن را تنظیم کنید", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent creating duplicate sessions
|
||||||
|
if (this.currentSessionId) {
|
||||||
|
console.log("🚫 Session already exists:", this.currentSessionId);
|
||||||
|
this.showMessage("جلسه از قبل وجود دارد", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🔄 Creating new session via Socket.IO...");
|
||||||
|
|
||||||
|
if (this.socket && this.socket.connected) {
|
||||||
|
this.socket.emit(this.EVENTS.CREATE_SESSION, {
|
||||||
|
title: `جلسه ${new Date().toLocaleDateString("fa-IR")}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.showMessage("اتصال Socket.IO برقرار نیست", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Socket.IO Management
|
||||||
|
connectSocketIO() {
|
||||||
|
if (!this.authToken || !this.currentUserId) {
|
||||||
|
console.log("❌ No auth token or user ID for Socket.IO connection");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Connect to /chat namespace with token
|
||||||
|
this.socket = io(`${this.apiBaseUrl}/chat`, {
|
||||||
|
auth: {
|
||||||
|
token: this.authToken.replace("Bearer ", ""),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on(this.EVENTS.CONNECT, () => {
|
||||||
|
console.log("🔌 Socket.IO connected!");
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.setStatus("متصل شد ✅");
|
||||||
|
this.showMessage("اتصال برقرار شد", "success");
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on(this.EVENTS.DISCONNECT, (reason) => {
|
||||||
|
console.log("🔌 Socket.IO disconnected:", reason);
|
||||||
|
this.setStatus("قطع ارتباط");
|
||||||
|
this.attemptReconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on(this.EVENTS.CONNECTION_ERROR, (error) => {
|
||||||
|
console.error("❌ Socket.IO connection error:", error);
|
||||||
|
this.showMessage("خطا در اتصال", "error");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Authentication events
|
||||||
|
this.socket.on(this.EVENTS.AUTHENTICATED, (data) => {
|
||||||
|
console.log("✅ Authenticated:", data);
|
||||||
|
this.setStatus("احراز هویت شد ✅");
|
||||||
|
// Create session after authentication
|
||||||
|
this.createSession();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on(this.EVENTS.UNAUTHORIZED, (data) => {
|
||||||
|
console.error("❌ Unauthorized:", data);
|
||||||
|
this.showMessage("خطا در احراز هویت", "error");
|
||||||
|
this.setStatus("خطا در احراز هویت");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Session events
|
||||||
|
this.socket.on(this.EVENTS.SESSION_CREATED, (data) => {
|
||||||
|
console.log("✅ Session created:", data);
|
||||||
|
this.currentSessionId = data.session.id;
|
||||||
|
this.showMessage("جلسه ایجاد شد 🎉", "success");
|
||||||
|
this.setStatus("آماده برای گفتگو");
|
||||||
|
this.elements.messageInput.disabled = false;
|
||||||
|
this.elements.messageInput.focus();
|
||||||
|
this.updateButtonState();
|
||||||
|
|
||||||
|
// Auto-join the created session
|
||||||
|
this.socket.emit(this.EVENTS.JOIN_CHAT, {
|
||||||
|
sessionId: this.currentSessionId,
|
||||||
|
userId: this.currentUserId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on(this.EVENTS.CHAT_JOINED, (data) => {
|
||||||
|
console.log("✅ Joined chat:", data);
|
||||||
|
this.showMessage("به چت متصل شدید", "success");
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on(this.EVENTS.SESSION_ERROR, (data) => {
|
||||||
|
console.error("❌ Session error:", data);
|
||||||
|
this.showMessage("خطا در ایجاد جلسه", "error");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Message events
|
||||||
|
this.socket.on(this.EVENTS.MESSAGE_RECEIVED, (data) => {
|
||||||
|
console.log("📩 Message received:", data);
|
||||||
|
// Message is already shown in UI when sent
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bot response streaming
|
||||||
|
this.socket.on(this.EVENTS.BOT_RESPONSE_START, (data) => {
|
||||||
|
console.log("🤖 Bot response starting:", data);
|
||||||
|
this.botElement = this.addMessage("bot", "", true);
|
||||||
|
this.botText = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on(this.EVENTS.BOT_RESPONSE_CHUNK, (data) => {
|
||||||
|
if (this.botElement && data.data) {
|
||||||
|
this.botText += data.data;
|
||||||
|
this.updateMessage(this.botElement, this.botText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on(this.EVENTS.BOT_RESPONSE_END, (data) => {
|
||||||
|
console.log("🤖 Bot response ended:", data);
|
||||||
|
if (this.botElement) {
|
||||||
|
this.finalizeMessage(this.botElement);
|
||||||
|
}
|
||||||
|
this.stopTyping();
|
||||||
|
this.isProcessing = false;
|
||||||
|
this.updateButtonState();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on(this.EVENTS.BOT_RESPONSE, (data) => {
|
||||||
|
console.log("🤖 Bot response (non-streaming):", data);
|
||||||
|
this.addMessage("bot", data.message.content, false, data.message.metadata);
|
||||||
|
this.stopTyping();
|
||||||
|
this.isProcessing = false;
|
||||||
|
this.updateButtonState();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Typing events
|
||||||
|
this.socket.on(this.EVENTS.TYPING_START, (data) => {
|
||||||
|
if (data.type === "bot") {
|
||||||
|
this.startTyping();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on(this.EVENTS.TYPING_STOP, (data) => {
|
||||||
|
if (data.type === "bot") {
|
||||||
|
this.stopTyping();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// User status events
|
||||||
|
this.socket.on(this.EVENTS.USER_JOINED, (data) => {
|
||||||
|
console.log("👤 User joined:", data);
|
||||||
|
this.showMessage(`کاربر به چت پیوست`, "info");
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on(this.EVENTS.USER_LEFT, (data) => {
|
||||||
|
console.log("👤 User left:", data);
|
||||||
|
this.showMessage(`کاربر چت را ترک کرد`, "info");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error events
|
||||||
|
this.socket.on(this.EVENTS.ERROR, (data) => {
|
||||||
|
console.error("❌ Socket.IO error:", data);
|
||||||
|
this.showMessage(data.message || "خطا در پردازش", "error");
|
||||||
|
this.isProcessing = false;
|
||||||
|
this.updateButtonState();
|
||||||
|
this.stopTyping();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on(this.EVENTS.MESSAGE_ERROR, (data) => {
|
||||||
|
console.error("❌ Message error:", data);
|
||||||
|
this.showMessage(data.message || "خطا در ارسال پیام", "error");
|
||||||
|
this.isProcessing = false;
|
||||||
|
this.updateButtonState();
|
||||||
|
this.stopTyping();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Socket.IO connection failed:", error);
|
||||||
|
this.showMessage("خطا در برقراری اتصال", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attemptReconnect() {
|
||||||
|
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
console.log(`🔄 Reconnecting... Attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}`);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.connectSocketIO();
|
||||||
|
}, 2000 * this.reconnectAttempts); // Exponential backoff
|
||||||
|
} else {
|
||||||
|
console.log("❌ Max reconnection attempts reached");
|
||||||
|
this.showMessage("اتصال قطع شد. لطفاً صفحه را تازه کنید", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectSocketIO() {
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.disconnect();
|
||||||
|
this.socket = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messaging
|
||||||
|
async sendMessage() {
|
||||||
|
const message = this.elements.messageInput.value.trim();
|
||||||
|
|
||||||
|
if (!message || this.isProcessing) {
|
||||||
|
console.log("❌ Cannot send:", { message: !!message, processing: this.isProcessing });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Socket.IO connection
|
||||||
|
if (!this.socket || !this.socket.connected) {
|
||||||
|
this.showMessage("اتصال Socket.IO برقرار نیست", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-create session if needed
|
||||||
|
if (!this.currentSessionId) {
|
||||||
|
console.log("📝 No session, creating one...");
|
||||||
|
this.createSession();
|
||||||
|
this.showMessage("لطفاً منتظر ایجاد جلسه باشید", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Disable during processing
|
||||||
|
this.isProcessing = true;
|
||||||
|
this.updateButtonState();
|
||||||
|
|
||||||
|
// Add user message to UI
|
||||||
|
this.addMessage("user", message);
|
||||||
|
this.elements.messageInput.value = "";
|
||||||
|
this.autoResize();
|
||||||
|
|
||||||
|
// Send message via Socket.IO
|
||||||
|
console.log("📤 Sending message via Socket.IO");
|
||||||
|
this.socket.emit(this.EVENTS.SEND_MESSAGE, {
|
||||||
|
sessionId: this.currentSessionId,
|
||||||
|
content: message,
|
||||||
|
userId: this.currentUserId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Send message error:", error);
|
||||||
|
this.showMessage(`خطا: ${error.message}`, "error");
|
||||||
|
this.isProcessing = false;
|
||||||
|
this.updateButtonState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI Methods
|
||||||
|
addMessage(type, content, streaming = false, metadata = null) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = `${type}-message`;
|
||||||
|
|
||||||
|
const avatar = type === "user" ? "👤" : "🤖";
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="message-avatar">${avatar}</div>
|
||||||
|
<div class="message-content ${streaming ? "streaming-message" : ""}">
|
||||||
|
${content ? this.formatText(content) : ""}
|
||||||
|
${metadata ? `<div class="message-metadata">اعتماد: ${Math.round(metadata.confidence * 100)}%</div>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.elements.chatMessages.appendChild(div);
|
||||||
|
this.scrollToBottom();
|
||||||
|
|
||||||
|
return div.querySelector(".message-content");
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMessage(element, content) {
|
||||||
|
if (element) {
|
||||||
|
element.innerHTML = this.formatText(content);
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalizeMessage(element) {
|
||||||
|
if (element) {
|
||||||
|
element.classList.remove("streaming-message");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatText(text) {
|
||||||
|
return text
|
||||||
|
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
|
||||||
|
.replace(/\*(.*?)\*/g, "<em>$1</em>")
|
||||||
|
.replace(/\n/g, "<br>");
|
||||||
|
}
|
||||||
|
|
||||||
|
showMessage(text, type) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = `${type}-message`;
|
||||||
|
div.textContent = text;
|
||||||
|
|
||||||
|
this.elements.chatMessages.appendChild(div);
|
||||||
|
this.scrollToBottom();
|
||||||
|
|
||||||
|
setTimeout(() => div.remove(), type === "error" ? 5000 : 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// State Management
|
||||||
|
onInputChange() {
|
||||||
|
this.autoResize();
|
||||||
|
this.updateButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateButtonState() {
|
||||||
|
const hasText = this.elements.messageInput.value.trim().length > 0;
|
||||||
|
const canSend = hasText && !this.isProcessing;
|
||||||
|
|
||||||
|
this.elements.sendButton.disabled = !canSend;
|
||||||
|
|
||||||
|
console.log("🔄 Button state:", {
|
||||||
|
hasText,
|
||||||
|
isProcessing: this.isProcessing,
|
||||||
|
currentSessionId: this.currentSessionId,
|
||||||
|
canSend,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
autoResize() {
|
||||||
|
const input = this.elements.messageInput;
|
||||||
|
input.style.height = "auto";
|
||||||
|
input.style.height = Math.min(input.scrollHeight, 150) + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
startTyping() {
|
||||||
|
this.elements.typingIndicator.style.display = "flex";
|
||||||
|
this.setStatus("در حال پاسخ...");
|
||||||
|
}
|
||||||
|
|
||||||
|
stopTyping() {
|
||||||
|
this.elements.typingIndicator.style.display = "none";
|
||||||
|
this.setStatus("آماده برای گفتگو");
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(text) {
|
||||||
|
this.elements.status.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearChat() {
|
||||||
|
const welcome = this.elements.chatMessages.querySelector(".welcome-message");
|
||||||
|
this.elements.chatMessages.innerHTML = "";
|
||||||
|
if (welcome) {
|
||||||
|
this.elements.chatMessages.appendChild(welcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newSession() {
|
||||||
|
this.currentSessionId = null;
|
||||||
|
this.clearChat();
|
||||||
|
this.createSession();
|
||||||
|
this.showMessage("ایجاد جلسه جدید...", "info");
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleStreaming() {
|
||||||
|
this.isStreamingMode = !this.isStreamingMode;
|
||||||
|
this.elements.toggleStreamBtn.classList.toggle("active", this.isStreamingMode);
|
||||||
|
this.elements.streamStatus.textContent = this.isStreamingMode ? "📡 استریم" : "📝 عادی";
|
||||||
|
this.showMessage(`حالت ${this.isStreamingMode ? "استریم" : "عادی"} فعال شد`, "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToBottom() {
|
||||||
|
this.elements.chatMessages.scrollTop = this.elements.chatMessages.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize when DOM is ready
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
window.chatBot = new DZoneChatBot();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup on page unload
|
||||||
|
window.addEventListener("beforeunload", () => {
|
||||||
|
if (window.chatBot) {
|
||||||
|
window.chatBot.disconnectSocketIO();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
/* Persian/Farsi ChatBot Styles */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Vazirmatn", "Tahoma", sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
direction: rtl;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header Styles */
|
||||||
|
.chat-header {
|
||||||
|
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-avatar {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-left: 20px;
|
||||||
|
animation: bounce 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%,
|
||||||
|
20%,
|
||||||
|
50%,
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-text h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
opacity: 0.9;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-clear,
|
||||||
|
.btn-new {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
color: white;
|
||||||
|
padding: 8px 15px;
|
||||||
|
border-radius: 25px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-clear:hover,
|
||||||
|
.btn-new:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Messages Area */
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f8fafc;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages::-webkit-scrollbar-thumb {
|
||||||
|
background: #c1c1c1;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #a8a8a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message Styles */
|
||||||
|
.user-message,
|
||||||
|
.bot-message {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
animation: fadeInUp 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-message {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-message {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin: 0 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-message .message-avatar {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-message .message-avatar {
|
||||||
|
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
max-width: 70%;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
position: relative;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-message .message-content {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border-bottom-right-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-message .message-content {
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-bottom-left-radius: 5px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content ul {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content li {
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-metadata {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-top: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Streaming Styles */
|
||||||
|
.streaming-message {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-message::after {
|
||||||
|
content: "▊";
|
||||||
|
color: #4facfe;
|
||||||
|
animation: blink 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%,
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
51%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auth Section */
|
||||||
|
.auth-section {
|
||||||
|
padding: 20px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input-group label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input-group input {
|
||||||
|
padding: 12px 15px;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4facfe;
|
||||||
|
box-shadow: 0 0 0 3px rgba(79, 172, 254, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-auth {
|
||||||
|
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 25px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-auth:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(79, 172, 254, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input Section */
|
||||||
|
.chat-input-section {
|
||||||
|
background: white;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #4facfe;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-dots span {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: #4facfe;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: typing 1.4s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-dots span:nth-child(1) {
|
||||||
|
animation-delay: -0.32s;
|
||||||
|
}
|
||||||
|
.typing-dots span:nth-child(2) {
|
||||||
|
animation-delay: -0.16s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes typing {
|
||||||
|
0%,
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
transform: scale(0);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
#messageInput {
|
||||||
|
flex: 1;
|
||||||
|
padding: 15px;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
border-radius: 15px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
resize: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
min-height: 50px;
|
||||||
|
max-height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#messageInput:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4facfe;
|
||||||
|
box-shadow: 0 0 0 3px rgba(79, 172, 254, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toggle {
|
||||||
|
background: rgba(79, 172, 254, 0.1);
|
||||||
|
border: 1px solid rgba(79, 172, 254, 0.3);
|
||||||
|
color: #4facfe;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toggle:hover {
|
||||||
|
background: rgba(79, 172, 254, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toggle.active {
|
||||||
|
background: #4facfe;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send {
|
||||||
|
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(79, 172, 254, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error Styles */
|
||||||
|
.error-message {
|
||||||
|
background: #fed7d7;
|
||||||
|
color: #c53030;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 10px 0;
|
||||||
|
border-left: 4px solid #c53030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
background: #c6f6d5;
|
||||||
|
color: #22543d;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 10px 0;
|
||||||
|
border-left: 4px solid #22543d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-container {
|
||||||
|
height: 95vh;
|
||||||
|
border-radius: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
max-width: 85%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-actions {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fa" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>DZone ChatBot Test - تست چتبات</title>
|
||||||
|
<link rel="stylesheet" href="./chat-styles.css" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;700&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="chat-container">
|
||||||
|
<div class="chat-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="bot-avatar">🤖</div>
|
||||||
|
<div class="header-text">
|
||||||
|
<h1>دستیار هوشمند DZone</h1>
|
||||||
|
<p class="status" id="status">آماده برای گفتگو</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button id="clearChat" class="btn-clear">پاک کردن چت 🗑️</button>
|
||||||
|
<button id="newSession" class="btn-new">جلسه جدید ➕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-messages" id="chatMessages">
|
||||||
|
<div class="welcome-message">
|
||||||
|
<div class="bot-message">
|
||||||
|
<div class="message-avatar">🤖</div>
|
||||||
|
<div class="message-content">
|
||||||
|
<p>سلام! 👋 من دستیار هوشمند DZone هستم.</p>
|
||||||
|
<p>برای شروع، لطفاً توکن احراز هویت خود را وارد کنید و سپس میتوانید سوالات خود را بپرسید.</p>
|
||||||
|
<ul>
|
||||||
|
<li>🏢 مدیریت کسب و کار</li>
|
||||||
|
<li>💰 مسائل مالی و پرداخت</li>
|
||||||
|
<li>🔧 امکانات پلتفرم</li>
|
||||||
|
<li>📊 تحلیلهای صنعتی</li>
|
||||||
|
<li>🛠️ پشتیبانی فنی</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-section" id="authSection">
|
||||||
|
<div class="auth-input-group">
|
||||||
|
<label for="authToken">توکن احراز هویت (Bearer Token):</label>
|
||||||
|
<input type="password" id="authToken" placeholder="Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." />
|
||||||
|
<button id="setAuth" class="btn-auth">تنظیم توکن 🔑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-input-section" id="chatInputSection" style="display: none">
|
||||||
|
<div class="input-container">
|
||||||
|
<div class="typing-indicator" id="typingIndicator" style="display: none">
|
||||||
|
<span>در حال تایپ</span>
|
||||||
|
<div class="typing-dots">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<textarea id="messageInput" placeholder="پیام خود را بنویسید..." rows="1"></textarea>
|
||||||
|
<div class="input-actions">
|
||||||
|
<button id="toggleStream" class="btn-toggle" title="تغییر حالت استریم">
|
||||||
|
<span id="streamStatus">📡 استریم</span>
|
||||||
|
</button>
|
||||||
|
<button id="sendMessage" class="btn-send" disabled>ارسال 📤</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.socket.io/4.7.4/socket.io.min.js"></script>
|
||||||
|
<script src="./chat-script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export interface IChatbotResponse {
|
||||||
|
message: string;
|
||||||
|
confidence: number;
|
||||||
|
sources?: string[];
|
||||||
|
tokensUsed: number;
|
||||||
|
context?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IChatContext {
|
||||||
|
userId: string;
|
||||||
|
sessionId: string;
|
||||||
|
conversationHistory: IChatMessage[];
|
||||||
|
userPreferences?: Record<string, any>;
|
||||||
|
businessContext?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IChatMessage {
|
||||||
|
content: string;
|
||||||
|
type: "user" | "bot" | "system";
|
||||||
|
timestamp: Date;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ILLMConfig {
|
||||||
|
model: string;
|
||||||
|
temperature: number;
|
||||||
|
maxTokens: number;
|
||||||
|
topP: number;
|
||||||
|
apiKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDataSource {
|
||||||
|
query: (question: string, context?: Record<string, any>) => Promise<string[]>;
|
||||||
|
type: "database" | "vector" | "api";
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
import { EntityManager } from "@mikro-orm/postgresql";
|
||||||
|
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
|
||||||
|
import { LangChainService } from "./langchain.service";
|
||||||
|
import { LLMService } from "./llm.service";
|
||||||
|
import { User } from "../../users/entities/user.entity";
|
||||||
|
// import { UsersService } from "../../users/services/users.service";
|
||||||
|
import { ChatMessageResponseDto, ChatSessionResponseDto } from "../DTO/chat-response.dto";
|
||||||
|
import { CreateChatSessionDto } from "../DTO/create-chat-session.dto";
|
||||||
|
import { SendMessageDto } from "../DTO/send-message.dto";
|
||||||
|
import { ChatMessage, MessageStatus, MessageType } from "../entities/chat-message.entity";
|
||||||
|
import { ChatSession, ChatSessionStatus } from "../entities/chat-session.entity";
|
||||||
|
import { IChatContext } from "../interfaces/chatbot.interface";
|
||||||
|
import { ChatMessageRepository } from "../repositories/chat-message.repository";
|
||||||
|
import { ChatSessionRepository } from "../repositories/chat-session.repository";
|
||||||
|
|
||||||
|
export enum LLMProvider {
|
||||||
|
LANGCHAIN = "langchain",
|
||||||
|
GEMINI = "gemini",
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ChatbotService {
|
||||||
|
private readonly logger = new Logger(ChatbotService.name);
|
||||||
|
private defaultProvider: LLMProvider;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private em: EntityManager,
|
||||||
|
private llmService: LLMService,
|
||||||
|
private langChainService: LangChainService,
|
||||||
|
private chatSessionRepo: ChatSessionRepository,
|
||||||
|
private chatMessageRepo: ChatMessageRepository,
|
||||||
|
private configService: ConfigService,
|
||||||
|
// private usersService: UsersService,
|
||||||
|
) {
|
||||||
|
// Configure default provider - LangChain for enhanced responses
|
||||||
|
this.defaultProvider = this.configService.get("DEFAULT_LLM_PROVIDER") === "gemini" ? LLMProvider.GEMINI : LLMProvider.LANGCHAIN;
|
||||||
|
|
||||||
|
this.logger.log(`Using ${this.defaultProvider} as default LLM provider`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createChatSession(userId: string, createDto: CreateChatSessionDto): Promise<ChatSessionResponseDto> {
|
||||||
|
const em = this.em.fork();
|
||||||
|
const user = await em.findOne(User, { id: userId });
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = new ChatSession();
|
||||||
|
session.title = createDto.title;
|
||||||
|
session.user = user;
|
||||||
|
session.context = createDto.context || {};
|
||||||
|
session.lastMessageAt = new Date();
|
||||||
|
|
||||||
|
await em.persistAndFlush(session);
|
||||||
|
|
||||||
|
return this.mapSessionToDto(session);
|
||||||
|
}
|
||||||
|
//************************************ */
|
||||||
|
async getUserChatSessions(userId: string, limit = 10): Promise<ChatSessionResponseDto[]> {
|
||||||
|
const em = this.em.fork();
|
||||||
|
const sessions = await this.chatSessionRepo.findByUserWithMessages(userId, limit, em);
|
||||||
|
return sessions.map((session) => this.mapSessionToDto(session));
|
||||||
|
}
|
||||||
|
//************************************ */
|
||||||
|
|
||||||
|
async getChatSession(sessionId: string, userId: string): Promise<ChatSessionResponseDto> {
|
||||||
|
const em = this.em.fork();
|
||||||
|
const session = await em.findOne(ChatSession, { id: sessionId, user: userId }, { populate: ["messages", "messages.sender"] });
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new NotFoundException("Chat session not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.mapSessionToDto(session);
|
||||||
|
}
|
||||||
|
//************************************ */
|
||||||
|
|
||||||
|
async sendMessage(userId: string, sendDto: SendMessageDto, provider?: LLMProvider): Promise<ChatMessageResponseDto> {
|
||||||
|
const selectedProvider = provider || this.defaultProvider;
|
||||||
|
|
||||||
|
// Use LangChain by default for enhanced responses
|
||||||
|
if (selectedProvider === LLMProvider.LANGCHAIN) {
|
||||||
|
try {
|
||||||
|
return await this.sendMessageWithLangChain(userId, sendDto);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("LangChain failed, falling back to Gemini", error);
|
||||||
|
// Fallback to Gemini service
|
||||||
|
return await this.sendMessageWithGemini(userId, sendDto);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return await this.sendMessageWithGemini(userId, sendDto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************ */
|
||||||
|
|
||||||
|
private async sendMessageWithGemini(userId: string, sendDto: SendMessageDto): Promise<ChatMessageResponseDto> {
|
||||||
|
const em = this.em.fork();
|
||||||
|
const session = await em.findOne(ChatSession, { id: sendDto.sessionId, user: userId }, { populate: ["messages"] });
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new NotFoundException("Chat session not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status !== ChatSessionStatus.ACTIVE) {
|
||||||
|
throw new BadRequestException("Cannot send message to inactive session");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await em.findOne(User, { id: userId });
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user message
|
||||||
|
const userMessage = new ChatMessage();
|
||||||
|
userMessage.content = sendDto.content;
|
||||||
|
userMessage.type = MessageType.USER;
|
||||||
|
userMessage.session = session;
|
||||||
|
userMessage.sender = user;
|
||||||
|
userMessage.responseToId = sendDto.responseToId;
|
||||||
|
userMessage.metadata = sendDto.metadata;
|
||||||
|
|
||||||
|
await em.persistAndFlush(userMessage);
|
||||||
|
|
||||||
|
// Update session last message time
|
||||||
|
await this.chatSessionRepo.updateLastMessageTime(session.id, em);
|
||||||
|
|
||||||
|
// Generate bot response asynchronously using original Gemini service
|
||||||
|
this.generateBotResponse(session, userMessage, userId);
|
||||||
|
|
||||||
|
return this.mapMessageToDto(userMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************ */
|
||||||
|
|
||||||
|
async sendMessageStream(
|
||||||
|
userId: string,
|
||||||
|
sendDto: SendMessageDto,
|
||||||
|
provider?: LLMProvider,
|
||||||
|
): Promise<{
|
||||||
|
userMessage: ChatMessageResponseDto;
|
||||||
|
streamGenerator: () => Promise<AsyncIterable<string>>;
|
||||||
|
}> {
|
||||||
|
const selectedProvider = provider || this.defaultProvider;
|
||||||
|
|
||||||
|
// Use LangChain by default for enhanced streaming responses
|
||||||
|
if (selectedProvider === LLMProvider.LANGCHAIN) {
|
||||||
|
try {
|
||||||
|
return await this.sendMessageStreamWithLangChain(userId, sendDto);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("LangChain streaming failed, falling back to Gemini", error);
|
||||||
|
// Fallback to Gemini service
|
||||||
|
return await this.sendMessageStreamWithGemini(userId, sendDto);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return await this.sendMessageStreamWithGemini(userId, sendDto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************ */
|
||||||
|
|
||||||
|
private async sendMessageStreamWithGemini(
|
||||||
|
userId: string,
|
||||||
|
sendDto: SendMessageDto,
|
||||||
|
): Promise<{
|
||||||
|
userMessage: ChatMessageResponseDto;
|
||||||
|
streamGenerator: () => Promise<AsyncIterable<string>>;
|
||||||
|
}> {
|
||||||
|
const em = this.em.fork();
|
||||||
|
const session = await em.findOne(ChatSession, { id: sendDto.sessionId, user: userId }, { populate: ["messages"] });
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new NotFoundException("Chat session not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status !== ChatSessionStatus.ACTIVE) {
|
||||||
|
throw new BadRequestException("Cannot send message to inactive session");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await em.findOne(User, { id: userId });
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user message
|
||||||
|
const userMessage = new ChatMessage();
|
||||||
|
userMessage.content = sendDto.content;
|
||||||
|
userMessage.type = MessageType.USER;
|
||||||
|
userMessage.session = session;
|
||||||
|
userMessage.sender = user;
|
||||||
|
userMessage.responseToId = sendDto.responseToId;
|
||||||
|
userMessage.metadata = sendDto.metadata;
|
||||||
|
|
||||||
|
await em.persistAndFlush(userMessage);
|
||||||
|
|
||||||
|
// Update session last message time
|
||||||
|
await this.chatSessionRepo.updateLastMessageTime(session.id, em);
|
||||||
|
|
||||||
|
// Get conversation history for context
|
||||||
|
const history = await this.chatMessageRepo.getConversationHistory(session.id, 20, em);
|
||||||
|
|
||||||
|
// Build context for LLM
|
||||||
|
const context: IChatContext = {
|
||||||
|
userId,
|
||||||
|
sessionId: session.id,
|
||||||
|
conversationHistory: history.reverse().map((msg) => ({
|
||||||
|
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 original LLM service
|
||||||
|
const streamGenerator = () => this.llmService.generateStreamResponse(sendDto.content, context);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userMessage: this.mapMessageToDto(userMessage),
|
||||||
|
streamGenerator,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************ */
|
||||||
|
|
||||||
|
private async generateBotResponse(session: ChatSession, userMessage: ChatMessage, userId: string): Promise<void> {
|
||||||
|
const em = this.em.fork();
|
||||||
|
try {
|
||||||
|
// Get conversation history
|
||||||
|
const history = await this.chatMessageRepo.getConversationHistory(session.id, 20, em);
|
||||||
|
|
||||||
|
// Build context for LLM
|
||||||
|
const context: IChatContext = {
|
||||||
|
userId,
|
||||||
|
sessionId: session.id,
|
||||||
|
conversationHistory: history.reverse().map((msg) => ({
|
||||||
|
content: msg.content,
|
||||||
|
type: msg.type as "user" | "bot" | "system",
|
||||||
|
timestamp: msg.createdAt,
|
||||||
|
metadata: msg.metadata,
|
||||||
|
})),
|
||||||
|
userPreferences: session.context,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate response using LLM
|
||||||
|
const llmResponse = await this.llmService.generateResponse(userMessage.content, context);
|
||||||
|
|
||||||
|
// Create bot message
|
||||||
|
const botMessage = new ChatMessage();
|
||||||
|
botMessage.content = llmResponse.message;
|
||||||
|
botMessage.type = MessageType.BOT;
|
||||||
|
botMessage.session = session;
|
||||||
|
botMessage.responseToId = userMessage.id;
|
||||||
|
botMessage.tokensUsed = llmResponse.tokensUsed;
|
||||||
|
botMessage.metadata = {
|
||||||
|
confidence: llmResponse.confidence,
|
||||||
|
sources: llmResponse.sources,
|
||||||
|
llmContext: llmResponse.context,
|
||||||
|
};
|
||||||
|
|
||||||
|
await em.persistAndFlush(botMessage);
|
||||||
|
|
||||||
|
// Update session last message time
|
||||||
|
await this.chatSessionRepo.updateLastMessageTime(session.id, em);
|
||||||
|
|
||||||
|
this.logger.log(`Generated bot response for session ${session.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to generate bot response for session ${session.id}`, error);
|
||||||
|
|
||||||
|
// Create error message
|
||||||
|
const errorMessage = new ChatMessage();
|
||||||
|
errorMessage.content = "متأسفم، در حال حاضر مشکلی در پاسخگویی دارم. لطفاً دوباره تلاش کنید. 🙏";
|
||||||
|
errorMessage.type = MessageType.BOT;
|
||||||
|
errorMessage.session = session;
|
||||||
|
errorMessage.responseToId = userMessage.id;
|
||||||
|
errorMessage.status = MessageStatus.FAILED;
|
||||||
|
|
||||||
|
await em.persistAndFlush(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************ */
|
||||||
|
|
||||||
|
async closeChatSession(sessionId: string, userId: string): Promise<void> {
|
||||||
|
const em = this.em.fork();
|
||||||
|
const session = await em.findOne(ChatSession, { id: sessionId, user: userId });
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new NotFoundException("Chat session not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
session.status = ChatSessionStatus.CLOSED;
|
||||||
|
await em.persistAndFlush(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************ */
|
||||||
|
|
||||||
|
async markMessagesAsRead(sessionId: string, userId: string, messageIds: string[]): Promise<void> {
|
||||||
|
const em = this.em.fork();
|
||||||
|
const session = await em.findOne(ChatSession, { id: sessionId, user: userId });
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new NotFoundException("Chat session not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.chatMessageRepo.markAsRead(messageIds, em);
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************ */
|
||||||
|
|
||||||
|
private mapSessionToDto(session: ChatSession): ChatSessionResponseDto {
|
||||||
|
return {
|
||||||
|
id: session.id,
|
||||||
|
title: session.title,
|
||||||
|
status: session.status,
|
||||||
|
createdAt: session.createdAt,
|
||||||
|
lastMessageAt: session.lastMessageAt,
|
||||||
|
context: session.context,
|
||||||
|
messages: session.messages ? session.messages.getItems().map((msg) => this.mapMessageToDto(msg)) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************ */
|
||||||
|
|
||||||
|
private mapMessageToDto(message: ChatMessage): ChatMessageResponseDto {
|
||||||
|
return {
|
||||||
|
id: message.id,
|
||||||
|
content: message.content,
|
||||||
|
type: message.type,
|
||||||
|
status: message.status,
|
||||||
|
createdAt: message.createdAt,
|
||||||
|
responseToId: message.responseToId,
|
||||||
|
metadata: message.metadata,
|
||||||
|
tokensUsed: message.tokensUsed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************ */
|
||||||
|
|
||||||
|
async sendMessageWithLangChain(userId: string, sendDto: SendMessageDto): Promise<ChatMessageResponseDto> {
|
||||||
|
const em = this.em.fork();
|
||||||
|
const session = await em.findOne(ChatSession, { id: sendDto.sessionId, user: userId }, { populate: ["messages"] });
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new NotFoundException("Chat session not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status !== ChatSessionStatus.ACTIVE) {
|
||||||
|
throw new BadRequestException("Cannot send message to inactive session");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await em.findOne(User, { id: userId });
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user message
|
||||||
|
const userMessage = new ChatMessage();
|
||||||
|
userMessage.content = sendDto.content;
|
||||||
|
userMessage.type = MessageType.USER;
|
||||||
|
userMessage.session = session;
|
||||||
|
userMessage.sender = user;
|
||||||
|
userMessage.responseToId = sendDto.responseToId;
|
||||||
|
userMessage.metadata = sendDto.metadata;
|
||||||
|
|
||||||
|
await em.persistAndFlush(userMessage);
|
||||||
|
|
||||||
|
// Update session last message time
|
||||||
|
await this.chatSessionRepo.updateLastMessageTime(session.id, em);
|
||||||
|
|
||||||
|
// Generate bot response using LangChain
|
||||||
|
this.generateBotResponseWithLangChain(session, userMessage, userId);
|
||||||
|
|
||||||
|
return this.mapMessageToDto(userMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************ */
|
||||||
|
|
||||||
|
async sendMessageStreamWithLangChain(
|
||||||
|
userId: string,
|
||||||
|
sendDto: SendMessageDto,
|
||||||
|
): Promise<{
|
||||||
|
userMessage: ChatMessageResponseDto;
|
||||||
|
streamGenerator: () => Promise<AsyncIterable<string>>;
|
||||||
|
}> {
|
||||||
|
const em = this.em.fork();
|
||||||
|
const session = await em.findOne(ChatSession, { id: sendDto.sessionId, user: userId }, { populate: ["messages"] });
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new NotFoundException("Chat session not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status !== ChatSessionStatus.ACTIVE) {
|
||||||
|
throw new BadRequestException("Cannot send message to inactive session");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await em.findOne(User, { id: userId });
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user message
|
||||||
|
const userMessage = new ChatMessage();
|
||||||
|
userMessage.content = sendDto.content;
|
||||||
|
userMessage.type = MessageType.USER;
|
||||||
|
userMessage.session = session;
|
||||||
|
userMessage.sender = user;
|
||||||
|
userMessage.responseToId = sendDto.responseToId;
|
||||||
|
userMessage.metadata = sendDto.metadata;
|
||||||
|
|
||||||
|
await em.persistAndFlush(userMessage);
|
||||||
|
|
||||||
|
// Update session last message time
|
||||||
|
await this.chatSessionRepo.updateLastMessageTime(session.id, em);
|
||||||
|
|
||||||
|
// Get conversation history for context
|
||||||
|
const history = await this.chatMessageRepo.getConversationHistory(session.id, 20, em);
|
||||||
|
|
||||||
|
// Build context for LangChain
|
||||||
|
const context: IChatContext = {
|
||||||
|
userId,
|
||||||
|
sessionId: session.id,
|
||||||
|
conversationHistory: history.reverse().map((msg) => ({
|
||||||
|
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 LangChain
|
||||||
|
const streamGenerator = () => this.langChainService.generateStreamResponse(sendDto.content, context);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userMessage: this.mapMessageToDto(userMessage),
|
||||||
|
streamGenerator,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************ */
|
||||||
|
|
||||||
|
private async generateBotResponseWithLangChain(session: ChatSession, userMessage: ChatMessage, userId: string): Promise<void> {
|
||||||
|
const em = this.em.fork();
|
||||||
|
try {
|
||||||
|
// Get conversation history
|
||||||
|
const history = await this.chatMessageRepo.getConversationHistory(session.id, 20, em);
|
||||||
|
|
||||||
|
// Build context for LangChain
|
||||||
|
const context: IChatContext = {
|
||||||
|
userId,
|
||||||
|
sessionId: session.id,
|
||||||
|
conversationHistory: history.reverse().map((msg) => ({
|
||||||
|
content: msg.content,
|
||||||
|
type: msg.type as "user" | "bot" | "system",
|
||||||
|
timestamp: msg.createdAt,
|
||||||
|
metadata: msg.metadata,
|
||||||
|
})),
|
||||||
|
userPreferences: session.context,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate response using LangChain
|
||||||
|
const langChainResponse = await this.langChainService.generateResponse(userMessage.content, context);
|
||||||
|
|
||||||
|
// Create bot message
|
||||||
|
const botMessage = new ChatMessage();
|
||||||
|
botMessage.content = langChainResponse.message;
|
||||||
|
botMessage.type = MessageType.BOT;
|
||||||
|
botMessage.session = session;
|
||||||
|
botMessage.responseToId = userMessage.id;
|
||||||
|
botMessage.tokensUsed = langChainResponse.tokensUsed;
|
||||||
|
botMessage.metadata = {
|
||||||
|
confidence: langChainResponse.confidence,
|
||||||
|
sources: langChainResponse.sources,
|
||||||
|
llmContext: langChainResponse.context,
|
||||||
|
provider: "langchain",
|
||||||
|
documentsRetrieved: langChainResponse.context?.documentsRetrieved || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
await em.persistAndFlush(botMessage);
|
||||||
|
|
||||||
|
// Update session last message time
|
||||||
|
await this.chatSessionRepo.updateLastMessageTime(session.id, em);
|
||||||
|
|
||||||
|
this.logger.log(`Generated LangChain bot response for session ${session.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to generate LangChain bot response for session ${session.id}`, error);
|
||||||
|
|
||||||
|
// Fallback to regular LLM service
|
||||||
|
this.logger.log(`Falling back to regular LLM service for session ${session.id}`);
|
||||||
|
await this.generateBotResponse(session, userMessage, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************ */
|
||||||
|
|
||||||
|
async refreshLangChainData(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.langChainService.refreshVectorStore();
|
||||||
|
this.logger.log("LangChain vector store refreshed successfully");
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Failed to refresh LangChain vector store", error);
|
||||||
|
throw new Error("Failed to refresh training data");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************ */
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { EntityManager } from "@mikro-orm/core";
|
||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { Business } from "../../businesses/entities/business.entity";
|
||||||
|
import { Company } from "../../companies/entities/company.entity";
|
||||||
|
import { Industry } from "../../industries/entities/industry.entity";
|
||||||
|
import { Invoice } from "../../invoices/entities/invoice.entity";
|
||||||
|
import { InvoiceStatus } from "../../invoices/enums/invoice-status.enum";
|
||||||
|
import { Payment } from "../../payments/entities/payment.entity";
|
||||||
|
import { PaymentStatus } from "../../payments/enums/payment-status.enum";
|
||||||
|
import { Ticket } from "../../tickets/entities/ticket.entity";
|
||||||
|
import { TicketStatus } from "../../tickets/enums/ticket-status.enum";
|
||||||
|
import { IChatContext } from "../interfaces/chatbot.interface";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DataContextService {
|
||||||
|
private readonly logger = new Logger(DataContextService.name);
|
||||||
|
|
||||||
|
constructor(private em: EntityManager) {}
|
||||||
|
|
||||||
|
async getRelevantContext(message: string, context: IChatContext): Promise<{ data: string[]; sources: string[] }> {
|
||||||
|
const relevantData: string[] = [];
|
||||||
|
const sources: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Analyze message to determine what data to fetch
|
||||||
|
const keywords = this.extractKeywords(message);
|
||||||
|
|
||||||
|
// Fetch relevant data based on keywords and user context
|
||||||
|
await Promise.all([
|
||||||
|
this.getBusinessData(keywords, context.userId, relevantData, sources),
|
||||||
|
this.getInvoiceData(keywords, context.userId, relevantData, sources),
|
||||||
|
this.getPaymentData(keywords, context.userId, relevantData, sources),
|
||||||
|
this.getCompanyData(keywords, context.userId, relevantData, sources),
|
||||||
|
this.getTicketData(keywords, context.userId, relevantData, sources),
|
||||||
|
this.getIndustryData(keywords, relevantData, sources),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { data: relevantData, sources };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Failed to get relevant context", error);
|
||||||
|
return { data: [], sources: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractKeywords(message: string): string[] {
|
||||||
|
const commonWords = ["the", "is", "at", "which", "on", "a", "an", "and", "or", "but", "in", "with", "to", "for", "of", "as", "by"];
|
||||||
|
|
||||||
|
return message
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\s]/g, "")
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((word) => word.length > 2 && !commonWords.includes(word));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getBusinessData(keywords: string[], userId: string, relevantData: string[], sources: string[]): Promise<void> {
|
||||||
|
if (this.hasBusinessKeywords(keywords)) {
|
||||||
|
try {
|
||||||
|
const businesses = await this.em.find(Business, { danakSubscriptionId: userId }, { limit: 5, populate: ["users"] });
|
||||||
|
|
||||||
|
businesses.forEach((business) => {
|
||||||
|
relevantData.push(
|
||||||
|
`Business: ${business.name} - Domain: ${business.domain || "No domain"} - Verified: ${business.isDomainVerified ? "Yes" : "No"}`,
|
||||||
|
);
|
||||||
|
sources.push("Businesses Database");
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn("Failed to fetch business data", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getInvoiceData(keywords: string[], userId: string, relevantData: string[], sources: string[]): Promise<void> {
|
||||||
|
if (this.hasInvoiceKeywords(keywords)) {
|
||||||
|
try {
|
||||||
|
// Find user's businesses first
|
||||||
|
const userBusinesses = await this.em.find(Business, { danakSubscriptionId: userId });
|
||||||
|
|
||||||
|
if (userBusinesses.length > 0) {
|
||||||
|
const businessIds = userBusinesses.map((b) => b.id);
|
||||||
|
const invoices = await this.em.find(Invoice, { business: { $in: businessIds } }, { limit: 10, orderBy: { createdAt: "DESC" } });
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
total: invoices.length,
|
||||||
|
paid: invoices.filter((inv) => inv.status === InvoiceStatus.PAID).length,
|
||||||
|
pending: invoices.filter((inv) => inv.status === InvoiceStatus.PENDING).length,
|
||||||
|
totalAmount: invoices.reduce((sum, inv) => sum + Number(inv.totalPrice), 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
relevantData.push(
|
||||||
|
`Invoice Summary: Total invoices: ${summary.total}, Paid: ${summary.paid}, Pending: ${summary.pending}, Total amount: $${summary.totalAmount}`,
|
||||||
|
);
|
||||||
|
sources.push("Invoices Database");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn("Failed to fetch invoice data", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPaymentData(keywords: string[], userId: string, relevantData: string[], sources: string[]): Promise<void> {
|
||||||
|
if (this.hasPaymentKeywords(keywords)) {
|
||||||
|
try {
|
||||||
|
// Find user's businesses first
|
||||||
|
const userBusinesses = await this.em.find(Business, { danakSubscriptionId: userId });
|
||||||
|
|
||||||
|
if (userBusinesses.length > 0) {
|
||||||
|
const businessIds = userBusinesses.map((b) => b.id);
|
||||||
|
const payments = await this.em.find(Payment, { business: { $in: businessIds } }, { limit: 10, orderBy: { createdAt: "DESC" } });
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
total: payments.length,
|
||||||
|
successful: payments.filter((pay) => pay.status === PaymentStatus.COMPLETED).length,
|
||||||
|
failed: payments.filter((pay) => pay.status === PaymentStatus.FAILED).length,
|
||||||
|
totalAmount: payments.reduce((sum, pay) => sum + Number(pay.amount), 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
relevantData.push(
|
||||||
|
`Payment Summary: Total payments: ${summary.total}, Successful: ${summary.successful}, Failed: ${summary.failed}, Total amount: $${summary.totalAmount}`,
|
||||||
|
);
|
||||||
|
sources.push("Payments Database");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn("Failed to fetch payment data", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCompanyData(keywords: string[], userId: string, relevantData: string[], sources: string[]): Promise<void> {
|
||||||
|
if (this.hasCompanyKeywords(keywords)) {
|
||||||
|
try {
|
||||||
|
// Find user's businesses first
|
||||||
|
const userBusinesses = await this.em.find(Business, { danakSubscriptionId: userId });
|
||||||
|
|
||||||
|
if (userBusinesses.length > 0) {
|
||||||
|
const businessIds = userBusinesses.map((b) => b.id);
|
||||||
|
const companies = await this.em.find(Company, { business: { $in: businessIds } }, { limit: 5, populate: ["industry"] });
|
||||||
|
|
||||||
|
companies.forEach((company) => {
|
||||||
|
relevantData.push(`Company: ${company.name} - Industry: ${company.industry?.title || "N/A"} - Status: ${company.status}`);
|
||||||
|
sources.push("Companies Database");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn("Failed to fetch company data", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTicketData(keywords: string[], userId: string, relevantData: string[], sources: string[]): Promise<void> {
|
||||||
|
if (this.hasTicketKeywords(keywords)) {
|
||||||
|
try {
|
||||||
|
// Find user's businesses first
|
||||||
|
const userBusinesses = await this.em.find(Business, { danakSubscriptionId: userId });
|
||||||
|
|
||||||
|
if (userBusinesses.length > 0) {
|
||||||
|
const businessIds = userBusinesses.map((b) => b.id);
|
||||||
|
const tickets = await this.em.find(
|
||||||
|
Ticket,
|
||||||
|
{ business: { $in: businessIds } },
|
||||||
|
{ limit: 10, orderBy: { createdAt: "DESC" }, populate: ["category"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
total: tickets.length,
|
||||||
|
pending: tickets.filter((t) => t.status === TicketStatus.PENDING).length,
|
||||||
|
answered: tickets.filter((t) => t.status === TicketStatus.ANSWERED).length,
|
||||||
|
closed: tickets.filter((t) => t.status === TicketStatus.CLOSED).length,
|
||||||
|
categories: [...new Set(tickets.map((t) => t.category?.title || "Uncategorized"))],
|
||||||
|
};
|
||||||
|
|
||||||
|
relevantData.push(
|
||||||
|
`Support Tickets: Total: ${summary.total}, Pending: ${summary.pending}, Answered: ${summary.answered}, Closed: ${summary.closed}, Categories: ${summary.categories.join(", ")}`,
|
||||||
|
);
|
||||||
|
sources.push("Support Tickets Database");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn("Failed to fetch ticket data", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getIndustryData(keywords: string[], relevantData: string[], sources: string[]): Promise<void> {
|
||||||
|
if (this.hasIndustryKeywords(keywords)) {
|
||||||
|
try {
|
||||||
|
const industries = await this.em.find(Industry, {}, { limit: 10 });
|
||||||
|
|
||||||
|
const industryInfo = industries.map((ind) => `${ind.title}: ${ind.isActive ? "Active" : "Inactive"}`);
|
||||||
|
relevantData.push(`Available Industries: ${industryInfo.join("; ")}`);
|
||||||
|
sources.push("Industries Database");
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn("Failed to fetch industry data", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasBusinessKeywords(keywords: string[]): boolean {
|
||||||
|
const businessTerms = ["business", "company", "startup", "enterprise", "organization", "firm"];
|
||||||
|
return keywords.some((keyword) => businessTerms.includes(keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasInvoiceKeywords(keywords: string[]): boolean {
|
||||||
|
const invoiceTerms = ["invoice", "bill", "billing", "payment", "charge", "fee", "cost", "price"];
|
||||||
|
return keywords.some((keyword) => invoiceTerms.includes(keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasPaymentKeywords(keywords: string[]): boolean {
|
||||||
|
const paymentTerms = ["payment", "pay", "transaction", "money", "refund", "charge", "card", "bank"];
|
||||||
|
return keywords.some((keyword) => paymentTerms.includes(keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasCompanyKeywords(keywords: string[]): boolean {
|
||||||
|
const companyTerms = ["company", "corporation", "business", "organization", "enterprise"];
|
||||||
|
return keywords.some((keyword) => companyTerms.includes(keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasTicketKeywords(keywords: string[]): boolean {
|
||||||
|
const ticketTerms = ["ticket", "support", "help", "issue", "problem", "bug", "error"];
|
||||||
|
return keywords.some((keyword) => ticketTerms.includes(keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasIndustryKeywords(keywords: string[]): boolean {
|
||||||
|
const industryTerms = ["industry", "sector", "field", "domain", "market"];
|
||||||
|
return keywords.some((keyword) => industryTerms.includes(keyword));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import { Document } from "@langchain/core/documents";
|
||||||
|
import { StringOutputParser } from "@langchain/core/output_parsers";
|
||||||
|
import { PromptTemplate } from "@langchain/core/prompts";
|
||||||
|
import { RunnableSequence } from "@langchain/core/runnables";
|
||||||
|
import { ChatGoogleGenerativeAI, GoogleGenerativeAIEmbeddings } from "@langchain/google-genai";
|
||||||
|
import { EntityManager } from "@mikro-orm/core";
|
||||||
|
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
|
||||||
|
import { MemoryVectorStore } from "langchain/vectorstores/memory";
|
||||||
|
|
||||||
|
import { Company } from "../../companies/entities/company.entity";
|
||||||
|
import { Industry } from "../../industries/entities/industry.entity";
|
||||||
|
import { CHATBOT_CONSTANTS } from "../constants/chatbot.constants";
|
||||||
|
import { IChatContext, IChatbotResponse } from "../interfaces/chatbot.interface";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LangChainService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(LangChainService.name);
|
||||||
|
private embeddings: GoogleGenerativeAIEmbeddings;
|
||||||
|
private llm: ChatGoogleGenerativeAI;
|
||||||
|
private vectorStore: MemoryVectorStore;
|
||||||
|
private textSplitter: RecursiveCharacterTextSplitter;
|
||||||
|
private isInitialized = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private em: EntityManager,
|
||||||
|
private configService: ConfigService,
|
||||||
|
) {
|
||||||
|
// Initialize Google Gemini components
|
||||||
|
this.embeddings = new GoogleGenerativeAIEmbeddings({
|
||||||
|
apiKey: this.configService.getOrThrow("GEMINI_API_KEY"),
|
||||||
|
modelName: "embedding-001", // Google's embedding model
|
||||||
|
});
|
||||||
|
|
||||||
|
this.llm = new ChatGoogleGenerativeAI({
|
||||||
|
apiKey: this.configService.getOrThrow("GEMINI_API_KEY"),
|
||||||
|
model: CHATBOT_CONSTANTS.DEFAULT_MODEL,
|
||||||
|
temperature: CHATBOT_CONSTANTS.DEFAULT_TEMPERATURE,
|
||||||
|
maxOutputTokens: CHATBOT_CONSTANTS.DEFAULT_MAX_TOKENS,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.textSplitter = new RecursiveCharacterTextSplitter({
|
||||||
|
chunkSize: 1000,
|
||||||
|
chunkOverlap: 200,
|
||||||
|
separators: ["\n\n", "\n", ".", "!", "?", "؟", "!", ".", " ", ""],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.initializeVectorStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initializeVectorStore() {
|
||||||
|
try {
|
||||||
|
this.logger.log(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.LOADING_DATA);
|
||||||
|
|
||||||
|
// Load company and industry data for training
|
||||||
|
const documents = await this.loadCompanyAndIndustryDocuments();
|
||||||
|
|
||||||
|
if (documents.length === 0) {
|
||||||
|
this.logger.warn(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.NO_DATA_FOUND);
|
||||||
|
this.vectorStore = new MemoryVectorStore(this.embeddings);
|
||||||
|
this.isInitialized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split documents into chunks
|
||||||
|
const splitDocs = await this.textSplitter.splitDocuments(documents);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.DOCUMENTS_SPLIT.replace("{totalDocs}", documents.length.toString()).replace(
|
||||||
|
"{chunks}",
|
||||||
|
splitDocs.length.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create vector store from documents
|
||||||
|
this.vectorStore = await MemoryVectorStore.fromDocuments(splitDocs, this.embeddings);
|
||||||
|
|
||||||
|
this.isInitialized = true;
|
||||||
|
this.logger.log(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.VECTOR_STORE_READY);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.VECTOR_STORE_ERROR, error);
|
||||||
|
// Fallback to empty vector store
|
||||||
|
this.vectorStore = new MemoryVectorStore(this.embeddings);
|
||||||
|
this.isInitialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadCompanyAndIndustryDocuments(): Promise<Document[]> {
|
||||||
|
const documents: Document[] = [];
|
||||||
|
const em = this.em.fork();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load companies data with their products and services
|
||||||
|
const companies = await em.find(Company, { isActive: true, deletedAt: null }, { populate: ["industry", "business", "products", "services"] });
|
||||||
|
|
||||||
|
companies.forEach((company) => {
|
||||||
|
// Company basic information using template
|
||||||
|
const companyContent = this.replaceTemplate(CHATBOT_CONSTANTS.COMPANY_DATA_TEMPLATES.COMPANY_INFO, {
|
||||||
|
name: company.name,
|
||||||
|
ceo: company.chiefExecutiveOfficer,
|
||||||
|
email: company.email,
|
||||||
|
phone: company.phone,
|
||||||
|
registrationNumber: company.identificationNumber,
|
||||||
|
establishmentDate: new Date(company.dateOfEstablishment).toLocaleDateString("fa-IR"),
|
||||||
|
address: company.address,
|
||||||
|
website: company.websiteUrl || CHATBOT_CONSTANTS.DEFAULT_VALUES.NO_WEBSITE,
|
||||||
|
description: company.description,
|
||||||
|
industry: company.industry?.title || CHATBOT_CONSTANTS.DEFAULT_VALUES.UNSPECIFIED,
|
||||||
|
status: company.status,
|
||||||
|
business: company.business?.name || CHATBOT_CONSTANTS.DEFAULT_VALUES.UNSPECIFIED,
|
||||||
|
});
|
||||||
|
|
||||||
|
documents.push(
|
||||||
|
new Document({
|
||||||
|
pageContent: companyContent,
|
||||||
|
metadata: {
|
||||||
|
type: "company",
|
||||||
|
id: company.id,
|
||||||
|
name: company.name,
|
||||||
|
industry: company.industry?.title,
|
||||||
|
businessId: company.business?.id,
|
||||||
|
status: company.status,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Company products using template
|
||||||
|
if (company.products && company.products.getItems().length > 0) {
|
||||||
|
company.products.getItems().forEach((product) => {
|
||||||
|
const productContent = this.replaceTemplate(CHATBOT_CONSTANTS.COMPANY_DATA_TEMPLATES.COMPANY_PRODUCT, {
|
||||||
|
companyName: company.name,
|
||||||
|
productTitle: product.title,
|
||||||
|
industry: company.industry?.title || CHATBOT_CONSTANTS.DEFAULT_VALUES.UNSPECIFIED,
|
||||||
|
companyDescription: company.description,
|
||||||
|
companyAddress: company.address,
|
||||||
|
companyPhone: company.phone,
|
||||||
|
companyEmail: company.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
documents.push(
|
||||||
|
new Document({
|
||||||
|
pageContent: productContent,
|
||||||
|
metadata: {
|
||||||
|
type: "product",
|
||||||
|
id: product.id,
|
||||||
|
title: product.title,
|
||||||
|
companyId: company.id,
|
||||||
|
companyName: company.name,
|
||||||
|
industry: company.industry?.title,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Company services using template
|
||||||
|
if (company.services && company.services.getItems().length > 0) {
|
||||||
|
company.services.getItems().forEach((service) => {
|
||||||
|
const serviceContent = this.replaceTemplate(CHATBOT_CONSTANTS.COMPANY_DATA_TEMPLATES.COMPANY_SERVICE, {
|
||||||
|
companyName: company.name,
|
||||||
|
serviceTitle: service.title,
|
||||||
|
industry: company.industry?.title || CHATBOT_CONSTANTS.DEFAULT_VALUES.UNSPECIFIED,
|
||||||
|
companyDescription: company.description,
|
||||||
|
companyAddress: company.address,
|
||||||
|
companyPhone: company.phone,
|
||||||
|
companyEmail: company.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
documents.push(
|
||||||
|
new Document({
|
||||||
|
pageContent: serviceContent,
|
||||||
|
metadata: {
|
||||||
|
type: "service",
|
||||||
|
id: service.id,
|
||||||
|
title: service.title,
|
||||||
|
companyId: company.id,
|
||||||
|
companyName: company.name,
|
||||||
|
industry: company.industry?.title,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load industries data using template
|
||||||
|
const industries = await em.find(Industry, { isActive: true, deletedAt: null }, { populate: ["companies", "business"] });
|
||||||
|
industries.forEach((industry) => {
|
||||||
|
const companiesInIndustry = industry.companies?.getItems().filter((c) => c.isActive && !c.deletedAt) || [];
|
||||||
|
|
||||||
|
const industryContent = this.replaceTemplate(CHATBOT_CONSTANTS.COMPANY_DATA_TEMPLATES.INDUSTRY_INFO, {
|
||||||
|
title: industry.title,
|
||||||
|
status: industry.isActive ? CHATBOT_CONSTANTS.DEFAULT_VALUES.ACTIVE : CHATBOT_CONSTANTS.DEFAULT_VALUES.INACTIVE,
|
||||||
|
companiesCount: companiesInIndustry.length.toString(),
|
||||||
|
business: industry.business?.name || CHATBOT_CONSTANTS.DEFAULT_VALUES.UNSPECIFIED,
|
||||||
|
companiesList: companiesInIndustry.map((company) => `- ${company.name}`).join("\n"),
|
||||||
|
});
|
||||||
|
|
||||||
|
documents.push(
|
||||||
|
new Document({
|
||||||
|
pageContent: industryContent,
|
||||||
|
metadata: {
|
||||||
|
type: "industry",
|
||||||
|
id: industry.id,
|
||||||
|
title: industry.title,
|
||||||
|
isActive: industry.isActive,
|
||||||
|
companiesCount: companiesInIndustry.length,
|
||||||
|
businessId: industry.business?.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add comprehensive Farsi guidance documents from constants
|
||||||
|
const companyGuidanceDocuments = [
|
||||||
|
new Document({
|
||||||
|
pageContent: CHATBOT_CONSTANTS.COMPANY_GUIDANCE_DOCUMENTS.COMPANY_GUIDE,
|
||||||
|
metadata: {
|
||||||
|
type: "company_guide",
|
||||||
|
category: "guidance",
|
||||||
|
language: "فارسی",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new Document({
|
||||||
|
pageContent: CHATBOT_CONSTANTS.COMPANY_GUIDANCE_DOCUMENTS.COMPANY_FAQ,
|
||||||
|
metadata: {
|
||||||
|
type: "company_faq",
|
||||||
|
category: "support",
|
||||||
|
language: "فارسی",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new Document({
|
||||||
|
pageContent: CHATBOT_CONSTANTS.COMPANY_GUIDANCE_DOCUMENTS.SEARCH_GUIDE,
|
||||||
|
metadata: {
|
||||||
|
type: "search_guide",
|
||||||
|
category: "tutorial",
|
||||||
|
language: "فارسی",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new Document({
|
||||||
|
pageContent: CHATBOT_CONSTANTS.COMPANY_GUIDANCE_DOCUMENTS.INDUSTRY_GUIDE,
|
||||||
|
metadata: {
|
||||||
|
type: "industry_guide",
|
||||||
|
category: "education",
|
||||||
|
language: "فارسی",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
documents.push(...companyGuidanceDocuments);
|
||||||
|
|
||||||
|
this.logger.log(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.DOCUMENTS_LOADED.replace("{count}", documents.length.toString()));
|
||||||
|
return documents;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.LOADING_ERROR, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateResponse(message: string, context: IChatContext): Promise<IChatbotResponse> {
|
||||||
|
if (!this.isInitialized) {
|
||||||
|
throw new Error(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.SERVICE_NOT_INITIALIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Retrieve relevant documents based on the user's query
|
||||||
|
const relevantDocs = await this.vectorStore.similaritySearch(message, 5);
|
||||||
|
|
||||||
|
// Build context from retrieved documents
|
||||||
|
const retrievedContext = relevantDocs.map((doc, index) => `${index + 1}. ${doc.pageContent}`).join("\n\n");
|
||||||
|
|
||||||
|
// Build conversation history
|
||||||
|
const conversationHistory =
|
||||||
|
context.conversationHistory
|
||||||
|
?.slice(-CHATBOT_CONSTANTS.MAX_CONVERSATION_HISTORY / 4)
|
||||||
|
?.map((msg) => {
|
||||||
|
const roleLabel = msg.type === "user" ? "کاربر" : msg.type === "bot" ? "ربات" : "سیستم";
|
||||||
|
return `${roleLabel}: ${msg.content}`;
|
||||||
|
})
|
||||||
|
?.join("\n") || "";
|
||||||
|
|
||||||
|
// Create the prompt template using constants
|
||||||
|
const promptTemplate = PromptTemplate.fromTemplate(CHATBOT_CONSTANTS.COMPANY_GUIDANCE_SYSTEM_PROMPT);
|
||||||
|
|
||||||
|
// Create the runnable sequence
|
||||||
|
const chain = RunnableSequence.from([promptTemplate, this.llm, new StringOutputParser()]);
|
||||||
|
|
||||||
|
// Execute the chain
|
||||||
|
const response = await chain.invoke({
|
||||||
|
context: retrievedContext,
|
||||||
|
conversation_history: conversationHistory,
|
||||||
|
question: message,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate approximate token usage
|
||||||
|
const tokensUsed = this.estimateTokens(retrievedContext) + this.estimateTokens(message) + this.estimateTokens(response);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: response,
|
||||||
|
confidence: 0.9, // Higher confidence for company guidance
|
||||||
|
sources: relevantDocs.map((doc) => {
|
||||||
|
const type = doc.metadata.type || "شرکت";
|
||||||
|
return CHATBOT_CONSTANTS.SOURCE_TYPE_LABELS[type as keyof typeof CHATBOT_CONSTANTS.SOURCE_TYPE_LABELS] || type;
|
||||||
|
}),
|
||||||
|
tokensUsed,
|
||||||
|
context: {
|
||||||
|
model: `${CHATBOT_CONSTANTS.DEFAULT_MODEL}-company-guide`,
|
||||||
|
relevantDataFound: relevantDocs.length > 0,
|
||||||
|
documentsRetrieved: relevantDocs.length,
|
||||||
|
vectorStoreInitialized: this.isInitialized,
|
||||||
|
language: "فارسی",
|
||||||
|
dataTypes: relevantDocs.map((doc) => doc.metadata.type).filter((type, index, arr) => arr.indexOf(type) === index),
|
||||||
|
focus: "company_and_industry_guidance",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.RESPONSE_ERROR, error);
|
||||||
|
throw new Error(CHATBOT_CONSTANTS.ERROR_MESSAGES.LLM_SERVICE_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateStreamResponse(message: string, context: IChatContext) {
|
||||||
|
if (!this.isInitialized) {
|
||||||
|
throw new Error(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.SERVICE_NOT_INITIALIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Retrieve relevant documents
|
||||||
|
const relevantDocs = await this.vectorStore.similaritySearch(message, 5);
|
||||||
|
const retrievedContext = relevantDocs.map((doc, index) => `${index + 1}. ${doc.pageContent}`).join("\n\n");
|
||||||
|
|
||||||
|
// Build conversation history
|
||||||
|
const conversationHistory =
|
||||||
|
context.conversationHistory
|
||||||
|
?.slice(-CHATBOT_CONSTANTS.MAX_CONVERSATION_HISTORY / 4)
|
||||||
|
?.map((msg) => {
|
||||||
|
const roleLabel = msg.type === "user" ? "کاربر" : msg.type === "bot" ? "ربات" : "سیستم";
|
||||||
|
return `${roleLabel}: ${msg.content}`;
|
||||||
|
})
|
||||||
|
?.join("\n") || "";
|
||||||
|
|
||||||
|
// Create prompt template using constants
|
||||||
|
const promptTemplate = PromptTemplate.fromTemplate(CHATBOT_CONSTANTS.COMPANY_GUIDANCE_SYSTEM_PROMPT);
|
||||||
|
|
||||||
|
// Create streaming chain
|
||||||
|
const chain = RunnableSequence.from([promptTemplate, this.llm, new StringOutputParser()]);
|
||||||
|
|
||||||
|
// Return async iterable for streaming
|
||||||
|
const stream = await chain.stream({
|
||||||
|
context: retrievedContext,
|
||||||
|
conversation_history: conversationHistory,
|
||||||
|
question: message,
|
||||||
|
});
|
||||||
|
|
||||||
|
return stream;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.STREAM_RESPONSE_ERROR, error);
|
||||||
|
throw new Error(CHATBOT_CONSTANTS.ERROR_MESSAGES.LLM_SERVICE_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshVectorStore(): Promise<void> {
|
||||||
|
this.logger.log(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.REFRESHING_VECTOR_STORE);
|
||||||
|
await this.initializeVectorStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
private estimateTokens(text: string): number {
|
||||||
|
// Rough estimation: 1 token ≈ 4 characters for most languages
|
||||||
|
return Math.ceil(text.length / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace placeholders in template with actual values
|
||||||
|
*/
|
||||||
|
private replaceTemplate(template: string, values: Record<string, string>): string {
|
||||||
|
let result = template;
|
||||||
|
Object.entries(values).forEach(([key, value]) => {
|
||||||
|
result = result.replace(new RegExp(`{${key}}`, "g"), value);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
get initialized(): boolean {
|
||||||
|
return this.isInitialized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { GoogleGenerativeAI, HarmBlockThreshold, HarmCategory } from "@google/generative-ai";
|
||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
|
||||||
|
import { DataContextService } from "./data-context.service";
|
||||||
|
import { CHATBOT_CONSTANTS } from "../constants/chatbot.constants";
|
||||||
|
import { IChatContext, IChatbotResponse, ILLMConfig } from "../interfaces/chatbot.interface";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LLMService {
|
||||||
|
private readonly logger = new Logger(LLMService.name);
|
||||||
|
private genAI: GoogleGenerativeAI;
|
||||||
|
private config: ILLMConfig;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private dataContextService: DataContextService,
|
||||||
|
) {
|
||||||
|
this.config = {
|
||||||
|
model: this.configService.get("GEMINI_MODEL", CHATBOT_CONSTANTS.DEFAULT_MODEL),
|
||||||
|
temperature: Number(this.configService.get("GEMINI_TEMPERATURE", CHATBOT_CONSTANTS.DEFAULT_TEMPERATURE)),
|
||||||
|
maxTokens: Number(this.configService.get("GEMINI_MAX_TOKENS", CHATBOT_CONSTANTS.DEFAULT_MAX_TOKENS)),
|
||||||
|
topP: Number(this.configService.get("GEMINI_TOP_P", 0.95)),
|
||||||
|
apiKey: this.configService.getOrThrow("GEMINI_API_KEY"),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.genAI = new GoogleGenerativeAI(this.config.apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateResponse(message: string, context: IChatContext): Promise<IChatbotResponse> {
|
||||||
|
try {
|
||||||
|
// Get relevant data from database
|
||||||
|
const relevantData = await this.dataContextService.getRelevantContext(message, context);
|
||||||
|
|
||||||
|
// Build system instruction with context
|
||||||
|
const systemInstruction = this.buildSystemInstruction(relevantData);
|
||||||
|
|
||||||
|
// Get the generative model with system instruction
|
||||||
|
const model = this.genAI.getGenerativeModel({
|
||||||
|
model: this.config.model,
|
||||||
|
systemInstruction,
|
||||||
|
generationConfig: {
|
||||||
|
temperature: this.config.temperature,
|
||||||
|
maxOutputTokens: this.config.maxTokens,
|
||||||
|
topP: this.config.topP,
|
||||||
|
},
|
||||||
|
safetySettings: [
|
||||||
|
{
|
||||||
|
category: HarmCategory.HARM_CATEGORY_HARASSMENT,
|
||||||
|
threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
|
||||||
|
threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
|
||||||
|
threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
|
||||||
|
threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build conversation history
|
||||||
|
const conversationHistory = this.buildConversationHistory(context);
|
||||||
|
|
||||||
|
// Generate response using conversation history or direct message
|
||||||
|
let result;
|
||||||
|
if (conversationHistory.length > 0) {
|
||||||
|
// Use chat session for multi-turn conversation
|
||||||
|
const chat = model.startChat({
|
||||||
|
history: conversationHistory,
|
||||||
|
});
|
||||||
|
result = await chat.sendMessage(message);
|
||||||
|
} else {
|
||||||
|
// Single message generation
|
||||||
|
result = await model.generateContent(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await result.response;
|
||||||
|
const botMessage = response.text() || "متأسفم، نمیتوانم پاسخی تولید کنم. لطفاً سوال خود را دوباره مطرح کنید. 🤖";
|
||||||
|
|
||||||
|
// Calculate token usage (approximate)
|
||||||
|
const tokensUsed = this.estimateTokens(systemInstruction) + this.estimateTokens(message) + this.estimateTokens(botMessage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: botMessage,
|
||||||
|
confidence: this.calculateConfidence(response),
|
||||||
|
sources: relevantData.sources,
|
||||||
|
tokensUsed,
|
||||||
|
context: {
|
||||||
|
model: this.config.model,
|
||||||
|
relevantDataFound: relevantData.data.length > 0,
|
||||||
|
finishReason: response.candidates?.[0]?.finishReason || "unknown",
|
||||||
|
safetyRatings: response.candidates?.[0]?.safetyRatings || [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Failed to generate Gemini response", error);
|
||||||
|
throw new Error("Failed to generate response from AI service");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateStreamResponse(message: string, context: IChatContext): Promise<AsyncIterable<string>> {
|
||||||
|
try {
|
||||||
|
// Get relevant data from database
|
||||||
|
const relevantData = await this.dataContextService.getRelevantContext(message, context);
|
||||||
|
|
||||||
|
// Build system instruction with context
|
||||||
|
const systemInstruction = this.buildSystemInstruction(relevantData);
|
||||||
|
|
||||||
|
// Get the generative model
|
||||||
|
const model = this.genAI.getGenerativeModel({
|
||||||
|
model: this.config.model,
|
||||||
|
systemInstruction,
|
||||||
|
generationConfig: {
|
||||||
|
temperature: this.config.temperature,
|
||||||
|
maxOutputTokens: this.config.maxTokens,
|
||||||
|
topP: this.config.topP,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build conversation history
|
||||||
|
const conversationHistory = this.buildConversationHistory(context);
|
||||||
|
|
||||||
|
let streamResult;
|
||||||
|
if (conversationHistory.length > 0) {
|
||||||
|
const chat = model.startChat({
|
||||||
|
history: conversationHistory,
|
||||||
|
});
|
||||||
|
streamResult = await chat.sendMessageStream(message);
|
||||||
|
} else {
|
||||||
|
streamResult = await model.generateContentStream(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.createAsyncIterableFromStream(streamResult);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Failed to generate streaming Gemini response", error);
|
||||||
|
throw new Error("Failed to generate streaming response from AI service");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSystemInstruction(relevantData: { data: string[]; sources: string[] }): string {
|
||||||
|
let instruction = CHATBOT_CONSTANTS.SYSTEM_PROMPT;
|
||||||
|
|
||||||
|
// Add relevant data context
|
||||||
|
if (relevantData.data.length > 0) {
|
||||||
|
instruction += "\n\nRelevant information from our database:\n";
|
||||||
|
instruction += relevantData.data.map((data, index) => `${index + 1}. ${data}`).join("\n");
|
||||||
|
instruction += "\n\nUse this information to provide accurate and specific answers.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return instruction;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildConversationHistory(context: IChatContext): Array<{ role: string; parts: Array<{ text: string }> }> {
|
||||||
|
if (!context.conversationHistory || context.conversationHistory.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = [];
|
||||||
|
const recentHistory = context.conversationHistory.slice(-CHATBOT_CONSTANTS.MAX_CONVERSATION_HISTORY).filter((msg) => msg.type !== "system");
|
||||||
|
|
||||||
|
for (const msg of recentHistory) {
|
||||||
|
history.push({
|
||||||
|
role: msg.type === "user" ? "user" : "model",
|
||||||
|
parts: [{ text: msg.content }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async *createAsyncIterableFromStream(streamResult: any): AsyncIterable<string> {
|
||||||
|
try {
|
||||||
|
// For Google Generative AI, the streamResult itself is the async iterable
|
||||||
|
for await (const chunk of streamResult.stream) {
|
||||||
|
const chunkText = chunk.text();
|
||||||
|
if (chunkText) {
|
||||||
|
yield chunkText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Error in streaming response", error);
|
||||||
|
|
||||||
|
// If the above doesn't work, try alternative approach for older SDK versions
|
||||||
|
try {
|
||||||
|
const response = await streamResult.response;
|
||||||
|
const text = response.text();
|
||||||
|
if (text) {
|
||||||
|
yield text;
|
||||||
|
}
|
||||||
|
} catch (fallbackError) {
|
||||||
|
this.logger.error("Fallback streaming approach also failed", fallbackError);
|
||||||
|
throw new Error("Failed to process streaming response");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateConfidence(response: any): number {
|
||||||
|
const text = response.text() || "";
|
||||||
|
|
||||||
|
// Check safety ratings - lower confidence if blocked
|
||||||
|
const safetyRatings = response.candidates?.[0]?.safetyRatings || [];
|
||||||
|
const hasHighRiskRatings = safetyRatings.some((rating: any) => rating.probability === "HIGH" || rating.probability === "MEDIUM");
|
||||||
|
|
||||||
|
if (hasHighRiskRatings) {
|
||||||
|
return 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for uncertainty indicators
|
||||||
|
if (text.includes("I don't know") || text.includes("I'm not sure") || text.includes("uncertain")) {
|
||||||
|
return 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check response length and quality
|
||||||
|
if (text.length < 50) {
|
||||||
|
return 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if response uses provided data
|
||||||
|
if (text.includes("based on") || text.includes("according to")) {
|
||||||
|
return 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
private estimateTokens(text: string): number {
|
||||||
|
// Rough estimation: 1 token ≈ 4 characters for English text
|
||||||
|
// Gemini uses similar tokenization to other models
|
||||||
|
return Math.ceil(text.length / 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { EntityManager, EntityRepository } from "@mikro-orm/postgresql";
|
||||||
|
|
||||||
|
import { ChatMessage, MessageStatus } from "../entities/chat-message.entity";
|
||||||
|
|
||||||
|
export class ChatMessageRepository extends EntityRepository<ChatMessage> {
|
||||||
|
async findBySessionWithPagination(sessionId: string, offset = 0, limit = 50, em?: EntityManager) {
|
||||||
|
const repository = em ? em.getRepository(ChatMessage) : this;
|
||||||
|
return repository.find(
|
||||||
|
{ session: sessionId },
|
||||||
|
{
|
||||||
|
populate: ["sender"],
|
||||||
|
orderBy: { createdAt: "ASC" },
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConversationHistory(sessionId: string, limit = 20, em?: EntityManager) {
|
||||||
|
const repository = em ? em.getRepository(ChatMessage) : this;
|
||||||
|
return repository.find(
|
||||||
|
{ session: sessionId },
|
||||||
|
{
|
||||||
|
orderBy: { createdAt: "DESC" },
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAsRead(messageIds: string[], em?: EntityManager) {
|
||||||
|
const repository = em ? em.getRepository(ChatMessage) : this;
|
||||||
|
return repository.nativeUpdate({ id: { $in: messageIds } }, { status: MessageStatus.READ });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { EntityManager, EntityRepository } from "@mikro-orm/postgresql";
|
||||||
|
|
||||||
|
import { ChatSession, ChatSessionStatus } from "../entities/chat-session.entity";
|
||||||
|
|
||||||
|
export class ChatSessionRepository extends EntityRepository<ChatSession> {
|
||||||
|
async findByUserWithMessages(userId: string, limit = 10, em?: EntityManager) {
|
||||||
|
const repository = em ? em.getRepository(ChatSession) : this;
|
||||||
|
return repository.find(
|
||||||
|
{ user: userId },
|
||||||
|
{
|
||||||
|
populate: ["messages"],
|
||||||
|
orderBy: { lastMessageAt: "DESC" },
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findActiveByUser(userId: string, em?: EntityManager) {
|
||||||
|
const repository = em ? em.getRepository(ChatSession) : this;
|
||||||
|
return repository.find(
|
||||||
|
{ user: userId, status: ChatSessionStatus.ACTIVE },
|
||||||
|
{
|
||||||
|
populate: ["messages"],
|
||||||
|
orderBy: { lastMessageAt: "DESC" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLastMessageTime(sessionId: string, em?: EntityManager) {
|
||||||
|
const repository = em ? em.getRepository(ChatSession) : this;
|
||||||
|
return repository.nativeUpdate({ id: sessionId }, { lastMessageAt: new Date() });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,8 +38,8 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
/*******************************/
|
/*******************************/
|
||||||
|
|
||||||
async getMe(userId: string, businessId: string) {
|
async getMe(userId: string) {
|
||||||
const user = await this.userRepository.findOne({ id: userId, business: { id: businessId } }, { populate: ["role"] });
|
const user = await this.userRepository.findOne({ id: userId }, { populate: ["role"] });
|
||||||
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
|
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
|
||||||
return { user };
|
return { user };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { Controller, Get, UseInterceptors } from "@nestjs/common";
|
import { Controller, Get } from "@nestjs/common";
|
||||||
import { ApiOperation } from "@nestjs/swagger";
|
import { ApiOperation } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { UsersService } from "./services/users.service";
|
import { UsersService } from "./services/users.service";
|
||||||
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
|
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
|
||||||
import { BusinessDec } from "../../common/decorators/business.decorator";
|
// import { BusinessDec } from "../../common/decorators/business.decorator";
|
||||||
import { UserDec } from "../../common/decorators/user.decorator";
|
import { UserDec } from "../../common/decorators/user.decorator";
|
||||||
import { BusinessInterceptor } from "../../core/interceptors/business.interceptor";
|
// import { BusinessInterceptor } from "../../core/interceptors/business.interceptor";
|
||||||
|
|
||||||
@Controller("users")
|
@Controller("users")
|
||||||
@AuthGuards()
|
@AuthGuards()
|
||||||
@UseInterceptors(BusinessInterceptor)
|
// @UseInterceptors(BusinessInterceptor)
|
||||||
export class UsersController {
|
export class UsersController {
|
||||||
constructor(private readonly usersService: UsersService) {}
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
@ApiOperation({ summary: "Get user profile" })
|
@ApiOperation({ summary: "Get user profile" })
|
||||||
@Get("me")
|
@Get("me")
|
||||||
getMe(@UserDec("id") userId: string, @BusinessDec("id") businessId: string) {
|
getMe(@UserDec("id") userId: string) {
|
||||||
return this.usersService.getMe(userId, businessId);
|
return this.usersService.getMe(userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Socket } from "socket.io";
|
||||||
|
|
||||||
|
export const extractTokenFromClient = (client: Socket): string | undefined => {
|
||||||
|
const authToken = client.handshake?.auth?.token || client.handshake?.query?.token;
|
||||||
|
return authToken;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user