feat: connect chatbot to conversation database

This commit is contained in:
2026-10-05 13:59:03 +03:30
parent 9842f4db31
commit 5c4e9df341
11 changed files with 246 additions and 69 deletions
@@ -3,7 +3,7 @@ Content-Type: application/json
{
"phone": "09333026363",
"otp": "55333"
"otp": "23122"
}
{{
+26 -36
View File
@@ -1,7 +1,16 @@
import { Body, Controller, Post, Res } from '@nestjs/common';
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
Res,
} from '@nestjs/common';
import { ChatbotService } from './providers/chatbot.service';
import { Public } from '../auth/decorators/public.decorator';
import type { Response } from 'express';
import { ChatDTO } from './dtos/chat.dto';
import { ActiveUser } from '@/common/decorators/active-user.decorator';
import { User } from '../users/entities/user.entity';
@Controller('chatbot')
export class ChatbotController {
@@ -13,24 +22,31 @@ export class ChatbotController {
) {}
@Post('chat')
// TEMP:
@Public()
public async chat(@Body('message') message: string) {
return this.chatbotService.chat(message);
@HttpCode(HttpStatus.OK)
public async chat(@Body() chatDto: ChatDTO, @ActiveUser() user: User) {
return this.chatbotService.chat(
user,
chatDto.message,
chatDto.conversationId,
);
}
@Post('stream')
// TEMP:
@Public()
@HttpCode(HttpStatus.OK)
public async streamChat(
@Body('message') message: string,
@Body() chatDto: ChatDTO,
@ActiveUser() user: User,
@Res() res: Response,
) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
for await (const content of this.chatbotService.streamChat(message)) {
for await (const content of this.chatbotService.streamChat(
user,
chatDto.message,
chatDto.conversationId,
)) {
res.write(
`data: ${JSON.stringify({
message: content,
@@ -40,30 +56,4 @@ export class ChatbotController {
res.end();
}
// @Sse('stream')
// @Public()
// public streamChat(
// @Body('message') message: string,
// ): Observable<MessageEvent> {
// return new Observable((subscriber) => {
// const run = async () => {
// try {
// for await (const content of this.chatbotService.streamChat(message)) {
// subscriber.next({
// data: {
// message: content,
// },
// });
// }
// subscriber.complete();
// } catch (err) {
// subscriber.error(err);
// }
// };
// void run();
// });
// }
}
+2
View File
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { ChatbotController } from './chatbot.controller';
import { ChatbotService } from './providers/chatbot.service';
import { ConversationModule } from '../conversation/conversation.module';
@Module({
imports: [ConversationModule],
controllers: [ChatbotController],
providers: [ChatbotService],
})
+3 -3
View File
@@ -1,14 +1,14 @@
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { i18nValidationMessage as t } from 'nestjs-i18n';
export class ChatDTO {
@IsString({
@IsInt({
message: t('validation.wrongFieldFormat', {
field: '$t(chatbot.fields.conversationId)',
}),
})
@IsOptional()
conversationId?: string;
conversationId?: number;
@IsString({
message: t('validation.wrongFieldFormat', {
@@ -1,6 +1,6 @@
POST {{baseUrl}}/chatbot/chat
Content-Type: application/json
# Authorization: Bearer {{$global.accessToken}}
Authorization: Bearer {{$global.accessToken}}
{
"message": "چطوری بفهمم به HPV مبتلا هستم یا خیر؟"
@@ -1,6 +1,10 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CHATBOT_SYSTEM_PROMPT } from '../constants';
import { User } from '@/modules/users/entities/user.entity';
import { Conversation } from '@/modules/conversation/entities/conversation.entity';
import { ConversationService } from '@/modules/conversation/providers/conversation.service';
import { MessageRole } from '@/modules/conversation/enums/message-role.enum';
@Injectable()
export class ChatbotService {
@@ -9,6 +13,11 @@ export class ChatbotService {
* Inject Config Service
*/
private readonly configService: ConfigService,
/**
* Inject Conversation Service
*/
private readonly conversationService: ConversationService,
) {}
private async getOpenRouter() {
@@ -19,22 +28,61 @@ export class ChatbotService {
});
}
public async chat(message: string) {
private async getConversationMessages(
user: User,
message: string,
conversationId?: number,
) {
let conversation: Conversation;
if (conversationId) {
conversation = await this.conversationService.findConversationById(
conversationId,
user,
);
} else {
conversation = await this.conversationService.createConversation(user);
}
await this.conversationService.addMessage(
conversation,
MessageRole.USER,
message,
);
const messages = await this.conversationService.getMessages(conversation);
return {
conversation,
messages: [
{
role: 'system' as const,
content: CHATBOT_SYSTEM_PROMPT.trim(),
},
...messages.map((message) => ({
role:
message.role === MessageRole.USER
? ('user' as const)
: ('assistant' as const),
content: message.content,
})),
],
};
}
public async chat(user: User, message: string, conversationId?: number) {
const { conversation, messages } = await this.getConversationMessages(
user,
message,
conversationId,
);
const openRouter = await this.getOpenRouter();
const response = await openRouter.chat.send({
chatRequest: {
model: this.configService.getOrThrow<string>('openRouter.model'),
messages: [
{
role: 'system',
content: CHATBOT_SYSTEM_PROMPT.trim(),
},
{
role: 'user',
content: message,
},
],
messages,
},
});
@@ -48,27 +96,35 @@ export class ChatbotService {
throw new Error('OpenRouter returned an unexpected response');
}
await this.conversationService.addMessage(
conversation,
MessageRole.ASSISTANT,
content,
);
return {
conversationId: conversation.id,
message: content,
};
}
public async *streamChat(message: string) {
public async *streamChat(
user: User,
message: string,
conversationId?: number,
) {
const { conversation, messages } = await this.getConversationMessages(
user,
message,
conversationId,
);
const openRouter = await this.getOpenRouter();
const response = await openRouter.chat.send({
chatRequest: {
model: this.configService.getOrThrow<string>('openRouter.model'),
messages: [
{
role: 'system',
content: CHATBOT_SYSTEM_PROMPT.trim(),
},
{
role: 'user',
content: message,
},
],
messages,
stream: true,
},
});
@@ -77,12 +133,26 @@ export class ChatbotService {
throw new Error('Expected streaming response');
}
let assistantMessage = '';
for await (const chunk of response) {
const content = chunk.choices[0]?.delta?.content;
if (content) {
yield content;
if (!content) {
continue;
}
assistantMessage += content;
yield { conversationId: conversation.id, content };
}
if (assistantMessage) {
await this.conversationService.addMessage(
conversation,
MessageRole.ASSISTANT,
assistantMessage,
);
}
}
}
@@ -2,8 +2,11 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Conversation } from './entities/conversation.entity';
import { Message } from './entities/message.entity';
import { ConversationService } from './providers/conversation.service';
@Module({
imports: [TypeOrmModule.forFeature([Conversation, Message])],
providers: [ConversationService],
exports: [ConversationService],
})
export class ConversationModule {}
@@ -1,10 +1,12 @@
import { Injectable } from '@nestjs/common';
import { HttpStatus, Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { Conversation } from '../entities/conversation.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Message } from '../entities/message.entity';
import { User } from '@/modules/users/entities/user.entity';
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
import { AppException } from '@/common/exceptions/app.exception';
import { MessageRole } from '../enums/message-role.enum';
@Injectable()
export class ConversationService {
@@ -38,7 +40,72 @@ export class ConversationService {
}
}
// public async findConversationById(id: number, user: User): Promise<Conversation> {
// const
// }
public async findConversationById(
id: number,
user: User,
): Promise<Conversation> {
let conversation: Conversation | null = null;
try {
conversation = await this.conversationRepository.findOne({
where: {
id,
user: {
id: user.id,
},
},
});
} catch (err) {
throw new AppTimeoutException(err);
}
if (!conversation) {
throw new AppException(
'common.errors.entityNotFound',
HttpStatus.NOT_FOUND,
{ entity: '$t(conversations.fields.conversation)' },
);
}
return conversation;
}
public async addMessage(
conversation: Conversation,
role: MessageRole,
content: string,
): Promise<Message> {
const message = this.messageRepository.create({
conversation,
role,
content,
});
try {
return await this.messageRepository.save(message);
} catch (err) {
throw new AppTimeoutException(err);
}
}
public async getMessages(conversation: Conversation): Promise<Message[]> {
let messages: Array<Message> = [];
try {
messages = await this.messageRepository.find({
where: {
conversation: {
id: conversation.id,
},
},
order: {
createdAt: 'ASC',
},
});
} catch (err) {
throw new AppTimeoutException(err);
}
return messages;
}
}