feat: add conversation module

This commit is contained in:
2026-09-30 17:55:21 +03:30
parent 13ba11f951
commit 9842f4db31
14 changed files with 270 additions and 18 deletions
+2
View File
@@ -25,6 +25,7 @@ import { AppThrottlerGuard } from './common/guards/app-throttler.guard';
import { ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerModule } from '@nestjs/throttler';
import { LoggingModule } from './common/modules/logging/logging.module'; import { LoggingModule } from './common/modules/logging/logging.module';
import { ChatbotModule } from './modules/chatbot/chatbot.module'; import { ChatbotModule } from './modules/chatbot/chatbot.module';
import { ConversationModule } from './modules/conversation/conversation.module';
import openRouterConfig from './config/open-router.config'; import openRouterConfig from './config/open-router.config';
const ENV = process.env.NODE_ENV; const ENV = process.env.NODE_ENV;
@@ -106,6 +107,7 @@ const ENV = process.env.NODE_ENV;
AdminModule, AdminModule,
LoggingModule, LoggingModule,
ChatbotModule, ChatbotModule,
ConversationModule,
], ],
providers: [ providers: [
{ {
+6
View File
@@ -0,0 +1,6 @@
{
"fields": {
"conversationId": "Conversation Id",
"message": "Message"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"fields": {
"conversationId": "شناسه مکالمه",
"message": "پیام"
}
}
+50 -1
View File
@@ -1,6 +1,7 @@
import { Body, Controller, Post } from '@nestjs/common'; import { Body, Controller, Post, Res } from '@nestjs/common';
import { ChatbotService } from './providers/chatbot.service'; import { ChatbotService } from './providers/chatbot.service';
import { Public } from '../auth/decorators/public.decorator'; import { Public } from '../auth/decorators/public.decorator';
import type { Response } from 'express';
@Controller('chatbot') @Controller('chatbot')
export class ChatbotController { export class ChatbotController {
@@ -17,4 +18,52 @@ export class ChatbotController {
public async chat(@Body('message') message: string) { public async chat(@Body('message') message: string) {
return this.chatbotService.chat(message); return this.chatbotService.chat(message);
} }
@Post('stream')
// TEMP:
@Public()
public async streamChat(
@Body('message') message: string,
@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)) {
res.write(
`data: ${JSON.stringify({
message: content,
})}\n\n`,
);
}
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();
// });
// }
} }
+14
View File
@@ -0,0 +1,14 @@
export const CHATBOT_SYSTEM_PROMPT = `
You are a medical AI assistant named Heala (Or in persian: هیلا).
Your role is to provide general medical information
and health education.
- Do not provide definitive diagnoses.
- Do not prescribe medications or dosages.
- Do not fabricate medical information.
- If symptoms may indicate an emergency, recommend
seeking urgent medical care.
- Clearly communicate uncertainty when appropriate.
- Answer in Persian when the user asks in Persian.
`;
+24
View File
@@ -0,0 +1,24 @@
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { i18nValidationMessage as t } from 'nestjs-i18n';
export class ChatDTO {
@IsString({
message: t('validation.wrongFieldFormat', {
field: '$t(chatbot.fields.conversationId)',
}),
})
@IsOptional()
conversationId?: string;
@IsString({
message: t('validation.wrongFieldFormat', {
field: '$t(chatbot.fields.message)',
}),
})
@IsNotEmpty({
message: t('validation.requiredField', {
field: '$t(chatbot.fields.message)',
}),
})
message!: string;
}
@@ -5,3 +5,6 @@ Content-Type: application/json
{ {
"message": "چطوری بفهمم به HPV مبتلا هستم یا خیر؟" "message": "چطوری بفهمم به HPV مبتلا هستم یا خیر؟"
} }
# POST {{baseUrl}}/chatbot/stream/
# Content-Type: application/json
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { CHATBOT_SYSTEM_PROMPT } from '../constants';
@Injectable() @Injectable()
export class ChatbotService { export class ChatbotService {
@@ -14,7 +15,7 @@ export class ChatbotService {
const { OpenRouter } = await import('@openrouter/sdk'); const { OpenRouter } = await import('@openrouter/sdk');
return new OpenRouter({ return new OpenRouter({
apiKey: this.configService.get<string>('openRouter.apiKey'), apiKey: this.configService.getOrThrow<string>('openRouter.apiKey'),
}); });
} }
@@ -23,24 +24,11 @@ export class ChatbotService {
const response = await openRouter.chat.send({ const response = await openRouter.chat.send({
chatRequest: { chatRequest: {
model: this.configService.get<string>('openRouter.model'), model: this.configService.getOrThrow<string>('openRouter.model'),
messages: [ messages: [
{ {
role: 'system', role: 'system',
content: ` content: CHATBOT_SYSTEM_PROMPT.trim(),
You are a medical AI assistant named Heala (Or in persian: هیلا).
Your role is to provide general medical information
and health education.
- Do not provide definitive diagnoses.
- Do not prescribe medications or dosages.
- Do not fabricate medical information.
- If symptoms may indicate an emergency, recommend
seeking urgent medical care.
- Clearly communicate uncertainty when appropriate.
- Answer in Persian when the user asks in Persian.
`.trim(),
}, },
{ {
role: 'user', role: 'user',
@@ -64,4 +52,37 @@ export class ChatbotService {
message: content, message: content,
}; };
} }
public async *streamChat(message: string) {
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,
},
],
stream: true,
},
});
if ('choices' in response) {
throw new Error('Expected streaming response');
}
for await (const chunk of response) {
const content = chunk.choices[0]?.delta?.content;
if (content) {
yield content;
}
}
}
} }
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Conversation } from './entities/conversation.entity';
import { Message } from './entities/message.entity';
@Module({
imports: [TypeOrmModule.forFeature([Conversation, Message])],
})
export class ConversationModule {}
@@ -0,0 +1,32 @@
import { User } from '@/modules/users/entities/user.entity';
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Message } from './message.entity';
@Entity()
export class Conversation {
@PrimaryGeneratedColumn()
id!: number;
@Column({ type: 'varchar', nullable: true })
title!: string | null;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
user!: User;
@OneToMany(() => Message, (message) => message.conversation)
messages!: Message[];
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
@@ -0,0 +1,33 @@
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Conversation } from './conversation.entity';
import { MessageRole } from '../enums/message-role.enum';
@Entity()
export class Message {
@PrimaryGeneratedColumn()
id!: number;
@ManyToOne(() => Conversation, (conversation) => conversation.messages, {
onDelete: 'CASCADE',
})
conversation!: Conversation;
@Column({
type: 'enum',
enum: MessageRole,
nullable: false,
})
role!: MessageRole;
@Column({ type: 'text' })
content!: string;
@CreateDateColumn()
createdAt!: Date;
}
@@ -0,0 +1,4 @@
export enum ConversationType {
AI = 'ai',
DOCTOR = 'doctor',
}
@@ -0,0 +1,5 @@
export enum MessageRole {
USER = 'user',
ASSISTANT = 'assistant',
DOCTOR = 'doctor',
}
@@ -0,0 +1,44 @@
import { 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';
@Injectable()
export class ConversationService {
constructor(
/**
* Inject Conversation Repository
*/
@InjectRepository(Conversation)
private readonly conversationRepository: Repository<Conversation>,
/**
* Inject Message Repository
*/
@InjectRepository(Message)
private readonly messageRepository: Repository<Message>,
) {}
public async createConversation(
user: User,
title?: string,
): Promise<Conversation> {
const conversation = this.conversationRepository.create({
user,
title: title ?? null,
});
try {
return await this.conversationRepository.save(conversation);
} catch (err) {
throw new AppTimeoutException(err);
}
}
// public async findConversationById(id: number, user: User): Promise<Conversation> {
// const
// }
}