diff --git a/backend/was/package-lock.json b/backend/was/package-lock.json index 9272bc4f..d65a9f64 100644 --- a/backend/was/package-lock.json +++ b/backend/was/package-lock.json @@ -59,6 +59,7 @@ "husky": "^8.0.3", "jest": "^29.5.0", "prettier": "^3.0.0", + "socket.io-client": "^4.7.3", "source-map-support": "^0.5.21", "supertest": "^6.3.3", "ts-jest": "^29.1.0", @@ -5562,6 +5563,19 @@ "node": ">=10.2.0" } }, + "node_modules/engine.io-client": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", + "integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==", + "dev": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0", + "xmlhttprequest-ssl": "~2.0.0" + } + }, "node_modules/engine.io-parser": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz", @@ -11079,6 +11093,21 @@ "ws": "~8.11.0" } }, + "node_modules/socket.io-client": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.3.tgz", + "integrity": "sha512-nU+ywttCyBitXIl9Xe0RSEfek4LneYkJxCeNnKCuhwoH4jGXO1ipIUw/VA/+Vvv2G1MTym11fzFC0SxkrcfXDw==", + "dev": true, + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", @@ -12846,6 +12875,15 @@ } } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -17047,6 +17085,19 @@ "ws": "~8.11.0" } }, + "engine.io-client": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", + "integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==", + "dev": true, + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0", + "xmlhttprequest-ssl": "~2.0.0" + } + }, "engine.io-parser": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz", @@ -21194,6 +21245,18 @@ "ws": "~8.11.0" } }, + "socket.io-client": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.3.tgz", + "integrity": "sha512-nU+ywttCyBitXIl9Xe0RSEfek4LneYkJxCeNnKCuhwoH4jGXO1ipIUw/VA/+Vvv2G1MTym11fzFC0SxkrcfXDw==", + "dev": true, + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.5.2", + "socket.io-parser": "~4.2.4" + } + }, "socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", @@ -22397,6 +22460,12 @@ "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", "requires": {} }, + "xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "dev": true + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/was/package.json b/backend/was/package.json index ac145794..b6a0ed8b 100644 --- a/backend/was/package.json +++ b/backend/was/package.json @@ -70,6 +70,7 @@ "husky": "^8.0.3", "jest": "^29.5.0", "prettier": "^3.0.0", + "socket.io-client": "^4.7.3", "source-map-support": "^0.5.21", "supertest": "^6.3.3", "ts-jest": "^29.1.0", diff --git a/backend/was/src/app.module.ts b/backend/was/src/app.module.ts index a581a9f5..29c71c55 100644 --- a/backend/was/src/app.module.ts +++ b/backend/was/src/app.module.ts @@ -3,13 +3,14 @@ import { ConfigModule } from '@nestjs/config'; import { APP_INTERCEPTOR } from '@nestjs/core'; import { AuthModule } from './auth/auth.module'; import { ChatModule } from './chat/chat.module'; +import { ChatbotModule } from './chatbot/chatbot.module'; import { RedisCacheModule } from './common/config/cache/redis-cache.module'; import { MysqlModule } from './common/config/database/mysql.module'; import { JwtConfigModule } from './common/config/jwt/jwt.module'; import { ErrorsInterceptor } from './common/interceptors/errors.interceptor'; -import { EventsModule } from './events/events.module'; import { LoggerModule } from './logger/logger.module'; import { MembersModule } from './members/members.module'; +import { SocketModule } from './socket/socket.module'; import { TarotModule } from './tarot/tarot.module'; @Module({ @@ -21,7 +22,8 @@ import { TarotModule } from './tarot/tarot.module'; MysqlModule, ChatModule, TarotModule, - EventsModule, + ChatbotModule, + SocketModule, LoggerModule, AuthModule, ], diff --git a/backend/was/src/chat/dto/create-chatting-message.dto.ts b/backend/was/src/chat/dto/create-chatting-message.dto.ts index 5eebd87c..1089e13d 100644 --- a/backend/was/src/chat/dto/create-chatting-message.dto.ts +++ b/backend/was/src/chat/dto/create-chatting-message.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsBoolean, IsString, IsUUID } from 'class-validator'; -import { Message } from 'src/events/type'; +import { ChatLog } from 'src/common/types/chatbot'; export class CreateChattingMessageDto { @IsUUID() @@ -20,11 +20,10 @@ export class CreateChattingMessageDto { }) readonly message: string; - static fromMessage(message: Message): CreateChattingMessageDto { - return { - roomId: message.roomId, - isHost: message.chat.role === 'assistant', - message: message.chat.content, - }; + static fromChatLog( + roomId: string, + chatLog: ChatLog, + ): CreateChattingMessageDto { + return { roomId, ...chatLog }; } } diff --git a/backend/was/src/chatbot/chatbot.interface.ts b/backend/was/src/chatbot/chatbot.interface.ts new file mode 100644 index 00000000..40ca13d9 --- /dev/null +++ b/backend/was/src/chatbot/chatbot.interface.ts @@ -0,0 +1,12 @@ +import type { ChatLog } from 'src/common/types/chatbot'; + +export interface ChatbotService { + generateTalk( + chatLogs: ChatLog[], + message: string, + ): Promise>; + generateTarotReading( + chatLogs: ChatLog[], + cardIdx: number, + ): Promise>; +} diff --git a/backend/was/src/chatbot/chatbot.module.ts b/backend/was/src/chatbot/chatbot.module.ts new file mode 100644 index 00000000..19ff3939 --- /dev/null +++ b/backend/was/src/chatbot/chatbot.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { ClovaStudioService } from './clova-studio/clova-studio.service'; + +@Module({ + providers: [{ provide: 'ChatbotService', useClass: ClovaStudioService }], + exports: ['ChatbotService'], +}) +export class ChatbotModule {} diff --git a/backend/was/src/chatbot/clova-studio/api.ts b/backend/was/src/chatbot/clova-studio/api.ts new file mode 100644 index 00000000..32af199f --- /dev/null +++ b/backend/was/src/chatbot/clova-studio/api.ts @@ -0,0 +1,44 @@ +import { + CLOVA_API_DEFAULT_BODY_OPTIONS, + CLOVA_API_DEFAULT_HEADER_OPTIONS, + CLOVA_URL, +} from 'src/common/constants/clova-studio'; +import { ERR_MSG } from 'src/common/constants/errors'; +import type { + ClovaStudioApiKeys, + ClovaStudioMessage, +} from 'src/common/types/clova-studio'; + +type APIOptions = { + apiKeys: ClovaStudioApiKeys; + messages: ClovaStudioMessage[]; + maxTokens: number; +}; + +export async function clovaStudioApi({ + apiKeys, + messages, + maxTokens, +}: APIOptions): Promise> { + const response = await fetch(CLOVA_URL, { + method: 'POST', + headers: { + ...CLOVA_API_DEFAULT_HEADER_OPTIONS, + ...apiKeys, + }, + body: JSON.stringify({ + ...CLOVA_API_DEFAULT_BODY_OPTIONS, + maxTokens, + messages, + }), + }); + + if (!response.ok) { + const errorMessage = `${ERR_MSG.AI_API_FAILED}: 상태코드 ${response.statusText}`; + throw new Error(errorMessage); + } + if (!response.body) { + throw new Error(ERR_MSG.AI_API_RESPONSE_EMPTY); + } + return response.body; +} diff --git a/backend/was/src/chatbot/clova-studio/clova-studio.service.spec.ts b/backend/was/src/chatbot/clova-studio/clova-studio.service.spec.ts new file mode 100644 index 00000000..aeae30d9 --- /dev/null +++ b/backend/was/src/chatbot/clova-studio/clova-studio.service.spec.ts @@ -0,0 +1,72 @@ +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { CLOVA_API_KEY_NAMES } from 'src/common/constants/clova-studio'; +import { string2Uint8ArrayStream } from 'src/common/utils/stream'; +import { + clovaStudioApiMock, + configServieMock, + createAllEventStringMock, + vaildateTokenStream, +} from 'src/mocks/clova-studio'; +import { ClovaStudioService, getAPIKeys } from './clova-studio.service'; + +jest.mock('./api'); + +describe('ClovaStudioService', () => { + let clovaStudioService: ClovaStudioService; + const tokens = ['안', '녕', '하', '세', '요']; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ClovaStudioService, + { provide: ConfigService, useValue: configServieMock }, + ], + }).compile(); + + clovaStudioService = module.get(ClovaStudioService); + }); + + it('ClovaStudioService 생성', () => { + expect(clovaStudioService).toBeDefined(); + }); + + describe('function getAPIKeys()', () => { + it('getAPIKeys(): clova api key 불러 와서 객체로 만들어서 반환', () => { + const apiKeys = getAPIKeys(configServieMock); + + CLOVA_API_KEY_NAMES.forEach((key) => { + expect(apiKeys[key.replaceAll('_', '-')]).toBe(key); + }); + }); + }); + + describe('ClovaStudioService.generateTalk()', () => { + it('사용자의 메세지 입력으로 AI의 답변을 생성해서 token stream 형식으로 반환', async () => { + setApiMock(tokens); + + const tokenStream = await clovaStudioService.generateTalk([], '안녕!'); + + const result = await vaildateTokenStream(tokenStream, tokens); + expect(result).toBeTruthy(); + }); + }); + + describe('ClovaStudioService.generateTarotReading()', () => { + it('사용자가 뽑은 카드 인덱스로 AI의 해설을 생성해서 token stream 형식으로 반환', async () => { + setApiMock(tokens); + + const tokenStream = await clovaStudioService.generateTarotReading([], 21); + + const result = await vaildateTokenStream(tokenStream, tokens); + expect(result).toBeTruthy(); + }); + }); +}); + +function setApiMock(tokens: string[]) { + const chunks = createAllEventStringMock(tokens); + clovaStudioApiMock.mockReturnValueOnce(string2Uint8ArrayStream(chunks)); +} diff --git a/backend/was/src/chatbot/clova-studio/clova-studio.service.ts b/backend/was/src/chatbot/clova-studio/clova-studio.service.ts new file mode 100644 index 00000000..ab3455b2 --- /dev/null +++ b/backend/was/src/chatbot/clova-studio/clova-studio.service.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + CHAT_MAX_TOKENS, + CLOVA_API_KEY_NAMES, + TAROT_MAX_TOKENS, +} from 'src/common/constants/clova-studio'; +import { ERR_MSG } from 'src/common/constants/errors'; +import type { ChatLog } from 'src/common/types/chatbot'; +import type { + ClovaStudioApiKeys, + ClovaStudioMessage, +} from 'src/common/types/clova-studio'; +import { ChatbotService } from '../chatbot.interface'; +import { clovaStudioApi } from './api'; +import { + buildTalkMessages, + buildTarotReadingMessages, + chatLog2clovaStudioMessages, +} from './message'; +import { apiResponseStream2TokenStream } from './stream'; + +@Injectable() +export class ClovaStudioService implements ChatbotService { + private readonly apiKeys: ClovaStudioApiKeys; + + constructor(private readonly configService: ConfigService) { + this.apiKeys = getAPIKeys(this.configService); + } + + generateTalk( + chatLogs: ChatLog[], + userMessage: string, + ): Promise> { + const convertedMessages = chatLog2clovaStudioMessages(chatLogs); + const messages = buildTalkMessages(convertedMessages, userMessage); + + return this.api(messages, CHAT_MAX_TOKENS); + } + + generateTarotReading( + chatLogs: ChatLog[], + cardIdx: number, + ): Promise> { + const convertedMessages = chatLog2clovaStudioMessages(chatLogs); + const messages = buildTarotReadingMessages(convertedMessages, cardIdx); + + return this.api(messages, TAROT_MAX_TOKENS); + } + + private async api( + messages: ClovaStudioMessage[], + maxTokens: number, + ): Promise> { + const options = { apiKeys: this.apiKeys, messages, maxTokens }; + const responseStream = await clovaStudioApi(options); + + return await apiResponseStream2TokenStream(responseStream); + } +} + +export function getAPIKeys(configService: ConfigService) { + return CLOVA_API_KEY_NAMES.reduce((acc, key) => { + const value = configService.get(key); + + if (!value) throw new Error(ERR_MSG.AI_API_KEY_NOT_FOUND); + + acc[key.replaceAll('_', '-')] = value; + return acc; + }, {} as ClovaStudioApiKeys); +} diff --git a/backend/was/src/chatbot/clova-studio/message/builder.ts b/backend/was/src/chatbot/clova-studio/message/builder.ts new file mode 100644 index 00000000..d02faeea --- /dev/null +++ b/backend/was/src/chatbot/clova-studio/message/builder.ts @@ -0,0 +1,30 @@ +import type { ClovaStudioMessage } from 'src/common/types/clova-studio'; +import { + createTalkSystemMessage, + createTarotCardMessage, + createTarotCardSystemMessage, + createUserMessage, +} from './creator'; + +export function buildTalkMessages( + messages: ClovaStudioMessage[], + userMessage: string, +): ClovaStudioMessage[] { + return [ + createTalkSystemMessage(), + ...messages, + createUserMessage(userMessage), + ]; +} + +export function buildTarotReadingMessages( + messages: ClovaStudioMessage[], + cardIdx: number, +): ClovaStudioMessage[] { + return [ + createTalkSystemMessage(), + ...messages, + createTarotCardSystemMessage(), + createTarotCardMessage(cardIdx), + ]; +} diff --git a/backend/was/src/chatbot/clova-studio/message/converter.ts b/backend/was/src/chatbot/clova-studio/message/converter.ts new file mode 100644 index 00000000..88bf05c7 --- /dev/null +++ b/backend/was/src/chatbot/clova-studio/message/converter.ts @@ -0,0 +1,16 @@ +import type { ChatLog } from 'src/common/types/chatbot'; +import type { ClovaStudioMessage } from 'src/common/types/clova-studio'; + +export function chatLog2clovaStudioMessages( + chatLogs: ChatLog[], +): ClovaStudioMessage[] { + const convertedMessages = chatLogs.reduce((acc, { isHost, message }) => { + acc.push({ + role: isHost ? 'assistant' : 'user', + content: message, + }); + return acc; + }, [] as ClovaStudioMessage[]); + + return [...convertedMessages]; +} diff --git a/backend/was/src/chatbot/clova-studio/message/creator.ts b/backend/was/src/chatbot/clova-studio/message/creator.ts new file mode 100644 index 00000000..75767a19 --- /dev/null +++ b/backend/was/src/chatbot/clova-studio/message/creator.ts @@ -0,0 +1,30 @@ +import { WsException } from '@nestjs/websockets'; +import { + TALK_SYSTEM_MESSAGE, + TAROTCARD_NAMES, + TAROTREADING_SYSTEM_MESSAGE, +} from 'src/common/constants/clova-studio'; +import { ERR_MSG } from 'src/common/constants/errors'; +import type { ClovaStudioMessage } from 'src/common/types/clova-studio'; + +export function createTalkSystemMessage(): ClovaStudioMessage { + return { role: 'system', content: TALK_SYSTEM_MESSAGE }; +} + +export function createTarotCardSystemMessage(): ClovaStudioMessage { + return { role: 'system', content: TAROTREADING_SYSTEM_MESSAGE }; +} + +export function createUserMessage(userMessage: string): ClovaStudioMessage { + if (!userMessage.trim()) { + throw new WsException(ERR_MSG.USER_CHAT_MESSAGE_INPUT_EMPTY); + } + return { role: 'user', content: userMessage }; +} + +export function createTarotCardMessage(cardIdx: number): ClovaStudioMessage { + if (cardIdx < 0 || cardIdx >= TAROTCARD_NAMES.length) { + throw new WsException(ERR_MSG.TAROT_CARD_IDX_OUT_OF_RANGE); + } + return { role: 'user', content: TAROTCARD_NAMES[cardIdx] }; +} diff --git a/backend/was/src/chatbot/clova-studio/message/index.ts b/backend/was/src/chatbot/clova-studio/message/index.ts new file mode 100644 index 00000000..d293c8cc --- /dev/null +++ b/backend/was/src/chatbot/clova-studio/message/index.ts @@ -0,0 +1,3 @@ +export { chatLog2clovaStudioMessages } from './converter'; + +export { buildTalkMessages, buildTarotReadingMessages } from './builder'; diff --git a/backend/was/src/chatbot/clova-studio/message/message.spec.ts b/backend/was/src/chatbot/clova-studio/message/message.spec.ts new file mode 100644 index 00000000..975ed0d2 --- /dev/null +++ b/backend/was/src/chatbot/clova-studio/message/message.spec.ts @@ -0,0 +1,116 @@ +import { + TALK_SYSTEM_MESSAGE, + TAROTCARD_NAMES, + TAROTREADING_SYSTEM_MESSAGE, +} from 'src/common/constants/clova-studio'; +import type { ChatLog } from 'src/common/types/chatbot'; +import type { ClovaStudioMessage } from 'src/common/types/clova-studio'; +import { buildTalkMessages, buildTarotReadingMessages } from './builder'; +import { chatLog2clovaStudioMessages } from './converter'; +import { createTarotCardMessage, createUserMessage } from './creator'; + +describe('[chatbot/clova-studio/message]', () => { + describe('function chatLog2clovaStudioMessages()', () => { + it('ChatLog 타입의 채팅 기록을 ClovaStudioMessage 타입의 채팅 기록으로 전환', () => { + const input: ChatLog[] = [ + { isHost: true, message: 'host message' }, + { isHost: false, message: 'user message' }, + ]; + const output: ClovaStudioMessage[] = [ + { role: 'assistant', content: 'host message' }, + { role: 'user', content: 'user message' }, + ]; + + expect(chatLog2clovaStudioMessages(input)).toEqual(output); + }); + + it('입력이 빈 배열일 때, 그대로 빈 배열이 출력', () => { + const input: ChatLog[] = []; + const output: ClovaStudioMessage[] = []; + + expect(chatLog2clovaStudioMessages(input)).toEqual(output); + }); + }); + + describe('function createUserMessage()', () => { + it('사용자 채팅 기록을 ClovaStudioMessage 타입으로 반환', () => { + const output: ClovaStudioMessage = { + role: 'user', + content: 'user message', + }; + expect(createUserMessage('user message')).toEqual(output); + }); + it('입력이 빈 값이면, 오류 발생', () => { + expect(() => createUserMessage('')).toThrow(); + expect(() => createUserMessage(' ')).toThrow(); + }); + }); + + describe('function createTarotCardMessage()', () => { + it('유저가 타로 카드를 뽑은 기록을 ClovaStudioMessage 타입으로 반환', () => { + const output: ClovaStudioMessage = { + role: 'user', + content: TAROTCARD_NAMES[21], + }; + + expect(createTarotCardMessage(21)).toEqual(output); + }); + it('입력이 범위에서 벗어난 값일 경우, 오류 발생', () => { + expect(() => createTarotCardMessage(-1)).toThrow(); + expect(() => createTarotCardMessage(79)).toThrow(); + }); + }); + + describe('function buildTalkMessages()', () => { + it('일반 채팅을 위한 시스템 메세지와 유저 메세지를 추가한 ClovaStudioMessage 형식의 채팅 기록 반환', () => { + const input: ClovaStudioMessage[] = [ + { role: 'assistant', content: 'assistant message 1' }, + { role: 'user', content: 'user message 1' }, + { role: 'assistant', content: 'assistant message 2' }, + ]; + const output: ClovaStudioMessage[] = [ + { role: 'system', content: TALK_SYSTEM_MESSAGE }, + { role: 'assistant', content: 'assistant message 1' }, + { role: 'user', content: 'user message 1' }, + { role: 'assistant', content: 'assistant message 2' }, + { role: 'user', content: 'user message 2' }, + ]; + + expect(buildTalkMessages(input, 'user message 2')).toEqual(output); + }); + + it('입력이 빈 배열인 경우, 시스템 메세지와 추가된 유저 메세지만 반환', () => { + const input: ClovaStudioMessage[] = []; + const output: ClovaStudioMessage[] = [ + { role: 'system', content: TALK_SYSTEM_MESSAGE }, + { role: 'user', content: 'user message' }, + ]; + + expect(buildTalkMessages(input, 'user message')).toEqual(output); + }); + }); + + describe('function buildTarotReadingMessages()', () => { + it('타로 카드 해설을 위한 시스템 메세지와 타로 카드 뽑은 기록을 추가한 ClovaStudioMessage 형식의 채팅 기록 반환', () => { + const input: ClovaStudioMessage[] = [ + { role: 'assistant', content: 'assistant message 1' }, + { role: 'user', content: 'user message 1' }, + { role: 'assistant', content: 'assistant message 2' }, + ]; + const output: ClovaStudioMessage[] = [ + { role: 'system', content: TALK_SYSTEM_MESSAGE }, + { role: 'assistant', content: 'assistant message 1' }, + { role: 'user', content: 'user message 1' }, + { role: 'assistant', content: 'assistant message 2' }, + { role: 'system', content: TAROTREADING_SYSTEM_MESSAGE }, + { role: 'user', content: TAROTCARD_NAMES[21] }, + ]; + + expect(buildTarotReadingMessages(input, 21)).toEqual(output); + }); + it('타로 카드 index 입력이 범위에서 벗어난 값일 때, 오류 발생', () => { + expect(() => buildTarotReadingMessages([], -1)).toThrow(); + expect(() => buildTarotReadingMessages([], 79)).toThrow(); + }); + }); +}); diff --git a/backend/was/src/chatbot/clova-studio/stream/converter.ts b/backend/was/src/chatbot/clova-studio/stream/converter.ts new file mode 100644 index 00000000..ae4a58f4 --- /dev/null +++ b/backend/was/src/chatbot/clova-studio/stream/converter.ts @@ -0,0 +1,116 @@ +import type { ClovaStudioEvent } from 'src/common/types/clova-studio'; +import { string2Uint8Array, uint8Array2String } from 'src/common/utils/stream'; + +export function apiResponseStream2TokenStream( + responseStream: ReadableStream, +): ReadableStream { + const transformStream = createTransformStream(); + const tokenStream = responseStream.pipeThrough(transformStream); + + return tokenStream; +} + +function createTransformStream(): TransformStream { + const transformer: Transformer = createTransformer(); + const transformStream = new TransformStream( + transformer, + ); + + return transformStream; +} + +// 각 chunk에서 token을 추출하는 transformer 생성 +function createTransformer(): Transformer { + let incompleteEvent = ''; + + return { + // 각 chunk에서 token을 추출해서 controller에 enqueue + async transform(chunk, controller) { + const events = splitChunk(chunk); + + if (incompleteEvent) { + events[0] = incompleteEvent + events[0]; + incompleteEvent = ''; + } + + const extractToken = getTokenExtractor({ + onFail: (event) => (incompleteEvent = event), + }); + + const newChunks = events.map(extractToken).filter(isToken); + newChunks + .map(string2Uint8Array) + .forEach((token) => controller.enqueue(token)); + }, + }; +} + +export function splitChunk(chunk: Uint8Array): string[] { + return uint8Array2String(chunk).split('\n\n'); +} + +type GetTokenExtractorOptions = { onFail: (incompleteEvent: string) => void }; + +export function getTokenExtractor({ + onFail, +}: GetTokenExtractorOptions): (chunk: string) => string | undefined { + return (chunk) => { + const streamEvent = streamEventParse(chunk); + + if (streamEvent === undefined) { + onFail(chunk); + } + if (!streamEvent || streamEvent.event !== 'token') { + return undefined; + } + + return streamEvent.data.message.content; + }; +} + +function isToken(object: any): object is string { + return typeof object === 'string'; +} + +export function streamEventParse(str: string): ClovaStudioEvent | undefined { + const lines = splitEvent(str); + + const event: any = lines.reduce((event, line) => { + const [key, value] = extractKeyValue(line); + + try { + return { ...event, [key]: JSON.parse(value) }; + } catch (err) { + return { ...event, [key]: value }; + } + }, {} as any); + + return isStreamEvent(event) ? (event as ClovaStudioEvent) : undefined; +} + +function splitEvent(str: string): string[] { + return str.split('\n'); +} + +export function extractKeyValue(line: string): [string, string] { + const splitIdx = line.indexOf(':'); + + if (splitIdx > 0) { + return [line.slice(0, splitIdx).trim(), line.slice(splitIdx + 1).trim()]; + } + return ['', '']; +} + +export function isStreamEvent(object: any): object is ClovaStudioEvent { + return ( + typeof object === 'object' && + object !== null && + 'id' in object && + 'event' in object && + 'data' in object && + typeof object.data === 'object' && + 'message' in object.data && + 'role' in object.data.message && + 'content' in object.data.message + ); +} diff --git a/backend/was/src/chatbot/clova-studio/stream/index.ts b/backend/was/src/chatbot/clova-studio/stream/index.ts new file mode 100644 index 00000000..60da05f1 --- /dev/null +++ b/backend/was/src/chatbot/clova-studio/stream/index.ts @@ -0,0 +1 @@ +export { apiResponseStream2TokenStream } from './converter'; diff --git a/backend/was/src/chatbot/clova-studio/stream/stream.spec.ts b/backend/was/src/chatbot/clova-studio/stream/stream.spec.ts new file mode 100644 index 00000000..6de566c4 --- /dev/null +++ b/backend/was/src/chatbot/clova-studio/stream/stream.spec.ts @@ -0,0 +1,197 @@ +import { string2Uint8Array, uint8Array2String } from 'src/common/utils/stream'; +import { + createAllEventStringMock, + createResponseStreamMock, + eventIdMock, + vaildateEventString, + vaildateTokenStream, +} from 'src/mocks/clova-studio'; +import { + apiResponseStream2TokenStream, + extractKeyValue, + getTokenExtractor, + isStreamEvent, + splitChunk, + streamEventParse, +} from './converter'; + +describe('[chatbot/clova-studio/stream]', () => { + describe('function splitChunk()', () => { + const tokens = ['안', '녕', '하', '세', '요']; + const orignalChunk = string2Uint8Array(createAllEventStringMock(tokens)); + + const vaildateSplitedChunks = (chunks: string[]) => { + expect(chunks.length).toBe(tokens.length + 1); + + chunks.forEach((chunk) => { + expect(vaildateEventString(chunk)).toBeTruthy(); + }); + }; + + it('하나의 chunk에 event가 여러 개 있을 때, 이벤트 별로 분리해서 반환', () => { + const splitedChunks = splitChunk(orignalChunk); + vaildateSplitedChunks(splitedChunks); + }); + + it('입력값에 시작이나 끝에 줄바꿈이나 공백이 들어가도 정상적으로 분리해서 반환', () => { + const orignalChunkString = uint8Array2String(orignalChunk); + const inputs = [ + `\n${orignalChunkString}`, + `\n\n${orignalChunkString}`, + `${orignalChunkString}\n`, + `${orignalChunkString}\n\n`, + ` ${orignalChunkString}`, + `${orignalChunkString} `, + ].map(string2Uint8Array); + + inputs.forEach((input) => { + const splitedChunks = splitChunk(input); + vaildateSplitedChunks(splitedChunks); + }); + }); + }); + + describe('function extractKeyValue()', () => { + it('event 문자열에서 key, value를 뽑아 object로 생성해 반환', () => { + const testcases = [ + { + input: `id: ${eventIdMock}`, + output: ['id', eventIdMock], + }, + { + input: `event: token`, + output: ['event', 'token'], + }, + { + input: `data: {"message": {"role": "assistant", "content": "안"}}`, + output: [ + 'data', + '{"message": {"role": "assistant", "content": "안"}}', + ], + }, + ]; + + testcases.forEach(({ input, output }) => { + expect(extractKeyValue(input)).toEqual(output); + }); + }); + }); + + describe('function isStreamEvent()', () => { + it('입력된 object가 event 객체 형식이 맞는지 여부를 반환', () => { + expect( + isStreamEvent({ + id: eventIdMock, + event: 'token', + data: { message: { role: 'assistant', content: '안' } }, + }), + ).toBeTruthy(); + + expect( + isStreamEvent({ + date: '2023-12-15', + content: '부스트캠프 8기 수료식', + }), + ).toBeFalsy(); + }); + + it('data 값이 존재하지 않는 object일 경우, false를 반환', () => { + const input = { + id: eventIdMock, + event: 'token', + }; + expect(isStreamEvent(input)).toBeFalsy(); + }); + + it('data 값이 있지만 완전하지 않은 값일 경우, false를 반환', () => { + const inputs = [ + { + id: eventIdMock, + event: 'token', + data: '{"message": {"role": "assistant", "conte', + }, + { + id: eventIdMock, + event: 'token', + data: '', + }, + ]; + inputs.forEach((input) => { + expect(isStreamEvent(input)).toBeFalsy(); + }); + }); + + it('input이 event와 상관없는 특별한 형식일 경우, false를 반환', () => { + const input = [null, undefined, '', [], {}, () => {}]; + expect(isStreamEvent(input)).toBeFalsy(); + }); + }); + + describe('function streamEventParse()', () => { + it('event가 문자열로 주어질 경우 object로 파싱해서 반환', () => { + const input = `id: ${eventIdMock} +event: token +data: {"message": {"role": "assistant", "content": "안"}}`; + const output = { + id: eventIdMock, + event: 'token', + data: { message: { role: 'assistant', content: '안' } }, + }; + + expect(streamEventParse(input)).toEqual(output); + }); + + it('input이 완전한 event의 문자열이 아닐 경우 undefined를 반환', () => { + const inputs = [ + `id: ${eventIdMock}\nevent: token`, + `id: ${eventIdMock}\nevent: token\ndata: {"message": {"role": "assistant", "conte`, + `id: ${eventIdMock}\nevent: token\ndata: `, + `id: ${eventIdMock}\nevent: token\nda`, + ]; + inputs.forEach((input) => { + expect(streamEventParse(input)).toBeUndefined(); + }); + }); + + it('input가 빈 값인 경우에도 undefined를 반환', () => { + expect(streamEventParse('')).toBeUndefined(); + }); + }); + + describe('function getTokenExtractor()', () => { + it('event string에서 token을 추출해서 반환, 추출에 성공한 경우에는 onFail 콜백이 실행되지 않는다.', () => { + const onFail = jest.fn(); + const extractor = getTokenExtractor({ onFail }); + const event = `id: aabdfe-dfgwr-edf-hpqwd-f2asd-g +event: token +data: {"message": {"role": "assistant", "content": "안"}}`; + + expect(extractor(event)).toBe('안'); + expect(onFail).not.toHaveBeenCalled(); + }); + + it('token 추출에 실패하는 경우에는 undefined를 반환하고 onFail 콜백이 실행된다.', () => { + const onFail = jest.fn(); + const extractor = getTokenExtractor({ onFail }); + + const event = `id: aabdfe-dfgwr-edf-hpqwd-f2asd-g +event: token +data: {"message": {"role": "assistant", "conte`; + + expect(extractor(event)).toBeUndefined(); + expect(onFail).toHaveBeenCalledTimes(1); + expect(onFail).toHaveBeenCalledWith(event); + }); + }); + + describe('function apiResponseStream2TokenStream()', () => { + it('api의 response로 받은 stream을 token만을 chunk로 하는 stream를 변환', async () => { + const tokens = ['안', '녕', '하', '세', '요']; + + const responseStream = await createResponseStreamMock(tokens); + const tokenStream = apiResponseStream2TokenStream(responseStream); + + expect(await vaildateTokenStream(tokenStream, tokens)).toBeTruthy(); + }); + }); +}); diff --git a/backend/was/src/common/constants/events.ts b/backend/was/src/common/constants/clova-studio.ts similarity index 89% rename from backend/was/src/common/constants/events.ts rename to backend/was/src/common/constants/clova-studio.ts index 7e2cba9d..2db1ec3b 100644 --- a/backend/was/src/common/constants/events.ts +++ b/backend/was/src/common/constants/clova-studio.ts @@ -1,19 +1,41 @@ export const CLOVA_URL = 'https://clovastudio.stream.ntruss.com/testapp/v1/chat-completions/HCX-002'; -export const chatMaxTokens = 100; -export const tarotMaxTokens = 800; - -export const welcomeMessage = - '안녕, 나는 어떤 고민이든지 들어주는 마법의 소라고둥이야!\n고민이 있으면 말해줘!'; -export const askTarotCardMessage = '타로 카드를 뽑아볼까?'; -export const askTarotCardCandidates = [ - '타로 카드를 뽑', - '타로를 뽑', - '뽑아볼까?', +export const CLOVA_API_KEY_NAMES = [ + 'X_NCP_APIGW_API_KEY', + 'X_NCP_CLOVASTUDIO_API_KEY', ]; -export const tarotReadingSystemMessage = ` +export const CLOVA_API_DEFAULT_HEADER_OPTIONS = { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', +}; +export const CLOVA_API_DEFAULT_BODY_OPTIONS = { + topK: 0, + includeAiFilters: true, + maxTokens: 0, + temperature: 0.28, + messages: [], + repeatPenalty: 3.0, + topP: 0.8, +}; + +export const CHAT_MAX_TOKENS = 100; +export const TAROT_MAX_TOKENS = 800; + +export const ASK_TAROTCARD_MESSAGE = '타로 카드를 뽑아볼까?'; + +export const TALK_SYSTEM_MESSAGE = ` +사용자와 친근한 반말로 상황에 맞게 대화를 이어가며, +- user의 고민에 대해 공감성 있는 반말로 대화를 이어가기 +- assistant는 user의 고민을 상담해주는 타로 상담사이다. 역할에 벗어나는 대화를 하지 않기 +- 사용자가 무언가를 알려달라고 하거나 알고 싶은 것이 명확해질 때, 정확히 "그럼 ${ASK_TAROTCARD_MESSAGE}"라는 문장으로만 응답하기 +- "그럼 ${ASK_TAROTCARD_MESSAGE}"라는 문장 이외의 표현으로 타로 카드를 뽑자고 말하지 않기 +- 타로 카드 해설을 요구하는 system 메세지가 오기 전까지, 타로에 대한 설명은 하지 않기 +- 답변은 50토큰 이하로 제한되며, 간결하게 표현하기 +- 답변은 반드시 반말로 작성하기. 존댓말을 사용하지 않기`; + +export const TAROTREADING_SYSTEM_MESSAGE = ` 사용자가 말한 고민에 대해 공감성 멘트로 친근한 반말로 타로 카드를 해설하시오. 500토큰 이하로 답변하시오 ### @@ -60,17 +82,7 @@ export const tarotReadingSystemMessage = ` 친구와의 관계를 회복하는 데 시간이 걸릴 수 있겠지만, 여황의 힘을 믿고 포기하지 않고 노력해봐. 분명히 긍정적인 전환을 이룰 수 있을 거야. 힘내고 화이팅이야! ###`; -export const talkSystemMessage = ` -사용자와 친근한 반말로 상황에 맞게 대화를 이어가며, -- user의 고민에 대해 공감성 있는 반말로 대화를 이어가기 -- assistant는 user의 고민을 상담해주는 타로 상담사이다. 역할에 벗어나는 대화를 하지 않기 -- 사용자가 무언가를 알려달라고 하거나 알고 싶은 것이 명확해질 때, 정확히 "그럼 ${askTarotCardMessage}"라는 문장으로만 응답하기 -- "그럼 ${askTarotCardMessage}"라는 문장 이외의 표현으로 타로 카드를 뽑자고 말하지 않기 -- 타로 카드 해설을 요구하는 system 메세지가 오기 전까지, 타로에 대한 설명은 하지 않기 -- 답변은 50토큰 이하로 제한되며, 간결하게 표현하기 -- 답변은 반드시 반말로 작성하기. 존댓말을 사용하지 않기`; - -export const tarotCardNames = [ +export const TAROTCARD_NAMES = [ 'The Fool', 'The Magician', 'The High Priestess', diff --git a/backend/was/src/common/constants/errors.ts b/backend/was/src/common/constants/errors.ts index c25ed754..9f347432 100644 --- a/backend/was/src/common/constants/errors.ts +++ b/backend/was/src/common/constants/errors.ts @@ -29,11 +29,23 @@ export const ERR_MSG = { OAUTH_KAKAO_ACCESS_TOKEN_INFO_BAD_REQUEST: '카카오 액세스 토큰 정보 조회에 실패했습니다.', + /** + * chatbot + */ + USER_CHAT_MESSAGE_INPUT_EMPTY: '사용자 입력한 채팅 메세지가 비어있습니다.', + USER_CHAT_MESSAGE_INPUT_TOO_LONG: '사용자 입력한 채팅 메세지가 너무 깁니다.', + TAROT_CARD_IDX_OUT_OF_RANGE: '타로 카드 인덱스가 범위를 벗어났습니다.', + AI_API_KEY_NOT_FOUND: 'API 키를 찾을 수 없습니다.', + AI_API_FAILED: '인공지능 API 호출에 실패했습니다.', + AI_API_RESPONSE_EMPTY: '인공지능 API 응답이 비어있습니다.', + /** * socket */ + CREATE_ROOM: '채팅방을 생성하는 데 실패했습니다.', SAVE_CHATTING_LOG: '채팅 로그를 저장하는 데 실패했습니다.', SAVE_TAROT_RESULT: '타로 결과를 저장하는 데 실패했습니다.', + HANDLE_MESSAGE: '서버에서 메시지를 처리하는 데 실패했습니다.', /** * common diff --git a/backend/was/src/common/constants/socket.ts b/backend/was/src/common/constants/socket.ts new file mode 100644 index 00000000..e810a5a9 --- /dev/null +++ b/backend/was/src/common/constants/socket.ts @@ -0,0 +1,8 @@ +export const WELCOME_MESSAGE = + '안녕, 나는 어떤 고민이든지 들어주는 마법의 소라고둥이야!\n고민이 있으면 말해줘!'; + +export const ASK_TAROTCARD_MESSAGE_CANDIDATES = [ + '타로 카드를 뽑', + '타로를 뽑', + '뽑아볼까?', +]; diff --git a/backend/was/src/common/types/chatbot.ts b/backend/was/src/common/types/chatbot.ts new file mode 100644 index 00000000..35b1832f --- /dev/null +++ b/backend/was/src/common/types/chatbot.ts @@ -0,0 +1,4 @@ +export type ChatLog = { + isHost: boolean; + message: string; +}; diff --git a/backend/was/src/common/types/clova-studio.ts b/backend/was/src/common/types/clova-studio.ts new file mode 100644 index 00000000..77cfcdf0 --- /dev/null +++ b/backend/was/src/common/types/clova-studio.ts @@ -0,0 +1,18 @@ +import { CLOVA_API_KEY_NAMES } from '../constants/clova-studio'; + +export type ClovaStudioEvent = { + id: string; + event: string; + data: { + message: ClovaStudioMessage; + }; +}; + +export type ClovaStudioMessage = { + role: 'user' | 'system' | 'assistant'; + content: string; +}; + +export type ClovaStudioApiKeys = { + [key in (typeof CLOVA_API_KEY_NAMES)[number]]: string; +}; diff --git a/backend/was/src/common/types/socket.ts b/backend/was/src/common/types/socket.ts new file mode 100644 index 00000000..6ac04f47 --- /dev/null +++ b/backend/was/src/common/types/socket.ts @@ -0,0 +1,7 @@ +import { Socket as originalSocket } from 'socket.io'; +import { ChatLog } from './chatbot'; + +export interface Socket extends originalSocket { + chatLog: ChatLog[]; + chatEnd: boolean; +} diff --git a/backend/was/src/common/utils/stream.ts b/backend/was/src/common/utils/stream.ts new file mode 100644 index 00000000..cdf2310e --- /dev/null +++ b/backend/was/src/common/utils/stream.ts @@ -0,0 +1,48 @@ +export function string2Uint8Array(str: string): Uint8Array { + return new TextEncoder().encode(str); +} + +export function uint8Array2String(uint8Array: Uint8Array): string { + return new TextDecoder().decode(uint8Array).trim(); +} + +export async function string2Uint8ArrayStream( + input: string, +): Promise> { + const encoder = new TextEncoder(); + const uint8Array = encoder.encode(input); + + const readableStream = new ReadableStream({ + async start(controller) { + controller.enqueue(uint8Array); + controller.close(); + }, + }); + + return readableStream; +} + +export function readStream( + stream: ReadableStream, + onStreaming: (token: string) => void, +): Promise { + let message = ''; + const reader = stream.getReader(); + + return new Promise((resolve) => { + const readStream = () => { + reader.read().then(({ done, value }) => { + if (done) { + resolve(message); + return; + } + const token = new TextDecoder().decode(value); + message += token; + onStreaming(message); + + return readStream(); + }); + }; + readStream(); + }); +} diff --git a/backend/was/src/events/clova-studio.ts b/backend/was/src/events/clova-studio.ts deleted file mode 100644 index d631d398..00000000 --- a/backend/was/src/events/clova-studio.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { WsException } from '@nestjs/websockets'; -import { - CLOVA_URL, - chatMaxTokens, - talkSystemMessage, - tarotMaxTokens, - tarotReadingSystemMessage, -} from '../common/constants/events'; -import { convertClovaEventStream2TokenStream } from './stream'; -import type { Chat } from './type'; - -class ClovaStudio { - headers; - - constructor( - public readonly X_NCP_APIGW_API_KEY: string, - public readonly X_NCP_CLOVASTUDIO_API_KEY: string, - ) { - this.headers = { - 'X-NCP-CLOVASTUDIO-API-KEY': X_NCP_CLOVASTUDIO_API_KEY, - 'X-NCP-APIGW-API-KEY': X_NCP_APIGW_API_KEY, - 'Content-Type': 'application/json', - Accept: 'text/event-stream', - }; - } - - initChatLog(chatLog: Chat[]): void { - chatLog.length = 0; - chatLog.push({ role: 'system', content: talkSystemMessage }); - } - - createTalk(chatLog: Chat[], message: string) { - chatLog.push({ role: 'user', content: message }); - return this.fetchClovaAPI(chatLog, chatMaxTokens); - } - - createTarotReading(chatLog: Chat[], tarotName: string) { - chatLog.push( - { role: 'system', content: tarotReadingSystemMessage }, - { role: 'user', content: `타로 카드: ${tarotName}\n` }, - ); - return this.fetchClovaAPI(chatLog, tarotMaxTokens); - } - - private async fetchClovaAPI(chatLog: Chat[], maxTokens: number) { - const response = await fetch(CLOVA_URL, { - method: 'POST', - headers: this.headers, - body: JSON.stringify({ - topK: 0, - includeAiFilters: true, - maxTokens: maxTokens, - temperature: 0.28, - messages: chatLog, - repeatPenalty: 3.0, - topP: 0.8, - }), - }); - - if (!response.ok || !response.body) { - throw new WsException('서버에서 Clova API를 호출하는데 실패했습니다.'); - } - - const tokenStream = convertClovaEventStream2TokenStream(response.body); - return tokenStream; - } -} - -export default ClovaStudio; diff --git a/backend/was/src/events/create-dto-helper.ts b/backend/was/src/events/create-dto-helper.ts deleted file mode 100644 index 8dc6c496..00000000 --- a/backend/was/src/events/create-dto-helper.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { CreateChattingMessageDto } from 'src/chat/dto/create-chatting-message.dto'; -import type { Chat, Message } from './type'; - -export function createChattingMessageDtos( - roomId: string, - chatLog: Chat[], -): CreateChattingMessageDto[] { - const messages: Message[] = chatLog.map((chat: Chat): Message => { - return { - roomId: roomId, - chat: chat, - }; - }); - const parsingMessage: CreateChattingMessageDto[] = messages - .map(createChattingMessageDto) - .filter(isCreateChattingMessageDto); - return parsingMessage.slice(0, -2).concat(parsingMessage.slice(-1)); -} - -function createChattingMessageDto( - message: Message, -): CreateChattingMessageDto | undefined { - if (message.chat.role === 'system') { - return undefined; - } - return CreateChattingMessageDto.fromMessage(message); -} - -function isCreateChattingMessageDto( - message: CreateChattingMessageDto | undefined, -): message is CreateChattingMessageDto { - return message instanceof CreateChattingMessageDto; -} diff --git a/backend/was/src/events/events.gateway.ts b/backend/was/src/events/events.gateway.ts deleted file mode 100644 index c3e184a7..00000000 --- a/backend/was/src/events/events.gateway.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { ConfigService } from '@nestjs/config'; -import { - OnGatewayConnection, - OnGatewayDisconnect, - OnGatewayInit, - SubscribeMessage, - WebSocketGateway, - WebSocketServer, - WsException, -} from '@nestjs/websockets'; -import { AIClientEvent, AIServerEvent } from '@tarotmilktea/ai-socketio-event'; -import { Server, Socket } from 'socket.io'; -import { ChatService, ChattingInfo } from 'src/chat/chat.service'; -import { ERR_MSG } from 'src/common/constants/errors'; -import { LoggerService } from 'src/logger/logger.service'; -import { CreateTarotResultDto } from 'src/tarot/dto/create-tarot-result.dto'; -import { TarotService } from 'src/tarot/tarot.service'; -import { - askTarotCardCandidates, - tarotCardNames, - welcomeMessage, -} from '../common/constants/events'; -import ClovaStudio from './clova-studio'; -import { createChattingMessageDtos } from './create-dto-helper'; -import { readTokenStream, string2TokenStream } from './stream'; -import type { MySocket } from './type'; - -@WebSocketGateway({ - cors: { origin: '*' }, -}) -export class EventsGateway - implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect -{ - clovaStudio: ClovaStudio; - - constructor( - private readonly configService: ConfigService, - private readonly chatService: ChatService, - private readonly tarotService: TarotService, - private readonly logger: LoggerService, - ) { - const X_NCP_APIGW_API_KEY = this.configService.get('X_NCP_APIGW_API_KEY'); - const X_NCP_CLOVASTUDIO_API_KEY = this.configService.get( - 'X_NCP_CLOVASTUDIO_API_KEY', - ); - - this.clovaStudio = new ClovaStudio( - X_NCP_APIGW_API_KEY, - X_NCP_CLOVASTUDIO_API_KEY, - ); - } - - @WebSocketServer() - server: Server; - - afterInit(server: Server) { - this.logger.info('🚀 웹소켓 서버 초기화'); - } - - handleDisconnect(client: Socket) { - this.logger.debug(`🚀 Client Disconnected : ${client.id}`); - } - - handleConnection(client: MySocket, ...args: any[]) { - this.logger.debug(`🚀 Client Connected : ${client.id}`); - - client.chatLog = []; - this.clovaStudio.initChatLog(client.chatLog); - - client.chatEnd = false; - - setTimeout(() => this.welcome(client), 2000); - } - - @SubscribeMessage('message') - async handleMessageEvent(client: MySocket, message: string) { - this.logger.debug(`🚀 Received a message from ${client.id}`); - - if (client.chatEnd) { - return; - } - this.eventEmit(client, 'streamStart'); - - const stream = await this.clovaStudio.createTalk(client.chatLog, message); - if (stream) { - const sentMessage = await this.streamMessage(client, stream); - - const askedTarotCard = askTarotCardCandidates.some((candidates) => - sentMessage.includes(candidates), - ); - if (askedTarotCard) { - this.eventEmit(client, 'tarotCard'); - } - } - } - - @SubscribeMessage('tarotRead') - async handleTarotReadEvent(client: MySocket, cardIdx: number) { - this.logger.debug( - `🚀 TarotRead request received from ${client.id}: ${cardIdx}번`, - ); - - this.eventEmit(client, 'streamStart'); - - const stream = await this.clovaStudio.createTarotReading( - client.chatLog, - tarotCardNames[cardIdx], - ); - if (stream) { - const sentMessage = await this.streamMessage(client, stream); - - this.saveChatLog(client); - const shareLinkId = await this.createShareLinkId(cardIdx, sentMessage); - - this.eventEmit(client, 'chatEnd', shareLinkId); - } - } - - private eventEmit(client: MySocket, event: AIServerEvent, ...args: any[]) { - client.emit(event, ...args); - } - - private async createRoom(client: MySocket) { - const chattingInfo: ChattingInfo = await this.chatService.createRoom( - client.id, - ); - client.memberId = chattingInfo.memeberId; - client.chatRoomId = chattingInfo.roomId; - } - - private welcome(client: MySocket) { - this.eventEmit(client, 'streamStart'); - - const stream = string2TokenStream(welcomeMessage); - this.streamMessage(client, stream); - } - - private async streamMessage( - client: MySocket, - stream: ReadableStream, - ) { - const onStreaming = (token: string) => - this.eventEmit(client, 'streaming', token); - const sentMessage = await readTokenStream(stream, onStreaming); - - client.chatLog.push({ role: 'assistant', content: sentMessage }); - - this.logger.debug(`🚀 Send a message to ${client.id}`); - this.eventEmit(client, 'streamEnd'); - - return sentMessage; - } - - private async saveChatLog(client: MySocket) { - try { - this.createRoom(client); - - const createChattingMessageDto = createChattingMessageDtos( - client.chatRoomId, - client.chatLog, - ); - this.chatService.createMessage( - client.chatRoomId, - createChattingMessageDto, - ); - } catch (err: unknown) { - if (err instanceof Error) { - this.logger.error( - `🚀 Failed to save chat log : ${err.message}`, - err.stack, - ); - } - throw new WsException(ERR_MSG.SAVE_CHATTING_LOG); - } - } - - private async createShareLinkId( - cardIdx: number, - result: string, - ): Promise { - try { - const createTarotResultDto: CreateTarotResultDto = - CreateTarotResultDto.fromResult(cardIdx, result); - return await this.tarotService.createTarotResult(createTarotResultDto); - } catch (err: unknown) { - if (err instanceof Error) { - this.logger.error( - `🚀 Failed to create share link ID : ${err.message}`, - err.stack, - ); - } - throw new WsException(ERR_MSG.SAVE_TAROT_RESULT); - } - } -} diff --git a/backend/was/src/events/stream.ts b/backend/was/src/events/stream.ts deleted file mode 100644 index 7333da4b..00000000 --- a/backend/was/src/events/stream.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { ClovaEvent } from './type'; - -const encoder = new TextEncoder(); - -export function convertClovaEventStream2TokenStream( - clovaEventStream: ReadableStream, -): ReadableStream { - const transformer = createTransformer(); - return clovaEventStream.pipeThrough(transformer); -} - -export function readTokenStream( - stream: ReadableStream, - onStreaming: (token: string) => void, -): Promise { - let message = ''; - const reader = stream.getReader(); - - return new Promise((resolve) => { - const readStream = () => { - reader.read().then(({ done, value }) => { - if (done) { - resolve(message); - return; - } - const token = new TextDecoder().decode(value); - message += token; - onStreaming(message); - - return readStream(); - }); - }; - readStream(); - }); -} - -export function string2TokenStream(string: string): ReadableStream { - const uint8Array = string2Uint8Array(string); - const stream = uint8Array2Stream(uint8Array); - return stream; -} - -function createTransformer(): TransformStream { - let incompleteEvent = ''; - - const transformer: Transformer = { - async transform(chunk, controller) { - const splitedChunk = new TextDecoder().decode(chunk).split('\n\n'); - - if (incompleteEvent) { - splitedChunk[0] = incompleteEvent + splitedChunk[0]; - incompleteEvent = ''; - } - - const extractToken = (chunk: string): string | undefined => { - const streamEvent = streamEventParse(chunk); - if (streamEvent === undefined) { - incompleteEvent = chunk; - return; - } - if (streamEvent.event !== 'token') { - return; - } - return streamEvent.data.message.content; - }; - - const newChunks = splitedChunk.map(extractToken).filter(isString); - - newChunks - .map((token) => encoder.encode(token)) - .forEach((encodeedToken) => controller.enqueue(encodeedToken)); - }, - }; - - return new TransformStream(transformer); -} - -function isString(object: any): object is string { - return typeof object === 'string'; -} - -function streamEventParse(str: string): ClovaEvent | undefined { - const event: any = str.split('\n').reduce((event, line) => { - const splitIdx = line.indexOf(':'); - const [key, value] = [line.slice(0, splitIdx), line.slice(splitIdx + 1)]; - - if (key === 'id' || key === 'event') { - return { ...event, [key]: value }; - } - if (key === 'data') { - try { - return { ...event, [key]: JSON.parse(value) }; - } catch (err) { - return event; - } - } - return event; - }, {} as any); - - return isStreamEvent(event) ? (event as ClovaEvent) : undefined; -} - -function isStreamEvent(object: any): object is ClovaEvent { - return ( - typeof object === 'object' && - object !== null && - 'id' in object && - 'event' in object && - 'data' in object && - 'message' in object.data && - 'role' in object.data.message && - 'content' in object.data.message - ); -} - -function string2Uint8Array(string: string): Uint8Array { - return encoder.encode(string); -} - -function uint8Array2Stream(uint8Array: Uint8Array): ReadableStream { - const readableStream = new ReadableStream({ - start(controller) { - controller.enqueue(uint8Array); - controller.close(); - }, - }); - - return readableStream; -} diff --git a/backend/was/src/events/type.ts b/backend/was/src/events/type.ts deleted file mode 100644 index afa8427c..00000000 --- a/backend/was/src/events/type.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Socket } from 'socket.io'; - -type Role = 'user' | 'system' | 'assistant'; - -export interface MySocket extends Socket { - memberId: string; - chatLog: Chat[]; - chatEnd: boolean; - chatRoomId: string; -} - -export type Chat = { - role: Role; - content: string; -}; - -export type Message = { - roomId: string; - chat: Chat; -}; - -export type ClovaEvent = { - id: string; - event: string; - data: ClovaEventData; -}; - -type ClovaEventData = { - message: { - role: string; - content: string; - }; -}; diff --git a/backend/was/src/mocks/clova-studio/clova-studio.mocks.ts b/backend/was/src/mocks/clova-studio/clova-studio.mocks.ts new file mode 100644 index 00000000..de85bbd1 --- /dev/null +++ b/backend/was/src/mocks/clova-studio/clova-studio.mocks.ts @@ -0,0 +1,72 @@ +import { ConfigService } from '@nestjs/config'; +import { clovaStudioApi } from 'src/chatbot/clova-studio/api'; +import { CLOVA_API_KEY_NAMES } from 'src/common/constants/clova-studio'; +import { + string2Uint8ArrayStream, + uint8Array2String, +} from 'src/common/utils/stream'; + +export const eventIdMock = '123456-12345-123-12345-12345-1'; + +export const configServieMock = { + get(key: string) { + return CLOVA_API_KEY_NAMES.includes(key) ? key : undefined; + }, +} as ConfigService; + +export const clovaStudioApiMock = clovaStudioApi as jest.MockedFunction< + typeof clovaStudioApi +>; + +export function createResponseStreamMock( + tokens: string[], +): Promise> { + const chunk = createAllEventStringMock(tokens); + return string2Uint8ArrayStream(chunk); +} + +export function createAllEventStringMock(tokens: string[]): string { + return [...tokens, tokens.join('')].reduce( + (acc, content, idx) => + acc + createEventStringMock(content, idx === tokens.length), + '', + ); +} + +export function createEventStringMock( + content: string, + isResult = false, +): string { + return ( + `id: ${eventIdMock}\n` + + `event: ${isResult ? 'result' : 'token'}\n` + + `data: {"message": {"role": "assistant", "content": "${content}" }}\n\n` + ); +} + +export async function vaildateTokenStream( + stream: ReadableStream, + tokens: string[], +): Promise { + const reader = await stream.getReader(); + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (uint8Array2String(value) !== tokens.shift()) { + return false; + } + } + return tokens.length === 0; +} + +export function vaildateEventString(chunk: string): boolean { + const regx = + `^id: ${eventIdMock}\n` + + `event: (token|result)\\n` + + `data: {"message": {"role": "assistant", "content": ".+" }}$`; + + return new RegExp(regx, 'm').test(chunk); +} diff --git a/backend/was/src/mocks/clova-studio/index.ts b/backend/was/src/mocks/clova-studio/index.ts new file mode 100644 index 00000000..f5cce5d7 --- /dev/null +++ b/backend/was/src/mocks/clova-studio/index.ts @@ -0,0 +1 @@ +export * from './clova-studio.mocks'; diff --git a/backend/was/src/mocks/socket/index.ts b/backend/was/src/mocks/socket/index.ts new file mode 100644 index 00000000..69d50832 --- /dev/null +++ b/backend/was/src/mocks/socket/index.ts @@ -0,0 +1 @@ +export * from './socket.mocks'; diff --git a/backend/was/src/mocks/socket/socket.mocks.ts b/backend/was/src/mocks/socket/socket.mocks.ts new file mode 100644 index 00000000..c19818ae --- /dev/null +++ b/backend/was/src/mocks/socket/socket.mocks.ts @@ -0,0 +1,35 @@ +import { LoggerService } from '@nestjs/common'; +import { ChatService } from 'src/chat/chat.service'; +import { ChatbotService } from 'src/chatbot/chatbot.interface'; +import { Socket } from 'src/common/types/socket'; +import { string2Uint8ArrayStream } from 'src/common/utils/stream'; +import { TarotService } from 'src/tarot/tarot.service'; + +export const aiMessageMock = '인공지능입니다.'; +export const humanMessageMock = '사람입니다.'; +export const tarotIdxMock = 0; + +export const chatServiceMock = { + createRoom: () => 'room_id', + createMessage: jest.fn(), +} as unknown as ChatService; + +export const tarotServiceMock = { + createTarotResult: jest.fn(), +} as unknown as TarotService; + +export const loggerServiceMock = { + error: jest.fn(), +} as unknown as LoggerService; + +export const chatbotServiceMock = { + generateTalk: (...argv: any) => string2Uint8ArrayStream(aiMessageMock), + generateTarotReading: (...argv: any) => + string2Uint8ArrayStream(aiMessageMock), +} as unknown as ChatbotService; + +export const clientMock = { + chatLog: [], + chatEnd: false, + emit: jest.fn(), +} as unknown as Socket; diff --git a/backend/was/src/socket/socket.gateway.ts b/backend/was/src/socket/socket.gateway.ts new file mode 100644 index 00000000..514f9488 --- /dev/null +++ b/backend/was/src/socket/socket.gateway.ts @@ -0,0 +1,66 @@ +import { + OnGatewayConnection, + OnGatewayDisconnect, + OnGatewayInit, + SubscribeMessage, + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; +import { Server } from 'socket.io'; +import type { Socket } from 'src/common/types/socket'; +import { LoggerService } from 'src/logger/logger.service'; +import { SocketService } from './socket.service'; + +@WebSocketGateway({ + cors: { origin: '*' }, +}) +export class SocketGateway + implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect +{ + @WebSocketServer() + server: Server; + + constructor( + private readonly socketService: SocketService, + private readonly logger: LoggerService, + ) {} + + afterInit(server: Server) { + this.logger.info('🚀 웹소켓 서버 초기화'); + } + + handleConnection(client: any) { + this.logger.debug(`🚀 Client Connected : ${client.id}`); + + this.socketService.initClient(client); + this.socketService.sendWelcomeMessage(client); + } + + @SubscribeMessage('message') + async handleMessageEvent(client: Socket, message: string) { + this.logger.debug(`🚀 Received a message from ${client.id}`); + + const sentMessage = await this.socketService.handleMessageEvent( + client, + message, + ); + this.logger.debug(`🚀 Send a message to ${client.id}: ${sentMessage}`); + } + + @SubscribeMessage('tarotRead') + async handleTarotReadEvent(client: Socket, cardIdx: number) { + this.logger.debug( + `🚀 TarotRead request received from ${client.id}: ${cardIdx}`, + ); + + const sentMessage = await this.socketService.handleTarotReadEvent( + client, + cardIdx, + ); + this.logger.debug(`🚀 Send a message to ${client.id}: ${sentMessage}`); + } + + handleDisconnect(client: Socket) { + this.logger.debug(`🚀 Client Disconnected : ${client.id}`); + } +} diff --git a/backend/was/src/events/events.module.ts b/backend/was/src/socket/socket.module.ts similarity index 60% rename from backend/was/src/events/events.module.ts rename to backend/was/src/socket/socket.module.ts index 40a57902..975ad30f 100644 --- a/backend/was/src/events/events.module.ts +++ b/backend/was/src/socket/socket.module.ts @@ -1,16 +1,19 @@ +// socket.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { ChatService } from 'src/chat/chat.service'; import { ChattingMessage } from 'src/chat/entities/chatting-message.entity'; import { ChattingRoom } from 'src/chat/entities/chatting-room.entity'; +import { ChatbotModule } from 'src/chatbot/chatbot.module'; import { LoggerModule } from 'src/logger/logger.module'; -import { LoggerService } from 'src/logger/logger.service'; import { Member } from 'src/members/entities/member.entity'; import { TarotCardPack } from 'src/tarot/entities/tarot-card-pack.entity'; import { TarotCard } from 'src/tarot/entities/tarot-card.entity'; import { TarotResult } from 'src/tarot/entities/tarot-result.entity'; -import { TarotService } from 'src/tarot/tarot.service'; -import { EventsGateway } from './events.gateway'; +import { ChatService } from '../chat/chat.service'; +import { LoggerService } from '../logger/logger.service'; +import { TarotService } from '../tarot/tarot.service'; +import { SocketGateway } from './socket.gateway'; +import { SocketService } from './socket.service'; @Module({ imports: [ @@ -23,7 +26,14 @@ import { EventsGateway } from './events.gateway'; TarotCardPack, ]), LoggerModule, + ChatbotModule, + ], + providers: [ + SocketGateway, + SocketService, + ChatService, + TarotService, + LoggerService, ], - providers: [EventsGateway, ChatService, TarotService, LoggerService], }) -export class EventsModule {} +export class SocketModule {} diff --git a/backend/was/src/socket/socket.service.spec.ts b/backend/was/src/socket/socket.service.spec.ts new file mode 100644 index 00000000..a68fa49a --- /dev/null +++ b/backend/was/src/socket/socket.service.spec.ts @@ -0,0 +1,176 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ChatService } from 'src/chat/chat.service'; +import { + ASK_TAROTCARD_MESSAGE_CANDIDATES, + WELCOME_MESSAGE, +} from 'src/common/constants/socket'; +import { LoggerService } from 'src/logger/logger.service'; +import { + aiMessageMock, + chatServiceMock, + chatbotServiceMock, + clientMock, + humanMessageMock, + loggerServiceMock, + tarotIdxMock, + tarotServiceMock, +} from 'src/mocks/socket'; +import { TarotService } from 'src/tarot/tarot.service'; +import { SocketService } from './socket.service'; + +describe('SocketService', () => { + let socketService: SocketService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SocketService, + { provide: ChatService, useValue: chatServiceMock }, + { provide: TarotService, useValue: tarotServiceMock }, + { provide: LoggerService, useValue: loggerServiceMock }, + { provide: 'ChatbotService', useValue: chatbotServiceMock }, + ], + }).compile(); + + socketService = module.get(SocketService); + }); + + afterEach(() => { + jest.clearAllMocks(); + socketService.initClient(clientMock); + }); + + it('SocketService 생성', () => { + expect(socketService).toBeDefined(); + }); + + it('client 소켓 초기화', () => { + socketService.initClient(clientMock); + expect(clientMock.chatLog).toEqual([]); + expect(clientMock.chatEnd).toBeFalsy(); + }); + + describe('SocketService.sendWelcomeMessage()', () => { + it('token 단위로 메세지 전달', async () => { + const sentMessage = await socketService.sendWelcomeMessage(clientMock); + + const emitNum = (clientMock.emit as jest.Mock).mock.calls.length; + expect(emitNum).toBeGreaterThanOrEqual(3); + + expect(clientMock.emit).toHaveBeenCalledWith('streamStart'); + expect(clientMock.emit).toHaveBeenCalledWith( + 'streaming', + expect.anything(), + ); + expect(clientMock.emit).toHaveBeenCalledWith('streamEnd'); + + expect(sentMessage).toEqual(WELCOME_MESSAGE); + }); + + it('chatLog 업데이트', async () => { + await socketService.sendWelcomeMessage(clientMock); + + expect(clientMock.chatLog).toEqual([ + { isHost: true, message: WELCOME_MESSAGE }, + ]); + }); + + it('오류 발생 시 client에게 알림', () => {}); + }); + + describe('SocketService.handleMessageEvent()', () => { + it('token 단위로 ai 답장 전달', async () => { + const sentMessage = await socketService.handleMessageEvent( + clientMock, + humanMessageMock, + ); + + const emitNum = (clientMock.emit as jest.Mock).mock.calls.length; + expect(emitNum).toBeGreaterThanOrEqual(3); + + expect(clientMock.emit).toHaveBeenCalledWith('streamStart'); + expect(clientMock.emit).toHaveBeenCalledWith( + 'streaming', + expect.anything(), + ); + expect(clientMock.emit).toHaveBeenCalledWith('streamEnd'); + + expect(sentMessage).toEqual(aiMessageMock); + }); + + it('chatLog 업데이트', async () => { + expect(clientMock.chatLog).toEqual([]); + await socketService.handleMessageEvent(clientMock, humanMessageMock); + + expect(clientMock.chatLog).toEqual([ + { isHost: false, message: humanMessageMock }, + { isHost: true, message: aiMessageMock }, + ]); + }); + + it('ai가 타로 카드 뽑기 제안 시 tarotCard 이벤트 발생', async () => { + jest.spyOn(socketService, 'streamMessage').mockImplementation(() => { + return Promise.resolve(ASK_TAROTCARD_MESSAGE_CANDIDATES[0]); + }); + await socketService.handleMessageEvent(clientMock, humanMessageMock); + + expect(clientMock.emit).toHaveBeenCalledWith('tarotCard'); + }); + it('오류 발생 시 client에게 알림', () => {}); + }); + + describe('SocketService.handleTarotReadEvent()', () => { + it('token 단위로 ai 답장 전달', async () => { + const sentMessage = await socketService.handleTarotReadEvent( + clientMock, + tarotIdxMock, + ); + + const emitNum = (clientMock.emit as jest.Mock).mock.calls.length; + expect(emitNum).toBeGreaterThanOrEqual(3); + + expect(clientMock.emit).toHaveBeenCalledWith('streamStart'); + expect(clientMock.emit).toHaveBeenCalledWith( + 'streaming', + expect.anything(), + ); + expect(clientMock.emit).toHaveBeenCalledWith('streamEnd'); + + expect(sentMessage).toEqual(aiMessageMock); + }); + + it('chatLog 업데이트', async () => { + expect(clientMock.chatLog).toEqual([]); + await socketService.handleTarotReadEvent(clientMock, tarotIdxMock); + + expect(clientMock.chatLog).toEqual([ + { isHost: true, message: aiMessageMock }, + ]); + }); + + it('타로 결과 DB에 저장하고, client에게 결과 링크 ID 전달', async () => { + const shareLinkIdMock = 'shareLinkId'; + jest + .spyOn(tarotServiceMock, 'createTarotResult') + .mockImplementation(() => Promise.resolve(shareLinkIdMock)); + + await socketService.handleTarotReadEvent(clientMock, tarotIdxMock); + + expect(tarotServiceMock.createTarotResult).toHaveBeenCalled(); + expect(clientMock.emit).toHaveBeenCalledWith('chatEnd', shareLinkIdMock); + }); + + it('채팅 종료 상태 업데이트', async () => { + expect(clientMock.chatEnd).toBeFalsy(); + await socketService.handleTarotReadEvent(clientMock, tarotIdxMock); + expect(clientMock.chatEnd).toBeTruthy(); + }); + + it('chatLog DB에 저장', async () => { + await socketService.handleTarotReadEvent(clientMock, tarotIdxMock); + expect(chatServiceMock.createMessage).toHaveBeenCalled(); + }); + + it('오류 발생 시 client에게 알림', () => {}); + }); +}); diff --git a/backend/was/src/socket/socket.service.ts b/backend/was/src/socket/socket.service.ts new file mode 100644 index 00000000..cf116dcd --- /dev/null +++ b/backend/was/src/socket/socket.service.ts @@ -0,0 +1,172 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { WsException } from '@nestjs/websockets'; +import { ChatService } from 'src/chat/chat.service'; +import { CreateChattingMessageDto } from 'src/chat/dto/create-chatting-message.dto'; +import { ChatbotService } from 'src/chatbot/chatbot.interface'; +import { ERR_MSG } from 'src/common/constants/errors'; +import { + ASK_TAROTCARD_MESSAGE_CANDIDATES, + WELCOME_MESSAGE, +} from 'src/common/constants/socket'; +import { ChatLog } from 'src/common/types/chatbot'; +import type { Socket } from 'src/common/types/socket'; +import { readStream, string2Uint8ArrayStream } from 'src/common/utils/stream'; +import { LoggerService } from 'src/logger/logger.service'; +import { CreateTarotResultDto } from 'src/tarot/dto/create-tarot-result.dto'; +import { TarotService } from 'src/tarot/tarot.service'; + +@Injectable() +export class SocketService { + constructor( + @Inject('ChatbotService') private readonly chatbotService: ChatbotService, + private readonly chatService: ChatService, + private readonly tarotService: TarotService, + private readonly logger: LoggerService, + ) {} + + initClient(client: Socket) { + client.chatLog = []; + client.chatEnd = false; + } + + async sendWelcomeMessage(client: Socket) { + try { + const sentMessage = await this.streamMessage(client, () => + string2Uint8ArrayStream(WELCOME_MESSAGE), + ); + client.chatLog.push({ isHost: true, message: sentMessage }); + return sentMessage; + } catch (err) { + if (err instanceof Error) { + this.logger.error( + `🚀 Failed to send welcome message : ${err.message}`, + err.stack, + ); + } + throw new WsException(ERR_MSG.HANDLE_MESSAGE); + } + } + + async handleMessageEvent(client: Socket, message: string) { + try { + const sentMessage = await this.streamMessage(client, () => + this.chatbotService.generateTalk(client.chatLog, message), + ); + + client.chatLog.push({ isHost: false, message: message }); + client.chatLog.push({ isHost: true, message: sentMessage }); + + if ( + ASK_TAROTCARD_MESSAGE_CANDIDATES.some((string) => + sentMessage.includes(string), + ) + ) { + client.emit('tarotCard'); + } + + return sentMessage; + } catch (err) { + if (err instanceof Error) { + this.logger.error( + `🚀 Failed to handle message event : ${err.message}`, + err.stack, + ); + } + throw new WsException(ERR_MSG.HANDLE_MESSAGE); + } + } + + async handleTarotReadEvent(client: Socket, cardIdx: number) { + try { + const sentMessage = await this.streamMessage(client, () => + this.chatbotService.generateTarotReading(client.chatLog, cardIdx), + ); + + client.chatLog.push({ isHost: true, message: sentMessage }); + + client.chatEnd = true; + + const shareLinkId = await this.createShareLinkId(cardIdx, sentMessage); + client.emit('chatEnd', shareLinkId); + + const { roomId } = await this.createRoom(client); + await this.saveChatLogs(roomId, client.chatLog); + + return sentMessage; + } catch (err) { + if (err instanceof Error) { + this.logger.error( + `🚀 Failed to handle tarot read event : ${err.message}`, + err.stack, + ); + } + throw new WsException(ERR_MSG.HANDLE_MESSAGE); + } + } + + async streamMessage( + client: Socket, + generateStream: () => Promise>, + ) { + client.emit('streamStart'); + + const stream = await generateStream(); + const onStreaming = (token: string) => client.emit('streaming', token); + + const sentMessage = await readStream(stream, onStreaming); + + client.emit('streamEnd'); + + return sentMessage; + } + + private async createRoom(client: Socket) { + try { + const chattingInfo = await this.chatService.createRoom(client.id); + return chattingInfo; + } catch (err) { + if (err instanceof Error) { + this.logger.error( + `🚀 Failed to create room : ${err.message}`, + err.stack, + ); + } + throw new WsException(ERR_MSG.CREATE_ROOM); + } + } + + private async saveChatLogs(roomId: string, chatLogs: ChatLog[]) { + try { + const chattingMessages = chatLogs.map((chatLog) => + CreateChattingMessageDto.fromChatLog(roomId, chatLog), + ); + return await this.chatService.createMessage(roomId, chattingMessages); + } catch (err) { + if (err instanceof Error) { + this.logger.error( + `🚀 Failed to save chat log : ${err.message}`, + err.stack, + ); + } + throw new WsException(ERR_MSG.SAVE_CHATTING_LOG); + } + } + + private async createShareLinkId( + cardIdx: number, + result: string, + ): Promise { + try { + const tarotResult = CreateTarotResultDto.fromResult(cardIdx, result); + return await this.tarotService.createTarotResult(tarotResult); + } catch (err) { + if (err instanceof Error) { + this.logger.error( + `🚀 Failed to create share link ID : ${err.message}`, + err.stack, + ); + } + throw new WsException(ERR_MSG.SAVE_TAROT_RESULT); + } + } +} diff --git a/backend/was/src/events/ws-exception.filter.ts b/backend/was/src/socket/ws-exception.filter.ts similarity index 63% rename from backend/was/src/events/ws-exception.filter.ts rename to backend/was/src/socket/ws-exception.filter.ts index ff21994a..140c844d 100644 --- a/backend/was/src/events/ws-exception.filter.ts +++ b/backend/was/src/socket/ws-exception.filter.ts @@ -1,9 +1,17 @@ import { ArgumentsHost, Catch } from '@nestjs/common'; import { BaseWsExceptionFilter, WsException } from '@nestjs/websockets'; +import { SocketService } from './socket.service'; @Catch(WsException) export class WsExceptionFilter extends BaseWsExceptionFilter { + constructor(private readonly socketService: SocketService) { + super(); + } + catch(exception: WsException, host: ArgumentsHost) { + if (!(this.socketService instanceof SocketService)) { + return; + } const client = host.switchToWs().getClient(); client.emit('error', exception.message); }