From 580652c4dc0dacff968580707ed7faae6f45572a Mon Sep 17 00:00:00 2001 From: hyoribogo <97094709+hyoribogo@users.noreply.github.com> Date: Wed, 29 Nov 2023 15:33:22 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A0=95=EC=A0=9C=20api=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: sse.js 패키지 설치 * feat: gpt 정제 훅 구현 * chore: textarea-autosize 패키지 설치 * style: 저장 아이콘 추가 * refactor: 텍스트 prop 추가 * feat: 취합된 주관식 답변 컴포넌트 구현 * refactor: defaultValue 속성 제거 * refactor: 주관식 컴포넌트 연결 --- package-lock.json | 112 +++++++++-------- package.json | 4 +- src/assets/icons/index.ts | 1 + src/assets/icons/save.svg | 3 + src/hooks/index.ts | 1 + src/hooks/useRefine.ts | 116 ++++++++++++++++++ .../AnswerGroup/RenderRefinedSubjective.tsx | 55 +++++++++ .../components/AnswerGroup/index.tsx | 57 ++------- 8 files changed, 250 insertions(+), 99 deletions(-) create mode 100644 src/assets/icons/save.svg create mode 100644 src/hooks/useRefine.ts create mode 100644 src/pages/CreatedReviewManagePage/components/AnswerGroup/RenderRefinedSubjective.tsx diff --git a/package-lock.json b/package-lock.json index b8295945..b1e7643c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,15 +15,15 @@ "axios": "^1.5.1", "chart.js": "^4.4.0", "dayjs": "^1.11.10", - "framer-motion": "^10.16.4", "react": "^18.2.0", "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.11", "react-hook-form": "^7.47.0", "react-router-dom": "^6.17.0", + "react-textarea-autosize": "^8.5.3", "rippleui": "^1.12.1", - "zod": "^3.22.4" + "sse": "github:mpetazzoni/sse.js" }, "devDependencies": { "@commitlint/cli": "^18.0.0", @@ -5014,44 +5014,6 @@ "url": "https://github.com/sponsors/rawify" } }, - "node_modules/framer-motion": { - "version": "10.16.4", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.16.4.tgz", - "integrity": "sha512-p9V9nGomS3m6/CALXqv6nFGMuFOxbWsmaOrdmhyQimMIlLl3LC7h7l86wge/Js/8cRu5ktutS/zlzgR7eBOtFA==", - "dependencies": { - "tslib": "^2.4.0" - }, - "optionalDependencies": { - "@emotion/is-prop-valid": "^0.8.2" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, - "node_modules/framer-motion/node_modules/@emotion/is-prop-valid": { - "version": "0.8.8", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", - "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", - "optional": true, - "dependencies": { - "@emotion/memoize": "0.7.4" - } - }, - "node_modules/framer-motion/node_modules/@emotion/memoize": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", - "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", - "optional": true - }, "node_modules/fs-extra": { "version": "11.1.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", @@ -7723,6 +7685,22 @@ "react-dom": ">=16.8" } }, + "node_modules/react-textarea-autosize": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.3.tgz", + "integrity": "sha512-XT1024o2pqCuZSuBt9FwHlaDeNtVrtCXu0Rnz88t1jUGheCLa3PhjE1GH8Ctm2axEtvdCl5SUHYschyQ0L5QHQ==", + "dependencies": { + "@babel/runtime": "^7.20.13", + "use-composed-ref": "^1.3.0", + "use-latest": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -8351,6 +8329,12 @@ "readable-stream": "^3.0.0" } }, + "node_modules/sse": { + "name": "sse.js", + "version": "2.0.2", + "resolved": "git+ssh://git@github.com/mpetazzoni/sse.js.git#7723ab8a9ce33be83fa1fcf81a0f22de30850e84", + "license": "Apache-2.0" + }, "node_modules/strict-event-emitter": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.4.6.tgz", @@ -8816,7 +8800,8 @@ "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true }, "node_modules/type-check": { "version": "0.4.0", @@ -8997,6 +8982,43 @@ "punycode": "^2.1.0" } }, + "node_modules/use-composed-ref": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.3.0.tgz", + "integrity": "sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", + "integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.2.1.tgz", + "integrity": "sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw==", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -9305,14 +9327,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zod": { - "version": "3.22.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", - "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } } } } diff --git a/package.json b/package.json index 7eed55f1..fa91f609 100644 --- a/package.json +++ b/package.json @@ -17,15 +17,15 @@ "axios": "^1.5.1", "chart.js": "^4.4.0", "dayjs": "^1.11.10", - "framer-motion": "^10.16.4", "react": "^18.2.0", "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.11", "react-hook-form": "^7.47.0", "react-router-dom": "^6.17.0", + "react-textarea-autosize": "^8.5.3", "rippleui": "^1.12.1", - "zod": "^3.22.4" + "sse": "github:mpetazzoni/sse.js" }, "devDependencies": { "@commitlint/cli": "^18.0.0", diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts index 9573a4d0..f1221708 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -35,3 +35,4 @@ export { default as FilterReplyIcon } from './filter-reply.svg?react' export { default as ImageIcon } from './image.svg?react' export { default as SunIcon } from './sun.svg?react' export { default as MoonIcon } from './moon.svg?react' +export { default as SaveIcon } from './save.svg?react' diff --git a/src/assets/icons/save.svg b/src/assets/icons/save.svg new file mode 100644 index 00000000..dff3a6ec --- /dev/null +++ b/src/assets/icons/save.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/hooks/index.ts b/src/hooks/index.ts index f2356440..a56e7f48 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,3 +4,4 @@ export { default as usePasswordCheck } from './usePasswordCheck' export { default as useDarkMode } from './useDarkMode' export { default as useToast } from './useToast' export { default as useInfiniteScroll } from './useInfiniteScroll' +export { default as useRefine } from './useRefine' diff --git a/src/hooks/useRefine.ts b/src/hooks/useRefine.ts new file mode 100644 index 00000000..c369589a --- /dev/null +++ b/src/hooks/useRefine.ts @@ -0,0 +1,116 @@ +import { ChangeEvent, useEffect, useRef, useState } from 'react' +import { SSE } from 'sse' + +const useRefine = ({ text = '' }: { text: string }) => { + const [prompt, setPrompt] = useState(text) + const [isLoading, setIsLoading] = useState(false) + const [result, setResult] = useState('') + + const resultRef = useRef() + + useEffect(() => { + resultRef.current = result + }, [result]) + + const clearState = () => { + setPrompt('') + setResult('') + } + + const showAlertForEmptyPrompt = () => { + if (prompt === '') { + alert('빈 텍스트는 정제할 수 없습니다!') + + return true + } + + return false + } + + const refineConfig = { + model: 'gpt-3.5-turbo', + messages: [ + { content: prompt, role: 'user' }, + { + content: `이 글들은 동료에 대한 평가 글이야. "," 로 구분된 구문들에 대해 비판이 아닌 욕설, 비난, 부정적 감정 표현을 제거한 뒤 핵심만 간략하게 요약해서 최대한 짧은 하나의 구문을 만들어줘. 또한 조건이 있어. 모든 구문을 존댓말로 통일해야 해. 오로지 전달 받은 텍스트로만 정제해야 해. 너가 새로운 문구를 지어내면 안돼.`, + role: 'system', + }, + ], + temperature: 0.7, + max_tokens: 1300, + stream: true, + n: 1, + } + + const createSSEConfig = () => { + return { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Authorization: `Bearer ${import.meta.env.VITE_OPEN_API_KEY}`, + }, + method: 'POST', + payload: JSON.stringify(refineConfig), + } + } + + const handleClear = () => { + clearState() + } + + const handleRefine = async () => { + if (showAlertForEmptyPrompt() || isLoading) { + return + } + + if (result) { + setPrompt(result) + } + + setIsLoading(true) + setResult('') + + const url = 'https://api.openai.com/v1/chat/completions' + const source = new SSE(url, createSSEConfig()) + + source.addEventListener('message', (e: MessageEvent) => { + if (e.data === '[DONE]') { + source.close() + setIsLoading(false) + + return + } + + const payload = JSON.parse(e.data) + const text = payload.choices[0].delta.content + + if (text === '\n' || text === undefined) { + return + } + + resultRef.current += text + setResult(resultRef.current!) + }) + + source.addEventListener('readystatechange', () => { + if (source.readyState === 4) { + setIsLoading(false) + } + }) + + source.stream() + } + + const handleChangePrompt = (e: ChangeEvent) => { + setPrompt(e.target.value) + } + + const handlers = { + handleClear, + handleRefine, + handleChangePrompt, + } + + return { prompt, result, isLoading, handlers } +} + +export default useRefine diff --git a/src/pages/CreatedReviewManagePage/components/AnswerGroup/RenderRefinedSubjective.tsx b/src/pages/CreatedReviewManagePage/components/AnswerGroup/RenderRefinedSubjective.tsx new file mode 100644 index 00000000..2835cd38 --- /dev/null +++ b/src/pages/CreatedReviewManagePage/components/AnswerGroup/RenderRefinedSubjective.tsx @@ -0,0 +1,55 @@ +import ReactTextareaAutosize from 'react-textarea-autosize' +import { useRefine } from '@/hooks' +import { IconButton } from '@/components' +import { FilterReplyIcon, SaveIcon } from '@/assets/icons' + +interface RenderRefinedSubjectiveProps { + text: string +} + +const RenderRefinedSubjective = ({ text }: RenderRefinedSubjectiveProps) => { + const { handlers, isLoading, prompt, result } = useRefine({ text }) + const { handleChangePrompt, handleRefine } = handlers + + return ( +
+ {isLoading && ( +
+
+
+ )} + + + +
+ + + + + { + if (isLoading) { + return + } + //TODO: 저장 API 호출 + }} + > + + +
+
+ ) +} + +export default RenderRefinedSubjective diff --git a/src/pages/CreatedReviewManagePage/components/AnswerGroup/index.tsx b/src/pages/CreatedReviewManagePage/components/AnswerGroup/index.tsx index 5493b3a9..559aa55b 100644 --- a/src/pages/CreatedReviewManagePage/components/AnswerGroup/index.tsx +++ b/src/pages/CreatedReviewManagePage/components/AnswerGroup/index.tsx @@ -1,12 +1,9 @@ import { nanoid } from 'nanoid' -import { useState, useRef } from 'react' -import { IconButton, StarRatingList } from '@/components' -import { - CloseDropDownIcon, - BasicProfileIcon, - FilterReplyIcon, -} from '@/assets/icons' +import { useState } from 'react' +import { StarRatingList } from '@/components' +import { CloseDropDownIcon, BasicProfileIcon } from '@/assets/icons' import { QUESTION_TYPE } from '../../constants' +import RenderRefinedSubjective from './RenderRefinedSubjective' interface QuestionGroupProps { questionType: @@ -81,9 +78,7 @@ const renderResponseByQuestion = ( answers: Answer[], questionType: string, index?: number, - ref?: React.MutableRefObject, role?: 'responser' | 'receiver', - onClickCleanButton?: (answer: string) => void, ) => (
- - -
- - - - - { - onClickCleanButton && - onClickCleanButton(ref?.current?.value as string) - }} - > -
- + value).join('\n')} + /> )}
@@ -140,16 +111,14 @@ const QuestionAnswerRenderer = ({ questionType, questionTitle, role, - onClickCleanButton, }: QuestionGroupProps) => { const [inputId] = useState(nanoid()) - const textAreaRef = useRef(null) return (