Skip to content

Commit

Permalink
Feature: 커뮤니티 번역기능 추가 (#647)
Browse files Browse the repository at this point in the history
* feat: 댓글 대댓글 복사 가능

* feat: 중국어(간체) 추가 및 적용

* chore: openai 라이브러리 추가

* feat: community article 번역기능 추가

* feat: 프롬프트 수정, 댓글 대댓글 번역 기능 추가

* refactor: openai 라이브러리 제거, openai 로 직접 요청

* fix: build 오류 수정

* refactor: readme 위치 변경

* feat: readme 수정
  • Loading branch information
dev-dong-su authored Mar 3, 2024
1 parent 62360c6 commit 66fdcb2
Show file tree
Hide file tree
Showing 20 changed files with 716 additions and 27 deletions.
6 changes: 3 additions & 3 deletions packages/web/README.md → README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Gloddy는 국적에 상관없이 자유롭게 모임을 형성하고 원하는
</td>
<td align="center" width="200px">
<a href="https://github.com/kangju2000" target="_blank">
<img src="https://avatars.githubusercontent.com/u/23312485?v=4" alt="강주혁" />
<img src="https://avatars.githubusercontent.com/u/16986867?s=400&u=f7149ab3bdf5348647d4ff1ea96ac6747ec307c6&v=4" alt="강주혁" />
</a>
</td>
</tr>
Expand All @@ -88,8 +88,8 @@ Gloddy는 국적에 상관없이 자유롭게 모임을 형성하고 원하는
</a>
</td>
<td align="center">
<a href="https://github.com/kangju2000" target="_blank">
강주혁
<a href="https://github.com/dev-dong-su" target="_blank">
김희수
</a>
</td>
</tr>
Expand Down
21 changes: 21 additions & 0 deletions packages/web/src/apis/openApi/apis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import axios from 'axios';

import { Message } from '.';

export const postOpenAIAPI = async (messages: Message[]) => {
const response = await axios.post(
'https://api.openai.com/v1/chat/completions',
{
messages,
model: 'gpt-3.5-turbo',
},
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.NEXT_PUBLIC_OPEN_API}`,
},
}
);

return JSON.parse(response.data.choices[0].message.content);
};
3 changes: 3 additions & 0 deletions packages/web/src/apis/openApi/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './apis';
export * from './mutations';
export * from './type';
42 changes: 42 additions & 0 deletions packages/web/src/apis/openApi/mutations.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useMutation } from '@tanstack/react-query';

import { Message } from '.';
import { postOpenAIAPI } from './apis';

export const usePostTranslateGPT = () => {
const { mutateAsync, isPending } = useMutation({
mutationFn: postOpenAIAPI,
});

const postTranslate = async ({
title,
content,
targetLang,
}: {
title?: string;
content: string;
targetLang: string;
}) => {
const userContent = title
? `Translate the following text to ${targetLang} and format the output as requested: { title: "${title}", content: "${content}" }`
: `Translate the following text to ${targetLang} and format the output as requested: { content: "${content}" }`;

const messages: Message[] = [
{
role: 'system',
content:
'You are a translator assistant that provides JSON output. Please ensure the translation output is in the format of {title: "translated text", content: "translated text"} if title is provided, otherwise just {content: "translated text"}.',
},
{
role: 'user',
content: userContent,
},
];
return mutateAsync(messages);
};

return {
postTranslate,
isPending,
};
};
29 changes: 29 additions & 0 deletions packages/web/src/apis/openApi/type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export type RoleType = 'system' | 'user' | 'assistant' | 'tool' | 'function';

export interface OpenAIResponse {
id: string;
object: string;
created: number;
model: string;
choices: Choice[];
usage: Usage;
system_fingerprint: string;
}

export interface Choice {
index: number;
message: Message;
logprobs: null | any;
finish_reason: string;
}

export interface Message {
role: RoleType;
content: string;
}

export interface Usage {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { format, parseISO } from 'date-fns';
import Image from 'next/image';
import { useState } from 'react';

import { CommunityArticle, usePostCommunityArticleLike } from '@/apis/community';
import { usePostTranslateGPT } from '@/apis/openApi';
import { useTranslation } from '@/app/i18n/client';
import { cookieName } from '@/app/i18n/settings';
import { CardHeader } from '@/components/Card';
import { Icon } from '@/components/Icon';
import { Flex } from '@/components/Layout';
import { Loading } from '@/components/Loading';
import { ImageModal } from '@/components/Modal';
import { Spacing } from '@/components/Spacing';
import { useModal } from '@/hooks/useModal';
import cn from '@/utils/cn';
import { getLocalCookie } from '@/utils/cookieController';

interface ArticleItemProps {
article: CommunityArticle;
Expand Down Expand Up @@ -45,28 +50,60 @@ export default function ArticleItem({ article }: ArticleItemProps) {
} = article.writer;

const { mutate: mutateLike } = usePostCommunityArticleLike(articleId);
const [articleState, setArticleState] = useState({ title, content });

const { postTranslate, isPending } = usePostTranslateGPT();
const cookieLanguage = getLocalCookie(cookieName);

const handleLikeClick = () => {
mutateLike();
};

const handleTranslateClick = async () => {
if (!cookieLanguage) return;

const translatedText = await postTranslate({
title: articleState.title,
content: articleState.content,
targetLang: cookieLanguage,
});
setArticleState({ title: translatedText.title, content: translatedText.content });
};

return (
<div className="mx-20 mb-24 mt-16 px-4">
<CardHeader
showMoreIcon={false}
name={nickName}
userId={writerId}
userImageUrl={profileImage}
isWriterCertifiedStudent={isCertifiedStudent}
writerReliabilityLevel={reliabilityLevel}
isWriterCaptain={true}
date={format(parseISO(createdAt), 'yyyy.MM.dd HH:mm')}
countryImage={countryImage}
/>
<div className="flex items-center justify-between">
<CardHeader
showMoreIcon={false}
name={nickName}
userId={writerId}
userImageUrl={profileImage}
isWriterCertifiedStudent={isCertifiedStudent}
writerReliabilityLevel={reliabilityLevel}
isWriterCaptain={true}
date={format(parseISO(createdAt), 'yyyy.MM.dd HH:mm')}
countryImage={countryImage}
/>
<div onClick={handleTranslateClick} className="text-paragraph-2 text-sign-secondary">
{t('detail.translate')}
</div>
</div>
<Spacing size={16} />
<div className={'break-words text-2xl font-semibold'}>{title}</div>
<Spacing size={6} />
<div className="text-paragraph-1 text-sign-primary select-auto break-words">{content}</div>
{isPending ? (
<>
<Spacing size={16} />
<Loading />
<Spacing size={16} />
</>
) : (
<>
<div className={'break-words text-2xl font-semibold'}>{articleState.title}</div>
<Spacing size={6} />
<div className="text-paragraph-1 text-sign-primary select-auto break-words">
{articleState.content}
</div>
</>
)}
{images.length > 0 && (
<Flex className="h-160 my-16 gap-4 overflow-x-scroll">
{images.map((imageUrl, index) => (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { format, parseISO } from 'date-fns';
import { useState } from 'react';

import { useCommentContext } from './CommentProvider';
import CommunityModal from './CommunityModal';
Expand All @@ -11,17 +12,21 @@ import {
useGetCommunityReply,
usePostCommunityCommentLike,
} from '@/apis/community';
import { usePostTranslateGPT } from '@/apis/openApi';
import { useTranslation } from '@/app/i18n/client';
import { cookieName } from '@/app/i18n/settings';
import { IconButton } from '@/components/Button';
import { CardHeader } from '@/components/Card';
import { DropDown } from '@/components/DropDown';
import { DropDownOptionType } from '@/components/DropDown/DropDown';
import { Icon } from '@/components/Icon';
import { Flex } from '@/components/Layout';
import { Loading } from '@/components/Loading';
import { Spacing } from '@/components/Spacing';
import { useModal } from '@/hooks/useModal';
import { useBlockStore } from '@/store/useBlockStore';
import cn from '@/utils/cn';
import { getLocalCookie } from '@/utils/cookieController';

interface CommentItemProps {
comment: Comment;
Expand Down Expand Up @@ -63,6 +68,10 @@ export default function CommentItem({
const { data: replyDataList } = useGetCommunityReply(articleId, commentId);
const { setCommentType, setCommentId } = useCommentContext();

const [commentState, setCommentState] = useState(content);
const { postTranslate, isPending } = usePostTranslateGPT();
const cookieLanguage = getLocalCookie(cookieName);

const handleBlockComment = () => {
openModal(() => (
<CommunityModal
Expand Down Expand Up @@ -101,6 +110,16 @@ export default function CommentItem({
setCommentId(commentId);
};

const handleTranslateClick = async () => {
if (!cookieLanguage) return;

const translatedText = await postTranslate({
content: commentState,
targetLang: cookieLanguage,
});
setCommentState(translatedText.content);
};

return (
<>
<Flex direction="column" className="m-20 mb-20 px-4">
Expand All @@ -114,14 +133,27 @@ export default function CommentItem({
isWriterCaptain={articleWriterId === userId}
countryImage={countryImage}
>
<div onClick={handleTranslateClick} className="text-paragraph-2 text-sign-secondary">
{t('detail.translate')}
</div>
<DropDown options={options}>
<IconButton size="large">
<Icon id="24-more_secondary" />
</IconButton>
</DropDown>
</CardHeader>
<Spacing size={10} />
<div className="text-paragraph-2 text-sign-primary break-words">{content}</div>
{isPending ? (
<>
<Spacing size={16} />
<Loading />
<Spacing size={16} />
</>
) : (
<div className="text-paragraph-2 text-sign-primary select-auto break-words">
{commentState}
</div>
)}
<Flex align="center" className="gap-8">
<Flex align="center" className="gap-4" onClick={handleLikeClick}>
<Icon
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import { format, parseISO } from 'date-fns';
import { useState } from 'react';

import CommunityModal from './CommunityModal';

import { Reply } from '@/apis/community';
import { usePostTranslateGPT } from '@/apis/openApi';
import { useTranslation } from '@/app/i18n/client';
import { cookieName } from '@/app/i18n/settings';
import { IconButton } from '@/components/Button';
import { CardHeader } from '@/components/Card';
import { DropDown } from '@/components/DropDown';
import { DropDownOptionType } from '@/components/DropDown/DropDown';
import { Icon } from '@/components/Icon';
import { Flex } from '@/components/Layout';
import { Loading } from '@/components/Loading';
import { Spacing } from '@/components/Spacing';
import { useModal } from '@/hooks/useModal';
import { useBlockStore } from '@/store/useBlockStore';
import cn from '@/utils/cn';
import { getLocalCookie } from '@/utils/cookieController';

interface ReplyItemProps {
reply: Reply;
Expand Down Expand Up @@ -46,6 +51,10 @@ export default function ReplyItem({ reply, articleWriterId }: ReplyItemProps) {
countryImage,
} = reply.writer;

const [commentState, setCommentState] = useState(content);
const { postTranslate, isPending } = usePostTranslateGPT();
const cookieLanguage = getLocalCookie(cookieName);

const handleBlockReply = () => {
openModal(() => (
<CommunityModal
Expand All @@ -67,6 +76,16 @@ export default function ReplyItem({ reply, articleWriterId }: ReplyItemProps) {
},
];

const handleTranslateClick = async () => {
if (!cookieLanguage) return;

const translatedText = await postTranslate({
content: commentState,
targetLang: cookieLanguage,
});
setCommentState(translatedText.content);
};

return (
<div className={'bg-sub w-full'}>
<Flex direction={'row'} className=" m-20 px-4" gap={10}>
Expand All @@ -82,14 +101,27 @@ export default function ReplyItem({ reply, articleWriterId }: ReplyItemProps) {
isWriterCaptain={articleWriterId === userId}
countryImage={countryImage}
>
<div onClick={handleTranslateClick} className="text-paragraph-2 text-sign-secondary">
{t('detail.translate')}
</div>
<DropDown options={options}>
<IconButton size="large">
<Icon id="24-more_secondary" />
</IconButton>
</DropDown>
</CardHeader>
<Spacing size={10} />
<div className="text-paragraph-2 text-sign-primary break-all">{content}</div>
{isPending ? (
<>
<Spacing size={16} />
<Loading />
<Spacing size={16} />
</>
) : (
<div className="text-paragraph-2 text-sign-primary select-auto break-words">
{commentState}
</div>
)}
<Flex align="center" className="gap-4">
<Icon
id="16-favorite_fill"
Expand Down
Loading

0 comments on commit 66fdcb2

Please sign in to comment.