이번포스팅에는 대화를 저장하여 이전내용을 기억하도록 해보자
mysql DB를 활용할 수 있지만 이번 포스팅에는 node.js의 fs로 json파일을 생성하고 대화내용을 저장해보도록 하자
https://nodejs.org/api/fs.html
File system | Node.js v19.8.1 Documentation
nodejs.org
우선 파일시스템에서 파일생성 및 읽고 쓰기를 만들어보자
fs는 node.js에 기본으로 내장되어 있으므로 별도의 패키지를 설치할 필요는 없다
server/lib 디렉토리에 다음과같이 파일을 추가해주자
그리고 json파일을 저장하기위해 root에 data 디렉토리를 생성하고 slack과 browser 디렉토리도 생성한다
우선 interface를 추가해준다
// types/openai.ts
interface ChatList {
role: 'user'|'assistant'|'system',
content: string
}
// 추가해준다
interface ChatType {
type: 'slack'|'browser'
id: string
remove?: boolean
}
export {
ChatList,
ChatType, // 추가해준다
}
채팅은 slack 또는 브라우저에서 하기때문에 ChatType의 type을 정의하고 메시지를 발송할 id도 정의한다
remove는 채팅전송시 이전 기록을 모두 삭제하고 다시 질문할지 판별하는 값이다
checkFile.ts
파일을 체크하고 기본데이터를 세팅하는 함수를 만들자
// server/lib/checkFile.ts
import * as fs from 'node:fs/promises'
import type { ChatType } from '@/types/openai'
export async function checkFile (target:ChatType) {
const path = `data/${target.type}/${target.id}.json` // data 하위 slack과 browser로 구분하여 파일을 생성한다
if (target.remove) {
// 삭제명령인경우 덮어쓰기용으로 열어준다
const file = await fs.open(path, 'w+')
await file.writeFile('[]') // string으로 배열을 덮어쓰고
const read = await fs.readFile(path, { encoding: 'utf8' }) // 파일 읽는다
// 파일은 반드시 닫아준다
await file.close()
return Promise.resolve(read)
} else {
// 아닌경우 추가용으로 열어준다
const file = await fs.open(path, 'a+')
let read = await file.readFile({ encoding: 'utf8' }) // 파일 읽는다
if (read.length < 1) {
// 데이터가 없는경우 배열을 넣어준다
await file.appendFile('[]')
// path로 파일을 다시 읽음
read = await fs.readFile(path, { encoding: 'utf8' })
// 파일은 반드시 닫아준다
await file.close()
return Promise.resolve(read)
} else {
// 데이터가 있다면 그냥 결과값만 반환한다
const data = JSON.parse(read)
if (data.length > 0 && data.at(-1).role == 'user') {
// 배열의 데이터가 마지막 데이터가 유저라면 질문 처리중이므로 에러를 출력한다
return Promise.reject(new Error('proceeding'))
}
// 파일은 반드시 닫아준다
await file.close()
return Promise.resolve(read)
}
}
}
fs를 promise로 사용할 것이기 때문에 node:fs/promises 의존성을 주입하고 interface도 불러온다
path는 data/${슬랙/브라우저}/${아이디}.json 형태로 만들도록 작성해주고 target.remove를 체크한뒤 삭제명령일때 file을 flag w+로 아닌경우 a+ flag로 열어준다
https://nodejs.org/api/fs.html#file-system-flags
File system | Node.js v19.8.1 Documentation
nodejs.org
빈 파일이때는 아무것도 없는 json파일을 생성하기에 이후 JSON.parse에서 에러를 방지하기 위해 삭제일때는 writeFile함수에 string으로 '[]' 배열을 넣어준뒤 file을 닫아주고, 추가일때는 데이터가 없을경우 appendFile함수로 string으로 '[]' 배열을 넣어준뒤 파일을 변경하였으니 fs.readFile함수에 path를 전달하여 다시읽어준다
그리고 추가일때 데이터가 있다면 JSON.parse로 데이터를 검증해야되는데
배열을 던지는 형태로 통신하기 때문에 위와같이 답변을 받기전에 질문하는 것을 제한하지 않으면 답변이 섞이거나 이상하게 올 수 있다
배열의 값이 있을때 마지막 role이 user라면 아직 답변을 받지 못한 것 이기때문에 에러로 처리하고 그 외에는 정상으로 처리한다
그리고 중요한점이 있는데 node.js에서는 fs의 open으로 파일을 열었다면 가비지컬렉션에서 open의 FileHandle 객체를 닫을 수 없기 때문에 명시적으로 close 함수를 써서 닫지 않으면 오류가 발생하니 항상 열었다면 닫아줘야한다(메모리 누수가 발생)
writeData.ts
파일에 데이터 쓰기만을 담당하는 함수를 만들자
// server/lib/writeData.ts
import * as fs from 'node:fs/promises'
import type { ChatList, ChatType } from '@/types/openai'
export async function writeData (message: ChatList, target:ChatType) {
const path = `data/${target.type}/${target.id}.json` // data 하위 slack과 browser로 구분하여 파일을 생성한다
const read = await fs.readFile(path, { encoding: 'utf8' }) // 파일 내용을 읽는다
const file = await fs.open(path, 'w') // 파일은 덮어쓰한다
const data = JSON.parse(read) // JSON으로 변환
data.push(message) // 전달받은 내용을 push함
await fs.writeFile(file, JSON.stringify(data)) // string으로 저장해준다
await file.close() // 파일을 닫아준다
return Promise.resolve('done')
}
node:fs/promises 의존성을 주입하고 ChatList, ChatType interface를 불러온다
writeData 함수의 parameter의 타입을 불러온 interface로 각각 정의해주고 path를 생성해준다
배열에 값을 넣는 형태로 만들어야하기 때문에 fs.readFile함수로 파일을 먼저 읽은뒤 fs.open함수를 활용하고 읽기는 할 필요가 없기에 flag는 w로 덮어쓰기로 불러온다
read된 string을 JSON.parse함수를 써서 json형태로 만들어주고 전달받은 message는 data에 push한다
그리고 fs.writeFile함수로 파일에 덮어쓰기하고 파일을 닫아준뒤 resolve를 반환한다
openai_completion.ts
이제 기존에 openai api와 통신하는 부분에서 저장 및 체크를 넣어준다
// server/lib/openai_completion.ts
import { Configuration, OpenAIApi } from 'openai'
import { sendMessage } from '@/server/lib/slack_message'
import { checkFile } from '@/server/lib/checkFile'
import { writeData } from '@/server/lib/writeData'
import type { ChatList, ChatType } from '@/types/openai'
export async function chat(message: ChatList, target: ChatType) {
const configuration = new Configuration({ // configuration 인스턴트 생성
apiKey: process.env.OPENAI_SECRET_KEY, // env의 key를 넣어준다
})
const openai = new OpenAIApi(configuration) // 인스턴스 생성
/* 추가된코드 start */
const messages = await checkFile(target).catch((err) => {
if (target.type == 'slack') {
// 슬랙은 에러메시지를 전송한다
if (err.message == 'proceeding') {
// 이미 실행중이라 에러난경우
sendMessage(target.id, `이미 질문이 진행중입니다! \n 잠시후 다시 질문해주세요! \n 실패한 질문: ${message.content}`)
} else {
sendMessage(target.id, `오류가 발생했습니다. 잠시후 다시 실행해주세요.\n 실패한 질문: ${message.content}`)
// sendMessage(관리자 id, `오류가 발생했습니다.\n ${err}`) // 별도의 공지처리를 하거나 오류메시지를 반환하고 빨리고치자
}
}
return Promise.reject(new Error(message.content, {cause: err.message})) // 브라우저 에러처리
}) // 파일체크를 한다
const sendData = JSON.parse(messages)// JSON으로 변환해준다
sendData.slice(-6) // 질문 답변은 최대 6개까지만 유지하도록 만든다
sendData.push(message) // 전달된 메시지를 push해준다
// 문의를 우선 저장한다
await writeData(message, target)
/* 추가된코드 end */
const result = await openai.createChatCompletion({ // 비동기로 결과를 받아온다
model: 'gpt-3.5-turbo', // 사용할 모델
messages: sendData, // 사용될 메시지
})
if (result && result.data.choices[0].message) {
// 답변을 저장한다
await writeData(result.data.choices[0].message, target) // 추가된코드
if (target.type == 'slack') {
// 슬랙인경우 메시지를 발송한다
sendMessage(target.id, result.data.choices[0].message.content)
} else {
return Promise.resolve(result.data.choices[0].message)
}
}
}
paramter의 target은 string이었지만 구분이 필요해졌기 떄문에 선언해둔 interface의 ChatType으로 바꿔준다
슬랙인경우 에러가 proceeding이면 slack으로 에러메시지를 발송하고 그 외의 에러는 관리자에게 슬랙을 보낸뒤 별도로 처리하는 형태로 에러처리를 하였다
그외는 브라우저에서 에러처리를 하기위해 promise reject에 기존 메시지와 cause를 반환한다
이후 불러온 string 데이터를 JSON.parse()함수를 이용해 JSON으로 변환해주고 토큰의 한계를 고려하여 기존 질문 답변을 최대 6개까지만 서버에보내도록 만들어준뒤 message에 전달받은 user의 문의를 push해준다
그런 다음 writeData함수로 답변을 저장해주고 openai api에서 결과값을 반환받은 것도 json에 저장해준다
브라우저에서 이전 대화리스트를 불러오는 api를 추가하자
우선 server/api 디렉토리에 파일을 추가한다
// server/api/list.get.ts
import * as fs from 'node:fs/promises'
import type { ChatList } from '@/types/openai'
export default defineEventHandler(async (event) => {
const id = getCookie(event, 'browser_id') // 쿠키값으로 아이디를 생성
const result = {
message: '데이터가 없다',
data: [] as Array<ChatList>,
}
if (id) {
// 쿠키가 있는경우만 실행한다
const path = `data/browser/${id}.json`
const file = await fs.open(path, 'r')
.catch(() => {
result.message = '파일이 없다'
})
if (file) { // 파일이 존재하는경우만 실행한다
const read = await file.readFile({ encoding: 'utf8' }) // 파일을 읽어온뒤
const data = JSON.parse(read) as Array<ChatList> // 결과값 json으로 변환
result.message = '데이터가 있다'
result.data = data
file.close()
}
}
// 아무것도 해당하지 않는다면 빈 배열을 반환한다
return result
})
쿠키값으로 아이디를 생성하고 return될 object를 선언해준다
그리고 쿠키값이 있다면 해당 id로 path를 만들어 파일을 읽어주는데 오류가 발생할경우 파일이 없다는 message를 반환한다
파일이 있다면 해당 파일을 읽은뒤 데이터를 JSON.parse로 json화 시킨뒤 result에 할당시키고 값을 반환한다
remove.ts
브라우저에서 쿠키값과 파일을 삭제하는 api를 만들자
// server/api/remove.ts
import * as fs from 'node:fs/promises'
export default defineEventHandler(async (event) => {
const id = getCookie(event, 'browser_id') // 쿠키값으로 아이디를 생성
const result = {
message: '삭제완료'
}
if (id) {
// 쿠키가 있는경우만 실행한다
setCookie(event, 'browser_id', id, { // 만료날짜를 설정하여 쿠키삭제
maxAge: 0,
})
// 파일삭제
const path = `data/browser/${id}.json`
await fs.rm(path)
}
// 아무것도 없다면 정상
return result
})
getCookie로 쿠키를 가져온뒤 setCookie의 maxAge값을 0으로 바꿔서 쿠키를 삭제해준다
이후 path를 선언하고 fs.rm함수를 이용하여 최종적으로 파일까지 삭제해준다
전달받는 paramter가 변경되었기 때문에 기존코드도 일부 수정해줘야한다
// server/api/slack/event.post.ts
import { chat } from '@/server/lib/openai_completion'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const quest = body.event.text as string // 질문
if (!body.event.bot_profile) {
// 봇 메시지가 아닌경우만 답변함
chat({role: 'user', content: quest}, {type: 'slack', id: body.event.channel}) // 수정됨
}
return {
challenge: body.challenge // hook 검증용으로 반환해야된다
}
})
parameter를 object로 변경하여 type과 id를 추가해준다
// server/api/slack/slash.post.ts
import { chat } from '@/server/lib/openai_completion'
import type { ChatType } from '@/types/openai'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
let channel = ''
if (body.channel_name == 'directmessage') {
// DM은 userid
channel = body.user_id
} else {
// 채널은 채널id
channel = body.channel_id
}
/* 추가됨 start */
const target = {
type: 'slack',
id: channel
} as ChatType
if (body.command == '/clear') {
// 초기화가 추가된경우
target.remove = true
}
/* 추가됨 end */
chat({role: 'user', content: body.text}, target) // 수정됨
return '질문을 받았습니다!'
})
slash의 경우 paramter에 맞게 object도 전달해주는데 이때 대화의 초기화를 구현하기위해 clear command를 추가하고 remove값을 전달한다
// server/api/browser.post.ts
import { chat } from '@/server/lib/openai_completion'
export default defineEventHandler(async (event) => {
// GPT에게 물어보기
const body = await readBody(event)
let id = getCookie(event, 'browser_id') // 쿠키값으로 아이디를 생성
if (!id) {
// 없으면 만들어준다 현재시간 + 16진수
id = new Date().getTime().toString(16)
setCookie(event, 'browser_id', id)
}
if (body.prompt) {
// 질문던지기
const quest = await chat({role: 'user', content: body.prompt}, {type: 'browser', id: id})
.catch((err) => {
// 에러시 반환
throw createError({ statusCode: 500, statusMessage: err.cause, message: err.message })
})
if (quest) {
// 답변이 정상적으로 왔다면 리턴
return {
result: quest,
}
}
} else {
// 질문이 없는경우
throw createError({
statusCode: 400,
statusMessage: '질문이 입력되지 않았습니다.',
})
}
})
browser의 경우 변경점이 꽤 많은데 임시 저장하던 conversation을 삭제하고 Nuxt에 내장된 getCookie로 쿠키값을 만들어준다 CSR인 경우에 로컬스토리지를 만들거나 body에 값을 전달하겠지만 SSR에서는 window나 document같은 브라우저 전역객체를 사용할 수 없기 때문에 쿠키에 저장한다
이후 chat 함수에 message object를 전달하고 type과 id값도 object로 전달한다
기존에 임의로 에러메시지는 전송한 메시지 cause는 에러사유로 전달하였기 때문에 그에 맞춰서 브라우저에 statusMessage와 message를 반환한다
index.vue
대화를 삭제하는 것과 리스트를 가져오는 부분을 수정해준다
우선 component/svg에 trash.vue 파일을 추가해주고
// components/svg/trash.vue
<template>
<svg viewBox="0 0 16 16">
<path d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5ZM11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H2.506a.58.58 0 0 0-.01 0H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1h-.995a.59.59 0 0 0-.01 0H11Zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5h9.916Zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47ZM8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5Z" />
</svg>
</template>
아이콘으로 쓸 컴포넌트를 추가해준다
// pages/index.vue
<script setup lang="ts">
import Trash from '@/components/svg/trash.vue'
// ...생략
/* 추가된부분 */
async function clearChat () {
if (confirm('대화를 초기화하시겠습니까?')) {
// 대화삭제한다
await $fetch('/api/remove')
conversation.value = []
}
}
onMounted(async () => {
// 마운트시 리스트가 있다면 가져오기
const result = await $fetch('/api/list')
if (result) {
conversation.value = result.data
}
})
/* 추가된부분 */
</script>
<template>
<!-- 생략 -->
<div class="flex w-full mt-auto justify-center">
<div class="w-full max-w-screen-lg mb-3 resize-none relative">
<textarea
v-model="prompt"
class="w-full p-3 resize-none border border-gray-200 rounded shadow focus:shadow-xl outline-none overflow-hidden"
:style="{height: height}"
placeholder="무엇이든 질문하세요!"
@keydown.enter="enterSubmit($event)"
>
</textarea>
<button
class="px-3 py-2 hover:bg-gray-100 rounded absolute top-1/2 transform -translate-y-1/2 right-12"
type="button"
@click="quest"
>
<Letter class="w-4 h-4 fill-gray-500" />
</button>
<!-- 추가된부분 -->
<button
class="px-3 py-2 hover:bg-red-100 rounded absolute top-1/2 transform -translate-y-1/2 right-3"
type="button"
@click="clearChat()"
>
<Trash class="w-4 h-4 fill-red-500" />
</button>
<!-- 추가된부분 -->
</div>
</div>
</div>
</template>
앞서 만든 Trash컴포넌트를 import하여 아이콘을 사용할 수 있게하고, 삭제 api인 api/remove를 호출하여 쿠키와 파일을 삭제하고 문의답변 리스트를 빈 배열로 다시 만들어준다
onMounted는 api/list api를 호출하여 마운트시 리스트를 가져오도록 설정하고, quest 함수를 호출하는 버튼 이후에 clearChat 함수를 호출하는 버튼을 새로 생성한다
모두 올바르게 동작되는걸 확인할 수 있다
파일 추가까지 마무리하여 목표하였던 Nuxt와 openai api를 연동, slack으로 메시지를 발송하고 json으로 저장하여 대화를 유지하는 프로젝트를 완성하였다🎉
최대한 간단하게 만들기위해 인증 서비스도 없고, 검증도 하지않고 오직 기능개발에만 치우친 포스팅이라 실제 서비스에서는 브라우저에는 로그인 또는 접근제한을 두고 서버에서는 슬랙메시지에 대한 채널 검증이 필요하다. 추가적으로 test 코드도 작성하여 완성도와 안정성을 높일 수도 있다!
어쨌든 요즘 한창 이슈인 GPT를 연동하고 사용하고 학습할 수 있는 좋은 계기였으니 이 포스팅을 참고하는 모든분들도 좋은 기회가 되었으면 좋겠다
Vue3, unit test, e2e, chromatic github ci구성(1) - 프로젝트 구성 (0) | 2023.10.18 |
---|---|
Nuxt 직접로그인, 소셜로그인 구현하기 (Nuxt-auth) (1) | 2023.06.15 |
Nuxt.js로 openai api 연동하여 slack GPT채팅 만들기(3) - nuxt, slack, gpt연동 (0) | 2023.03.26 |
Nuxt.js로 openai api 연동하여 slack GPT채팅 만들기(2) - gpt 연동 (0) | 2023.03.26 |
Nuxt.js로 openai api 연동하여 slack GPT채팅 만들기(1) - nuxt 서버설정 (0) | 2023.03.26 |
댓글 영역