Skip to content

Commit

Permalink
Merge pull request #2 from MARU-EGG/feature-#3
Browse files Browse the repository at this point in the history
[FEAT-3] Funnel 패턴 도입, 각 단계에 해당하는 컴포넌트 개발, 챗봇 질문응답 api 연동
  • Loading branch information
swgvenghy authored Jan 22, 2025
2 parents 94a7c97 + 51d22f1 commit 7274e03
Show file tree
Hide file tree
Showing 43 changed files with 1,477 additions and 39 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.env
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"cz-customizable": "^7.4.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.3",
"react-router-dom": "^7.1.1",
"tailwind-merge": "^2.6.0",
"vite-plugin-svgr": "^4.3.0",
Expand Down
659 changes: 659 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

10 changes: 7 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { Route, Routes } from 'react-router-dom';
import MaruEgg from '@/page/maru-egg';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

function App() {
const queryClient = new QueryClient();
return (
<Routes>
<Route path="/" element={<MaruEgg />}></Route>
</Routes>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/" element={<MaruEgg />}></Route>
</Routes>
</QueryClientProvider>
);
}

Expand Down
21 changes: 21 additions & 0 deletions src/api/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { server_axiosInstance } from '@/api';
import { AdmissionType, ResponseDetailAdmissionType } from '@/types/admission-type';
import { DefaultPostQuestionParams, PostQuestionResponse } from '@/types/questions';

export const getAdmissionDetail = async (type: AdmissionType): Promise<ResponseDetailAdmissionType[]> => {
const response = await server_axiosInstance.get(`/api/admissions/details/${type}`);
return response.data;
};

export const postQuestion = async ({
category,
type,
content,
}: DefaultPostQuestionParams): Promise<PostQuestionResponse> => {
const response = await server_axiosInstance.post('/api/questions', JSON.stringify({ category, type, content }), {
headers: {
'Content-Type': 'application/json',
},
});
return response.data;
};
13 changes: 13 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import axios from 'axios';

export const server_axiosInstance = axios.create({
baseURL: import.meta.env.VITE_SPRING_SERVER_API_ADDRESS,
timeout: 200000,
withCredentials: true,
});

export const llm_axiosInstance = axios.create({
baseURL: import.meta.env.VITE_LLM_SERVER_API_ADDRESS,
timeout: 200000,
withCredentials: true,
});
2 changes: 1 addition & 1 deletion src/components/flyout/flyout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ function Flyout({ children }: FlyoutProps) {
return (
<FlyoutContext.Provider value={{ isOpen, setIsOpen }}>
<div className="relative w-56">
<div className="relative">{children}</div>
<div className="relative text-sm">{children}</div>
</div>
</FlyoutContext.Provider>
);
Expand Down
2 changes: 1 addition & 1 deletion src/components/flyout/items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ function Items({ children }: { children: ReactNode }) {
const { isOpen, setIsOpen } = useFlyoutContext();
return isOpen ? (
<div onMouseOver={() => setIsOpen(true)} onMouseLeave={() => setIsOpen(false)} className="absolute left-56 top-0">
<div className="ml-3 divide-y rounded-lg border">{children}</div>
<div className="ml-3 divide-y rounded-lg border bg-white">{children}</div>
</div>
) : null;
}
Expand Down
8 changes: 7 additions & 1 deletion src/components/flyout/trigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ function Trigger({ children, className }: TirggerProps) {
const { isOpen, setIsOpen } = useFlyoutContext();
return (
<div onMouseLeave={() => setIsOpen(false)} onMouseOver={() => setIsOpen(true)}>
<div className={cn('p-2', isOpen ? 'bg-primary text-white' : 'hover:bg-primary hover:text-white', className)}>
<div
className={cn(
'bg-white p-2',
isOpen ? 'bg-primary text-white' : 'hover:bg-primary hover:text-white',
className,
)}
>
{children}
<div
className={cn('bg-image-tree-right absolute inset-0', isOpen ? 'opacity-100' : 'opacity-0 hover:opacity-100')}
Expand Down
9 changes: 0 additions & 9 deletions src/components/main/main.tsx

This file was deleted.

2 changes: 1 addition & 1 deletion src/components/preset-button/preset-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ interface PresetButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement

function PresetButton({ children, ...props }: PresetButtonProps) {
const buttonClasses = cn(
'cursor-pointer rounded-lg border border-category_border bg-white px-4 py-3 text-black transition-colors',
'cursor-pointer rounded-lg border border-category_border bg-white px-4 py-3 text-black transition-colors text-sm',
'hover:border-primary hover:text-primary',
'focus:border-primary focus:text-primary',
'disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-400',
Expand Down
2 changes: 1 addition & 1 deletion src/components/selector/select-header.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';

function SelectorHeader({ children }: { children: React.ReactNode }) {
return <div className="bg-primary bg-image-tree-right text-title p-4 text-white">{children}</div>;
return <div className="bg-image-tree-right rounded-t-lg bg-primary p-4 text-title text-white">{children}</div>;
}

export default SelectorHeader;
4 changes: 1 addition & 3 deletions src/components/selector/selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import SelectorHeader from '@/components/selector/select-header';
import SelectOption from '@/components/selector/select-option';

function Selector({ children }: { children: React.ReactNode }) {
return (
<div className="border-category_border w-56 divide-y overflow-hidden rounded-lg border bg-white">{children}</div>
);
return <div className="w-56 min-w-56 divide-y rounded-lg border border-category_border bg-white">{children}</div>;
}

Selector.Header = SelectorHeader;
Expand Down
Empty file removed src/constants/.gitkeep
Empty file.
14 changes: 14 additions & 0 deletions src/constants/message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const systemMessage = {
introduction:
'안녕하세요,\n명지대학교 입시 정보를 똑똑하게\n찾아주는 마루에그입니다!\n\n명지대학교 입시에 대해 궁금하신가요?\n아래 전형 중 하나를 선택해주세요!',
admissionGuide: (type: string) =>
`${type} 전형이 궁금하시군요!\n\n어떤 세부 전형이 궁금하신가요?\n아래에서 세부 전형을 선택해주세요!`,
additionalInfo:
'아래 버튼을 눌러 더 자세한 정보를 확인\n하거나 직접 질문해보세요!\n\n더욱 자세한 상담을 원하시면\n 명지대학교 입학처 02-300-1799,1800으로 전화주시길 바랍니다.',
referenceGuide: '💡답변 출처를 알려드릴게요!\n출처를 클릭하면 모집요강으로\n확인할 수 있어요!',
errorMessage: '서버가 답변을 불러오는데 실패했어요.',
campusGuide:
'학과별로 보고싶으신가요?\n명지대학교는 총 2개의 캠퍼스,12개의\n단과대와 63개의 학과(부)로 구성되어\n있습니다.\n아래 질문들을 통해서 궁금하신 학과(부)를\n찾아주세요!',
} as const;

export default systemMessage;
36 changes: 36 additions & 0 deletions src/constants/preset-buttons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export interface AdmissionPresetType {
label: string;
category: 'ADMISSION_GUIDELINE' | 'PASSING_RESULT';
question: string | ((type: string) => string);
}

const PRESET_BUTTON: AdmissionPresetType[] = [
{
label: '전형일정',
category: 'ADMISSION_GUIDELINE',
question: '전형일정',
},

{
label: '제출 서류',
category: 'ADMISSION_GUIDELINE',
question: '제출 서류 유의사항',
},
{
label: '입시 결과',
category: 'PASSING_RESULT',
question: (type: string) => `${type}의 모든 학과에 대한 입시결과 알려줘`,
},
{
label: '면접 유의사항',
category: 'ADMISSION_GUIDELINE',
question: '블라인드 면접 유의사항',
},
{
label: '실기고사',
category: 'ADMISSION_GUIDELINE',
question: '실기고사',
},
] as const;

export default PRESET_BUTTON;
19 changes: 19 additions & 0 deletions src/hooks/querys/useAdmissionDetail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { getAdmissionDetail } from '@/api/api';
import { AdmissionType } from '@/types/admission-type';
import formatAdmissionDetail from '@/utils/format-admission-detail';
import { useSuspenseQuery } from '@tanstack/react-query';

export default function useAdmissionDetail(admissionType: AdmissionType | null) {
const { data } = useSuspenseQuery({
queryKey: [admissionType, 'admission'],
queryFn: async () => {
if (!admissionType) {
throw new Error('Admission type is required');
}
return getAdmissionDetail(admissionType);
},
select: (data) => formatAdmissionDetail(data),
});

return data;
}
11 changes: 11 additions & 0 deletions src/hooks/querys/usePostQuestion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { postQuestion } from '@/api/api';
import { DefaultPostQuestionParams } from '@/types/questions';
import { useMutation } from '@tanstack/react-query';

export default function usePostQuestion() {
const questionMutation = useMutation({
mutationFn: (params: DefaultPostQuestionParams) => postQuestion(params),
mutationKey: ['POST_QUESTION'],
});
return { questionMutation };
}
85 changes: 85 additions & 0 deletions src/hooks/use-admission-question-result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useEffect, useState } from 'react';
import systemMessage from '@/constants/message';
import usePostQuestion from '@/hooks/querys/usePostQuestion';
import useMessagesStore from '@/stores/store/message-store';
import useQuestionReferencesStore from '@/stores/store/question-references-store';
import { AdmissionType } from '@/types/admission-type';
import { PostQuestionResponse } from '@/types/questions';
import makePrompt from '@/utils/make-prompt';
import { useQueryClient } from '@tanstack/react-query';

interface UseAdmissionType {
admissionType: AdmissionType;
category: 'ADMISSION_GUIDELINE' | 'PASSING_RESULT';
question: string;
questionType: 'general' | 'detail';
}

export default function useAdmissionQuestionResult({
admissionType,
category,
question,
questionType,
}: UseAdmissionType) {
const [result, setResult] = useState<PostQuestionResponse | null>(null);
const [timestamp, setTimestamp] = useState(Date.now());
const { questionMutation } = usePostQuestion();
const { setMessages } = useMessagesStore();
const { setReferences } = useQuestionReferencesStore();
const queryClient = useQueryClient();

const checkAndSetCacheData = (QUERY_KEY: unknown[]) => {
const cachedData = queryClient.getQueryData<string>(QUERY_KEY);
if (cachedData) {
setMessages([
{
role: 'system',
message: cachedData,
markdown: true,
},
]);
return true;
}
return false;
};

useEffect(() => {
if (!admissionType || !question) return;

const QUERY_KEY = [admissionType, category, question, questionType];
if (checkAndSetCacheData(QUERY_KEY)) return;

const content = questionType === 'general' ? makePrompt(admissionType, question) : question;

questionMutation.mutate(
{
type: admissionType,
category: category,
content: content,
},
{
onSuccess: (data) => {
setResult(data);
setMessages([
{
role: 'system',
message: data.answer.content,
markdown: true,
},
]);
setReferences(data.references);
queryClient.setQueryData(QUERY_KEY, data.answer.content);
},
onError: () => {
setMessages([{ role: 'system', message: systemMessage.errorMessage }]);
},
},
);
}, [question, timestamp]);

const refetch = () => {
setTimestamp(Date.now());
};

return { result, isLoading: questionMutation.isPending, refetch };
}
9 changes: 3 additions & 6 deletions src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.tsx';
import './index.css';

createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
<BrowserRouter>
<App />
</BrowserRouter>,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import PresetButton from '@/components/preset-button/preset-button';
import PRESET_BUTTON, { AdmissionPresetType } from '@/constants/preset-buttons';
import useAdmissionQuestionResult from '@/hooks/use-admission-question-result';
import useAdmissionStore from '@/stores/store/admission-store';
import useMessagesStore from '@/stores/store/message-store';
import { AdmissionType } from '@/types/admission-type';
import { ChatSteps } from '@/types/chat';

interface Props {
changeStep: (step: ChatSteps) => void;
admissionType: AdmissionType;
admissionCategory: string;
}

function AdmissionCategoryResult({ admissionType, changeStep, admissionCategory }: Props) {
const { setQuestion } = useAdmissionStore();
const { isLoading } = useAdmissionQuestionResult({
admissionType: admissionType,
category: 'ADMISSION_GUIDELINE',
question: admissionCategory,
questionType: 'general',
});
const { setMessages } = useMessagesStore();

const selectQuestion = (question: AdmissionPresetType) => {
changeStep('상세전형 질문 결과');
setMessages([{ role: 'user', message: question.label }]);
setQuestion(question);
};

if (!isLoading)
return (
<div>
<div className="mt-2 flex w-full justify-end">
<div className="flex w-72 flex-wrap justify-end gap-2">
<PresetButton
onClick={() => {
changeStep('질문 출처 결과');
setMessages([{ role: 'user', message: '🙋‍♂️ 어디에서 볼 수 있나요?' }]);
}}
>
🙋‍♂️ 어디에서 볼 수 있나요?
</PresetButton>
{PRESET_BUTTON.map((question) => (
<PresetButton
onClick={() => {
selectQuestion(question);
}}
key={question.label}
>
{question.label}
</PresetButton>
))}
<PresetButton onClick={() => changeStep('상세전형 학과별 입시')}>학과별 입시</PresetButton>
<PresetButton onClick={() => window.location.reload()}>조건 재설정</PresetButton>
</div>
</div>
</div>
);
}

export default AdmissionCategoryResult;
Loading

0 comments on commit 7274e03

Please sign in to comment.