feat: connect chatbot to conversation database
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"fields": {
|
||||
"conversation": "Conversation"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"fields": {
|
||||
"conversation": "مکالمه"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class Conversation1791194400675 implements MigrationInterface {
|
||||
name = 'Conversation1791194400675';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "conversation" ("id" SERIAL NOT NULL, "title" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "userId" integer, CONSTRAINT "PK_864528ec4274360a40f66c29845" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE "public"."message_role_enum" AS ENUM('user', 'assistant', 'doctor')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "message" ("id" SERIAL NOT NULL, "role" "public"."message_role_enum" NOT NULL, "content" text NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "conversationId" integer, CONSTRAINT "PK_ba01f0a3e0123651915008bc578" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "conversation" ADD CONSTRAINT "FK_c308b1cd542522bb66430fa860a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "message" ADD CONSTRAINT "FK_7cf4a4df1f2627f72bf6231635f" FOREIGN KEY ("conversationId") REFERENCES "conversation"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "message" DROP CONSTRAINT "FK_7cf4a4df1f2627f72bf6231635f"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "conversation" DROP CONSTRAINT "FK_c308b1cd542522bb66430fa860a"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "message"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."message_role_enum"`);
|
||||
await queryRunner.query(`DROP TABLE "conversation"`);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ Content-Type: application/json
|
||||
|
||||
{
|
||||
"phone": "09333026363",
|
||||
"otp": "55333"
|
||||
"otp": "23122"
|
||||
}
|
||||
|
||||
{{
|
||||
|
||||
@@ -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();
|
||||
// });
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user