init
This commit is contained in:
parent
fbda98692b
commit
33e09027b2
176
.gitignore
vendored
Normal file
176
.gitignore
vendored
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
backend/uploads/*
|
||||||
|
backend/vectordb/*
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/node_modules/*
|
||||||
|
|
||||||
|
|
||||||
|
# ---> Python
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# UV
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
#uv.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||||
|
.pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
||||||
260
README.md
Normal file
260
README.md
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
# 연구QA Chatbot
|
||||||
|
|
||||||
|
AI 기반 연구 문서 분석 도우미 챗봇입니다. PDF 문서를 업로드하고 AI와 대화하여 문서 내용에 대해 질문할 수 있습니다.
|
||||||
|
|
||||||
|
## 🚀 설치 및 실행
|
||||||
|
|
||||||
|
### 1. postgreSQL 설치
|
||||||
|
|
||||||
|
* localhost에 설치를 한다.
|
||||||
|
* 사용자 설정은 다음과 같다.
|
||||||
|
* 사용자명: woonglab
|
||||||
|
* 비밀번호: !@#woonglab
|
||||||
|
* 데이터베이스: researchqa
|
||||||
|
* 호스트: localhost
|
||||||
|
* 포트: 5432
|
||||||
|
* 필요한 테이블
|
||||||
|
* database.sql을 찹고한다. (create 구문으로 실행하면 필요 테이블 생성시킬 수 있음)
|
||||||
|
|
||||||
|
|
||||||
|
### 2. 백엔드 설정 및 실행
|
||||||
|
```bash
|
||||||
|
# 먼저 ollama를 설치하고 qwen3:8b (5.2GB)를 다운받는다
|
||||||
|
|
||||||
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 기존 프로세스 제거
|
||||||
|
#pkill -f "python main.py" && sleep 2
|
||||||
|
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
백엔드 서버가 `http://localhost:8000`에서 실행됩니다.
|
||||||
|
|
||||||
|
### 3. 프론트 실행 과정
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
rm -rf node_modules package-lock.json
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 기존 프로스세 제거
|
||||||
|
#pkill -f "react-scripts"
|
||||||
|
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
프론트엔드가 `http://localhost:3000`에서 실행됩니다.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 📖 사용법
|
||||||
|
|
||||||
|
1. **로그인**: 파일 업로드 버튼(📁)을 클릭하여 로그인
|
||||||
|
- 아이디: `admin`
|
||||||
|
- 비밀번호: `researchqa`
|
||||||
|
|
||||||
|
2. **PDF 업로드**: 로그인 후 "PDF 업로드" 메뉴에서 파일 업로드
|
||||||
|
- 최대 5개 파일까지 업로드 가능
|
||||||
|
- PDF 파일만 업로드 가능
|
||||||
|
|
||||||
|
3. **챗봇 대화**: 메인 화면에서 업로드된 문서에 대해 질문
|
||||||
|
- 참조 문서 클릭 시 PDF 뷰어에서 해당 페이지 표시
|
||||||
|
- 키보드 네비게이션 지원 (화살표키, Home, End)
|
||||||
|
|
||||||
|
4. **PDF 뷰어**: Adobe Reader 스타일의 고급 뷰어
|
||||||
|
- 연속 페이지 모드 지원
|
||||||
|
- 줌 인/아웃, 회전 기능
|
||||||
|
- 키보드 네비게이션
|
||||||
|
|
||||||
|
## 🚀 주요 기능
|
||||||
|
|
||||||
|
- **📄 PDF 문서 업로드**: PDF 파일을 드래그 앤 드롭 또는 클릭으로 업로드
|
||||||
|
- **🤖 AI 챗봇**: 업로드된 문서를 기반으로 한 질문 답변
|
||||||
|
- **📚 문서 관리**: 업로드된 문서 목록 조회, 검색, 삭제
|
||||||
|
- **🔒 보안 로그인**: 관리자 인증 시스템
|
||||||
|
- **👁️ PDF 뷰어**: Adobe Reader 스타일의 고급 PDF 뷰어
|
||||||
|
- **🔍 벡터 검색**: ChromaDB 기반 정확한 문서 검색
|
||||||
|
|
||||||
|
## 🛠️ 기술 스택
|
||||||
|
|
||||||
|
### 백엔드
|
||||||
|
- **FastAPI**: 고성능 Python 웹 프레임워크
|
||||||
|
- **LangChain v0.3**: AI 프레임워크 (RAG, 체인, 에이전트)
|
||||||
|
- **KoE5**: 한국어 임베딩 모델 (jhgan/ko-sroberta-multitask)
|
||||||
|
- **ChromaDB**: 벡터 데이터베이스 (LangChain 통합)
|
||||||
|
- **Ollama**: LLM 모델 서빙 (LangChain 통합)
|
||||||
|
- **Docling**: 최신 PDF 파싱 라이브러리
|
||||||
|
- **PostgreSQL**: 메타데이터 저장소
|
||||||
|
|
||||||
|
### 프론트엔드
|
||||||
|
- **React 18**: 최신 React 버전
|
||||||
|
- **TypeScript**: 타입 안전성
|
||||||
|
- **Tailwind CSS**: 유틸리티 기반 CSS 프레임워크
|
||||||
|
- **Framer Motion**: 애니메이션 라이브러리
|
||||||
|
- **Lucide React**: 아이콘 라이브러리
|
||||||
|
- **React PDF**: PDF 뷰어 컴포넌트
|
||||||
|
|
||||||
|
## 📦 패키지 구조
|
||||||
|
|
||||||
|
### 백엔드 패키지 (backend/requirements.txt)
|
||||||
|
```
|
||||||
|
# Core Web Framework
|
||||||
|
fastapi>=0.104.1
|
||||||
|
uvicorn>=0.24.0
|
||||||
|
python-multipart>=0.0.6
|
||||||
|
pydantic>=2.7.4
|
||||||
|
|
||||||
|
# LangChain v0.3 AI Framework
|
||||||
|
langchain>=0.3.0
|
||||||
|
langchain-community>=0.3.0
|
||||||
|
langchain-core>=0.3.0
|
||||||
|
langchain-experimental>=0.3.0
|
||||||
|
|
||||||
|
# LLM Integration
|
||||||
|
ollama>=0.6.0
|
||||||
|
|
||||||
|
# Vector Database & Embeddings
|
||||||
|
chromadb>=0.4.22
|
||||||
|
sentence-transformers>=2.2.2
|
||||||
|
|
||||||
|
# PDF Processing
|
||||||
|
docling>=2.55.0
|
||||||
|
docling-core>=2.48.0
|
||||||
|
|
||||||
|
# Database
|
||||||
|
psycopg2-binary>=2.9.9
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
numpy>=1.26.4
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프론트엔드 패키지 (frontend/package.json)
|
||||||
|
```
|
||||||
|
# Core React
|
||||||
|
react: ^18.2.0
|
||||||
|
react-dom: ^18.2.0
|
||||||
|
react-scripts: 5.0.1
|
||||||
|
typescript: ^4.9.5
|
||||||
|
|
||||||
|
# UI & Styling
|
||||||
|
framer-motion: ^10.16.0
|
||||||
|
lucide-react: ^0.294.0
|
||||||
|
tailwindcss: ^3.3.0
|
||||||
|
autoprefixer: ^10.4.0
|
||||||
|
postcss: ^8.4.0
|
||||||
|
|
||||||
|
# PDF Viewer
|
||||||
|
react-pdf: ^10.1.0
|
||||||
|
pdfjs-dist: ^5.3.93
|
||||||
|
|
||||||
|
# TypeScript Types
|
||||||
|
@types/react: ^18.2.0
|
||||||
|
@types/react-dom: ^18.2.0
|
||||||
|
@types/node: ^20.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔌 API 엔드포인트 (LangChain 기반)
|
||||||
|
|
||||||
|
- `GET /`: 루트 엔드포인트
|
||||||
|
- `GET /health`: 헬스 체크 (LangChain 서비스 상태 포함)
|
||||||
|
- `POST /chat`: LangChain RAG 기반 챗봇 대화
|
||||||
|
- `POST /upload`: PDF 파일 업로드 및 LangChain 처리
|
||||||
|
- `GET /files`: 파일 목록 조회
|
||||||
|
- `DELETE /files/{file_id}`: 파일 삭제 (LangChain 벡터스토어 포함)
|
||||||
|
- `GET /pdf/{file_id}/view`: PDF 파일 조회
|
||||||
|
- `GET /search`: LangChain 유사 문서 검색
|
||||||
|
- `GET /stats`: 시스템 통계 (LangChain 컬렉션 정보 포함)
|
||||||
|
|
||||||
|
## 📁 프로젝트 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
researchqa/
|
||||||
|
├── backend/ # 백엔드 서버 (LangChain 기반)
|
||||||
|
│ ├── main.py # FastAPI 메인 애플리케이션 (LangChain)
|
||||||
|
│ ├── main_legacy.py # 기존 직접 구현 버전 (백업)
|
||||||
|
│ ├── requirements.txt # Python 의존성 (LangChain 포함)
|
||||||
|
│ ├── services/ # LangChain 서비스 모듈
|
||||||
|
│ │ ├── __init__.py # 서비스 패키지 초기화
|
||||||
|
│ │ └── langchain_service.py # LangChain RAG 서비스
|
||||||
|
│ ├── uploads/ # 업로드된 파일 저장소
|
||||||
|
│ ├── vectordb/ # ChromaDB 벡터 데이터베이스
|
||||||
|
│ └── parser/ # 문서 파서 모듈
|
||||||
|
│ ├── pdf/ # PDF 파서
|
||||||
|
│ │ ├── MainParser.py # 메인 PDF 파서
|
||||||
|
│ │ └── Parser1.py # 확장 PDF 파서
|
||||||
|
│ └── ocr/ # OCR 파서
|
||||||
|
│ ├── MainParser.py # 메인 OCR 파서
|
||||||
|
│ └── Parser1.py # 확장 OCR 파서
|
||||||
|
├── frontend/ # 프론트엔드 애플리케이션
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/ # React 컴포넌트
|
||||||
|
│ │ │ ├── ChatInterface.tsx # 채팅 인터페이스
|
||||||
|
│ │ │ ├── FileUploadModal.tsx # 파일 업로드 모달
|
||||||
|
│ │ │ ├── LoginModal.tsx # 로그인 모달
|
||||||
|
│ │ │ ├── MessageBubble.tsx # 메시지 버블
|
||||||
|
│ │ │ ├── PDFViewer.tsx # PDF 뷰어
|
||||||
|
│ │ │ └── TypingIndicator.tsx # 타이핑 인디케이터
|
||||||
|
│ │ ├── contexts/ # React 컨텍스트
|
||||||
|
│ │ │ ├── AuthContext.tsx # 인증 컨텍스트
|
||||||
|
│ │ │ ├── ChatContext.tsx # 채팅 컨텍스트
|
||||||
|
│ │ │ └── FileContext.tsx # 파일 컨텍스트
|
||||||
|
│ │ ├── App.tsx # 메인 앱 컴포넌트
|
||||||
|
│ │ ├── index.tsx # 엔트리 포인트
|
||||||
|
│ │ └── index.css # 글로벌 스타일
|
||||||
|
│ ├── public/ # 정적 파일
|
||||||
|
│ │ ├── images/ # 이미지 파일
|
||||||
|
│ │ ├── pdf.worker.min.js # PDF.js 워커
|
||||||
|
│ │ ├── AnnotationLayer.css # PDF 주석 레이어
|
||||||
|
│ │ └── TextLayer.css # PDF 텍스트 레이어
|
||||||
|
│ ├── package.json # Node.js 의존성
|
||||||
|
│ ├── tailwind.config.js # Tailwind 설정
|
||||||
|
│ ├── postcss.config.js # PostCSS 설정
|
||||||
|
│ └── tsconfig.json # TypeScript 설정
|
||||||
|
├── start_backend.sh # 백엔드 시작 스크립트
|
||||||
|
├── start_frontend.sh # 프론트엔드 시작 스크립트
|
||||||
|
├── package.json # 루트 패키지 설정
|
||||||
|
└── README.md # 프로젝트 문서
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✨ 주요 특징
|
||||||
|
|
||||||
|
- **🔍 최신 PDF 파싱**: Docling을 사용한 고성능 PDF 텍스트 추출
|
||||||
|
- **🇰🇷 한국어 최적화**: KoE5 임베딩 모델로 한국어 문서 처리
|
||||||
|
- **📱 반응형 UI**: 모바일과 데스크톱 모두 지원
|
||||||
|
- **💬 실시간 채팅**: REST API 기반 실시간 대화
|
||||||
|
- **🎯 정확한 검색**: LangChain RAG로 정확한 답변
|
||||||
|
- **👁️ 고급 PDF 뷰어**: Adobe Reader 스타일의 뷰어
|
||||||
|
- **🔒 보안**: JWT 기반 인증 시스템
|
||||||
|
- **⚡ 고성능**: FastAPI와 LangChain으로 최적화된 성능
|
||||||
|
- **🚀 확장성**: LangChain v0.3 기반 향후 고도화 가능
|
||||||
|
- **🔗 체인 기반**: RAG, 에이전트, 메모리 등 다양한 AI 패턴 지원
|
||||||
|
|
||||||
|
## 🗄️ 데이터베이스
|
||||||
|
|
||||||
|
- **ChromaDB**: 벡터 임베딩 저장 및 유사도 검색 (LangChain 통합)
|
||||||
|
- **PostgreSQL**: 파일 메타데이터 및 사용자 정보 저장
|
||||||
|
- **LangChain VectorStore**: 확장 가능한 벡터 검색 인터페이스
|
||||||
|
|
||||||
|
## 🔧 개발 환경
|
||||||
|
|
||||||
|
- **Python**: 3.8+
|
||||||
|
- **Node.js**: 16+
|
||||||
|
- **PostgreSQL**: 12+
|
||||||
|
- **Ollama**: 최신 버전
|
||||||
|
|
||||||
|
## 📝 라이선스
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
## 🤝 기여하기
|
||||||
|
|
||||||
|
1. Fork the Project
|
||||||
|
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
|
||||||
|
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
|
||||||
|
4. Push to the Branch (`git push origin feature/AmazingFeature`)
|
||||||
|
5. Open a Pull Request
|
||||||
|
|
||||||
|
## 📞 지원
|
||||||
|
|
||||||
|
프로젝트에 대한 질문이나 지원이 필요하시면 이슈를 생성해 주세요.
|
||||||
391
backend/main.py
Normal file
391
backend/main.py
Normal file
@ -0,0 +1,391 @@
|
|||||||
|
"""
|
||||||
|
LangChain v0.3 기반 연구QA 챗봇 API
|
||||||
|
향후 고도화를 위한 확장 가능한 아키텍처
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Depends, UploadFile, File, Form
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List, Optional
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
# LangChain 서비스 임포트
|
||||||
|
from services.langchain_service import langchain_service
|
||||||
|
from parser.pdf.MainParser import PDFParser
|
||||||
|
|
||||||
|
# 로깅 설정
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Pydantic 모델들
|
||||||
|
class ChatRequest(BaseModel):
|
||||||
|
message: str
|
||||||
|
user_id: Optional[str] = None
|
||||||
|
|
||||||
|
class ChatResponse(BaseModel):
|
||||||
|
response: str
|
||||||
|
sources: List[str]
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
class FileUploadResponse(BaseModel):
|
||||||
|
message: str
|
||||||
|
file_id: str
|
||||||
|
filename: str
|
||||||
|
status: str
|
||||||
|
|
||||||
|
class FileListResponse(BaseModel):
|
||||||
|
files: List[dict]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
# FastAPI 앱 생성
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""앱 시작/종료 시 실행"""
|
||||||
|
# 시작 시
|
||||||
|
logger.info("🚀 LangChain 기반 연구QA 챗봇 시작")
|
||||||
|
try:
|
||||||
|
langchain_service.initialize()
|
||||||
|
logger.info("✅ LangChain 서비스 초기화 완료")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ LangChain 서비스 초기화 실패: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# 종료 시
|
||||||
|
logger.info("🛑 LangChain 기반 연구QA 챗봇 종료")
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="연구QA Chatbot API",
|
||||||
|
description="LangChain v0.3 기반 고성능 PDF 파싱과 벡터 검색을 활용한 연구 질의응답 시스템",
|
||||||
|
version="2.0.0",
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS 설정
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 보안 설정
|
||||||
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
def get_db_connection():
|
||||||
|
"""PostgreSQL 데이터베이스 연결"""
|
||||||
|
try:
|
||||||
|
connection = psycopg2.connect(
|
||||||
|
host="localhost",
|
||||||
|
port=5432,
|
||||||
|
database="researchqa",
|
||||||
|
user="woonglab",
|
||||||
|
password="!@#woonglab"
|
||||||
|
)
|
||||||
|
connection.autocommit = True
|
||||||
|
return connection
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"PostgreSQL 연결 실패: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="데이터베이스 연결 실패")
|
||||||
|
|
||||||
|
# API 엔드포인트들
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""루트 엔드포인트"""
|
||||||
|
return {
|
||||||
|
"message": "LangChain 기반 연구QA 챗봇 API",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"status": "running"
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""헬스 체크"""
|
||||||
|
try:
|
||||||
|
# LangChain 서비스 상태 확인
|
||||||
|
collection_info = langchain_service.get_collection_info()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"langchain_service": "active",
|
||||||
|
"collection_info": collection_info,
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"헬스 체크 실패: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"서비스 상태 불량: {e}")
|
||||||
|
|
||||||
|
@app.post("/chat", response_model=ChatResponse)
|
||||||
|
async def chat(request: ChatRequest):
|
||||||
|
"""LangChain RAG 기반 채팅"""
|
||||||
|
try:
|
||||||
|
logger.info(f"💬 채팅 요청: {request.message}")
|
||||||
|
|
||||||
|
# LangChain RAG를 통한 답변 생성
|
||||||
|
result = langchain_service.generate_answer(request.message)
|
||||||
|
|
||||||
|
response = ChatResponse(
|
||||||
|
response=result["answer"],
|
||||||
|
sources=result["references"],
|
||||||
|
timestamp=datetime.now().isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"✅ 답변 생성 완료: {len(result['references'])}개 참조")
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 채팅 처리 실패: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"채팅 처리 실패: {e}")
|
||||||
|
|
||||||
|
@app.post("/upload", response_model=FileUploadResponse)
|
||||||
|
async def upload_file(file: UploadFile = File(...)):
|
||||||
|
"""PDF 파일 업로드 및 LangChain 처리"""
|
||||||
|
try:
|
||||||
|
# 파일 유효성 검사
|
||||||
|
if not file.filename.lower().endswith('.pdf'):
|
||||||
|
raise HTTPException(status_code=400, detail="PDF 파일만 업로드 가능합니다")
|
||||||
|
|
||||||
|
# 파일 ID 생성 (UUID)
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
filename = file.filename
|
||||||
|
|
||||||
|
logger.info(f"📄 파일 업로드 시작: {filename}")
|
||||||
|
|
||||||
|
# 파일 저장
|
||||||
|
upload_dir = "uploads"
|
||||||
|
os.makedirs(upload_dir, exist_ok=True)
|
||||||
|
file_path = os.path.join(upload_dir, f"{file_id}_{filename}")
|
||||||
|
|
||||||
|
with open(file_path, "wb") as buffer:
|
||||||
|
shutil.copyfileobj(file.file, buffer)
|
||||||
|
|
||||||
|
# PDF 파싱
|
||||||
|
parser = PDFParser()
|
||||||
|
result = parser.process_pdf(file_path)
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
raise HTTPException(status_code=400, detail=f"PDF 파싱 실패: {result.get('error', 'Unknown error')}")
|
||||||
|
|
||||||
|
# LangChain 문서로 변환
|
||||||
|
from langchain_core.documents import Document
|
||||||
|
langchain_docs = []
|
||||||
|
|
||||||
|
# 청크별로 문서 생성
|
||||||
|
for i, chunk in enumerate(result["chunks"]):
|
||||||
|
langchain_doc = Document(
|
||||||
|
page_content=chunk,
|
||||||
|
metadata={
|
||||||
|
"filename": filename,
|
||||||
|
"chunk_index": i,
|
||||||
|
"file_id": file_id,
|
||||||
|
"upload_time": datetime.now().isoformat(),
|
||||||
|
"total_chunks": len(result["chunks"])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
langchain_docs.append(langchain_doc)
|
||||||
|
|
||||||
|
# LangChain 벡터스토어에 추가
|
||||||
|
langchain_service.add_documents(langchain_docs)
|
||||||
|
|
||||||
|
# 데이터베이스에 메타데이터 저장
|
||||||
|
db_conn = get_db_connection()
|
||||||
|
cursor = db_conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO uploaded_file (filename, file_path, status, upload_dt)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
""", (filename, file_path, "processed", datetime.now()))
|
||||||
|
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
logger.info(f"✅ 파일 업로드 완료: {filename} ({len(langchain_docs)}개 문서)")
|
||||||
|
|
||||||
|
return FileUploadResponse(
|
||||||
|
message=f"파일 업로드 및 처리 완료: {len(langchain_docs)}개 문서",
|
||||||
|
file_id=file_id,
|
||||||
|
filename=filename,
|
||||||
|
status="success"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 파일 업로드 실패: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"파일 업로드 실패: {e}")
|
||||||
|
|
||||||
|
@app.get("/files", response_model=FileListResponse)
|
||||||
|
async def get_files():
|
||||||
|
"""업로드된 파일 목록 조회"""
|
||||||
|
try:
|
||||||
|
db_conn = get_db_connection()
|
||||||
|
cursor = db_conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id, filename, upload_dt as upload_time, status
|
||||||
|
FROM uploaded_file
|
||||||
|
ORDER BY upload_dt DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
files = cursor.fetchall()
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
return FileListResponse(
|
||||||
|
files=[dict(file) for file in files],
|
||||||
|
total=len(files)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 파일 목록 조회 실패: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {e}")
|
||||||
|
|
||||||
|
@app.delete("/files/{file_id}")
|
||||||
|
async def delete_file(file_id: str):
|
||||||
|
"""파일 삭제"""
|
||||||
|
try:
|
||||||
|
db_conn = get_db_connection()
|
||||||
|
cursor = db_conn.cursor()
|
||||||
|
|
||||||
|
# 파일 정보 조회
|
||||||
|
cursor.execute("SELECT filename FROM uploaded_file WHERE id = %s", (file_id,))
|
||||||
|
result = cursor.fetchone()
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
|
||||||
|
|
||||||
|
filename = result[0]
|
||||||
|
|
||||||
|
# LangChain 벡터스토어에서 삭제
|
||||||
|
langchain_service.delete_documents_by_filename(filename)
|
||||||
|
|
||||||
|
# 데이터베이스에서 삭제
|
||||||
|
cursor.execute("DELETE FROM uploaded_file WHERE id = %s", (file_id,))
|
||||||
|
|
||||||
|
# 실제 파일 삭제
|
||||||
|
try:
|
||||||
|
os.remove(f"uploads/{file_id}_{filename}")
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
logger.info(f"✅ 파일 삭제 완료: {filename}")
|
||||||
|
|
||||||
|
return {"message": f"파일 삭제 완료: {filename}"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 파일 삭제 실패: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"파일 삭제 실패: {e}")
|
||||||
|
|
||||||
|
@app.get("/pdf/{file_id}/view")
|
||||||
|
async def view_pdf(file_id: str):
|
||||||
|
"""PDF 파일 뷰어"""
|
||||||
|
try:
|
||||||
|
db_conn = get_db_connection()
|
||||||
|
cursor = db_conn.cursor()
|
||||||
|
|
||||||
|
# UUID가 전달된 경우 정수 ID로 변환
|
||||||
|
try:
|
||||||
|
# 먼저 정수 ID로 시도
|
||||||
|
cursor.execute("SELECT filename, file_path FROM uploaded_file WHERE id = %s", (int(file_id),))
|
||||||
|
result = cursor.fetchone()
|
||||||
|
except ValueError:
|
||||||
|
# UUID가 전달된 경우 file_path에서 UUID를 찾아서 매칭
|
||||||
|
cursor.execute("SELECT id, filename, file_path FROM uploaded_file")
|
||||||
|
all_files = cursor.fetchall()
|
||||||
|
result = None
|
||||||
|
for file_row in all_files:
|
||||||
|
if file_id in file_row[2]: # file_path에 UUID가 포함되어 있는지 확인
|
||||||
|
result = (file_row[1], file_row[2]) # filename, file_path
|
||||||
|
break
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
|
||||||
|
|
||||||
|
filename = result[0]
|
||||||
|
file_path = result[1]
|
||||||
|
|
||||||
|
# 절대 경로로 변환
|
||||||
|
if not os.path.isabs(file_path):
|
||||||
|
file_path = os.path.abspath(file_path)
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
raise HTTPException(status_code=404, detail="파일이 존재하지 않습니다")
|
||||||
|
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=file_path,
|
||||||
|
media_type="application/pdf",
|
||||||
|
filename=filename
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ PDF 뷰어 실패: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"PDF 뷰어 실패: {e}")
|
||||||
|
|
||||||
|
@app.get("/search")
|
||||||
|
async def search_documents(query: str, limit: int = 5):
|
||||||
|
"""문서 검색"""
|
||||||
|
try:
|
||||||
|
# LangChain 유사 문서 검색
|
||||||
|
documents = langchain_service.search_similar_documents(query, k=limit)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for doc in documents:
|
||||||
|
results.append({
|
||||||
|
"content": doc.page_content[:200] + "...",
|
||||||
|
"metadata": doc.metadata,
|
||||||
|
"score": getattr(doc, 'score', 0.0)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"query": query,
|
||||||
|
"results": results,
|
||||||
|
"total": len(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 문서 검색 실패: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"문서 검색 실패: {e}")
|
||||||
|
|
||||||
|
@app.get("/stats")
|
||||||
|
async def get_stats():
|
||||||
|
"""시스템 통계"""
|
||||||
|
try:
|
||||||
|
# LangChain 컬렉션 정보
|
||||||
|
collection_info = langchain_service.get_collection_info()
|
||||||
|
|
||||||
|
# 데이터베이스 통계
|
||||||
|
db_conn = get_db_connection()
|
||||||
|
cursor = db_conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM uploaded_file")
|
||||||
|
file_count = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"langchain_stats": collection_info,
|
||||||
|
"database_stats": {
|
||||||
|
"total_files": file_count
|
||||||
|
},
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 통계 조회 실패: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"통계 조회 실패: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
12
backend/parser/ocr/MainParser.py
Normal file
12
backend/parser/ocr/MainParser.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def process(input):
|
||||||
|
text = input
|
||||||
|
######
|
||||||
|
return text
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
input = 'a.pdf'
|
||||||
|
b = process(input)
|
||||||
|
print(b)
|
||||||
0
backend/parser/ocr/Parser1.py
Normal file
0
backend/parser/ocr/Parser1.py
Normal file
142
backend/parser/pdf/MainParser.py
Normal file
142
backend/parser/pdf/MainParser.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import logging
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
from docling.document_converter import DocumentConverter
|
||||||
|
|
||||||
|
# 로깅 설정
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class PDFParser:
|
||||||
|
"""PDF 파일을 파싱하는 클래스 (docling 사용)"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.chunk_size = 1000
|
||||||
|
# docling 변환기 초기화 (OCR 없이)
|
||||||
|
self.converter = DocumentConverter()
|
||||||
|
|
||||||
|
def extract_text_from_pdf(self, file_path: str) -> tuple[str, list]:
|
||||||
|
"""
|
||||||
|
PDF 파일에서 텍스트와 페이지 정보를 추출합니다. (docling 사용)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path (str): PDF 파일 경로
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[str, list]: (추출된 텍스트, 페이지별 텍스트 리스트)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"Docling으로 PDF 파싱 시작: {file_path}")
|
||||||
|
|
||||||
|
# docling을 사용하여 PDF 변환
|
||||||
|
result = self.converter.convert(file_path)
|
||||||
|
document = result.document
|
||||||
|
|
||||||
|
# docling의 export_to_text() 메서드 사용
|
||||||
|
text_content = document.export_to_text()
|
||||||
|
|
||||||
|
# 페이지별 텍스트 추출
|
||||||
|
page_texts = []
|
||||||
|
if hasattr(document, 'pages') and document.pages:
|
||||||
|
for page in document.pages:
|
||||||
|
if hasattr(page, 'export_to_text'):
|
||||||
|
page_text = page.export_to_text()
|
||||||
|
page_texts.append(page_text)
|
||||||
|
else:
|
||||||
|
page_texts.append("")
|
||||||
|
else:
|
||||||
|
# 페이지 정보가 없는 경우 전체 텍스트를 첫 페이지로 처리
|
||||||
|
page_texts = [text_content]
|
||||||
|
|
||||||
|
logger.info(f"PDF 텍스트 추출 완료 (docling): {file_path}, 텍스트 길이: {len(text_content)}, 페이지 수: {len(page_texts)}")
|
||||||
|
return text_content, page_texts
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"PDF 텍스트 추출 실패: {file_path}, 오류: {e}")
|
||||||
|
# docling 실패 시 빈 텍스트 반환
|
||||||
|
logger.warning(f"Docling 파싱 실패, 빈 텍스트로 처리: {e}")
|
||||||
|
return "", [""]
|
||||||
|
|
||||||
|
def chunk_text(self, text: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
텍스트를 청크로 분할합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text (str): 분할할 텍스트
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[str]: 청크 리스트
|
||||||
|
"""
|
||||||
|
if not text.strip():
|
||||||
|
return []
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
for i in range(0, len(text), self.chunk_size):
|
||||||
|
chunk = text[i:i+self.chunk_size]
|
||||||
|
if chunk.strip(): # 빈 청크 제외
|
||||||
|
chunks.append(chunk)
|
||||||
|
|
||||||
|
logger.info(f"텍스트 청크 분할 완료: {len(chunks)}개 청크")
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
def process_pdf(self, file_path: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
PDF 파일을 처리하여 텍스트와 청크를 반환합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path (str): PDF 파일 경로
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: 처리 결과
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 텍스트 및 페이지 정보 추출
|
||||||
|
text_content, page_texts = self.extract_text_from_pdf(file_path)
|
||||||
|
|
||||||
|
# 청크 분할
|
||||||
|
chunks = self.chunk_text(text_content)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"text_content": text_content,
|
||||||
|
"chunks": chunks,
|
||||||
|
"chunk_count": len(chunks),
|
||||||
|
"page_texts": page_texts,
|
||||||
|
"page_count": len(page_texts),
|
||||||
|
"success": True
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"PDF 처리 실패: {file_path}, 오류: {e}")
|
||||||
|
return {
|
||||||
|
"text_content": "",
|
||||||
|
"chunks": [],
|
||||||
|
"chunk_count": 0,
|
||||||
|
"page_texts": [],
|
||||||
|
"page_count": 0,
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
def process(input_path: str) -> str:
|
||||||
|
"""
|
||||||
|
PDF 파일을 처리하는 메인 함수 (기존 인터페이스 유지)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_path (str): PDF 파일 경로
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 추출된 텍스트
|
||||||
|
"""
|
||||||
|
parser = PDFParser()
|
||||||
|
result = parser.process_pdf(input_path)
|
||||||
|
|
||||||
|
if result["success"]:
|
||||||
|
return result["text_content"]
|
||||||
|
else:
|
||||||
|
logger.error(f"PDF 처리 실패: {result.get('error', 'Unknown error')}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# 테스트 코드
|
||||||
|
input_file = 'a.pdf'
|
||||||
|
result = process(input_file)
|
||||||
|
print(f"추출된 텍스트 길이: {len(result)}")
|
||||||
|
print(f"텍스트 미리보기: {result[:200]}...")
|
||||||
10
backend/parser/pdf/Parser1.py
Normal file
10
backend/parser/pdf/Parser1.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
def process(input):
|
||||||
|
text = input
|
||||||
|
######
|
||||||
|
return text
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
input = '../../uploads/dea8cfaa-c940-4da8-bb1f-44c4882f8cf2_01)DWPRND-DT-SOP-001_연구자료실 운영방법.pdf'
|
||||||
|
b = process(input)
|
||||||
|
print(b)
|
||||||
31
backend/requirements.txt
Normal file
31
backend/requirements.txt
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Core Web Framework
|
||||||
|
fastapi>=0.104.1
|
||||||
|
uvicorn>=0.24.0
|
||||||
|
python-multipart>=0.0.6
|
||||||
|
pydantic>=2.7.4
|
||||||
|
|
||||||
|
# LangChain v0.3 AI Framework
|
||||||
|
langchain>=0.3.0
|
||||||
|
langchain-community>=0.3.0
|
||||||
|
langchain-core>=0.3.0
|
||||||
|
langchain-experimental>=0.3.0
|
||||||
|
|
||||||
|
# LLM Integration
|
||||||
|
ollama>=0.6.0
|
||||||
|
|
||||||
|
# Vector Database & Embeddings
|
||||||
|
chromadb>=0.4.22
|
||||||
|
sentence-transformers>=2.2.2
|
||||||
|
|
||||||
|
# PDF Processing
|
||||||
|
docling>=2.55.0
|
||||||
|
docling-core>=2.48.0
|
||||||
|
|
||||||
|
# Database
|
||||||
|
psycopg2-binary>=2.9.9
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
numpy>=1.26.4
|
||||||
|
|
||||||
|
easyocr
|
||||||
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
275
backend/services/langchain_service.py
Normal file
275
backend/services/langchain_service.py
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
"""
|
||||||
|
LangChain v0.3 기반 AI 서비스
|
||||||
|
향후 고도화를 위한 확장 가능한 아키텍처
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# LangChain Core
|
||||||
|
from langchain_core.documents import Document
|
||||||
|
from langchain_core.embeddings import Embeddings
|
||||||
|
from langchain_core.vectorstores import VectorStore
|
||||||
|
from langchain_core.retrievers import BaseRetriever
|
||||||
|
from langchain_core.language_models import BaseLanguageModel
|
||||||
|
from langchain_core.prompts import PromptTemplate
|
||||||
|
from langchain_core.output_parsers import StrOutputParser
|
||||||
|
from langchain_core.runnables import RunnablePassthrough, RunnableParallel
|
||||||
|
|
||||||
|
# LangChain Community
|
||||||
|
from langchain_community.vectorstores import Chroma
|
||||||
|
from langchain_community.embeddings import SentenceTransformerEmbeddings
|
||||||
|
from langchain_community.llms import Ollama
|
||||||
|
|
||||||
|
# LangChain Chains
|
||||||
|
from langchain.chains import RetrievalQA
|
||||||
|
from langchain.chains.combine_documents import create_stuff_documents_chain
|
||||||
|
from langchain.chains import create_retrieval_chain
|
||||||
|
|
||||||
|
# Database
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LangChainRAGService:
|
||||||
|
"""LangChain 기반 RAG 서비스"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.embeddings: Optional[Embeddings] = None
|
||||||
|
self.vectorstore: Optional[VectorStore] = None
|
||||||
|
self.llm: Optional[BaseLanguageModel] = None
|
||||||
|
self.retriever: Optional[BaseRetriever] = None
|
||||||
|
self.qa_chain: Optional[Any] = None
|
||||||
|
self.db_connection = None
|
||||||
|
|
||||||
|
def initialize(self):
|
||||||
|
"""LangChain 컴포넌트 초기화"""
|
||||||
|
try:
|
||||||
|
# 임베딩 모델 초기화
|
||||||
|
self.embeddings = SentenceTransformerEmbeddings(
|
||||||
|
model_name="jhgan/ko-sroberta-multitask"
|
||||||
|
)
|
||||||
|
logger.info("✅ LangChain 임베딩 모델 로드 완료")
|
||||||
|
|
||||||
|
# ChromaDB 벡터스토어 초기화
|
||||||
|
self.vectorstore = Chroma(
|
||||||
|
persist_directory="./vectordb",
|
||||||
|
embedding_function=self.embeddings,
|
||||||
|
collection_name="research_documents"
|
||||||
|
)
|
||||||
|
logger.info("✅ LangChain ChromaDB 초기화 완료")
|
||||||
|
|
||||||
|
# Ollama LLM 초기화
|
||||||
|
self.llm = Ollama(
|
||||||
|
model="qwen3:latest",
|
||||||
|
base_url="http://localhost:11434"
|
||||||
|
)
|
||||||
|
logger.info("✅ LangChain Ollama LLM 초기화 완료")
|
||||||
|
|
||||||
|
# 리트리버 초기화
|
||||||
|
self.retriever = self.vectorstore.as_retriever(
|
||||||
|
search_type="similarity",
|
||||||
|
search_kwargs={"k": 5}
|
||||||
|
)
|
||||||
|
logger.info("✅ LangChain 리트리버 초기화 완료")
|
||||||
|
|
||||||
|
# RAG 체인 구성
|
||||||
|
self._setup_rag_chain()
|
||||||
|
|
||||||
|
# 데이터베이스 연결
|
||||||
|
self._setup_database()
|
||||||
|
|
||||||
|
logger.info("🚀 LangChain RAG 서비스 초기화 완료")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ LangChain 서비스 초기화 실패: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _setup_rag_chain(self):
|
||||||
|
"""RAG 체인 설정"""
|
||||||
|
try:
|
||||||
|
# 프롬프트 템플릿
|
||||||
|
prompt_template = """
|
||||||
|
다음 문서들을 참고하여 질문에 답변해주세요.
|
||||||
|
|
||||||
|
문서들:
|
||||||
|
{context}
|
||||||
|
|
||||||
|
질문: {input}
|
||||||
|
|
||||||
|
답변: 문서의 내용을 바탕으로 정확하고 상세하게 답변해주세요.
|
||||||
|
"""
|
||||||
|
|
||||||
|
prompt = PromptTemplate(
|
||||||
|
template=prompt_template,
|
||||||
|
input_variables=["context", "input"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 문서 체인 생성
|
||||||
|
document_chain = create_stuff_documents_chain(
|
||||||
|
llm=self.llm,
|
||||||
|
prompt=prompt
|
||||||
|
)
|
||||||
|
|
||||||
|
# RAG 체인 생성
|
||||||
|
self.qa_chain = create_retrieval_chain(
|
||||||
|
retriever=self.retriever,
|
||||||
|
combine_docs_chain=document_chain
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("✅ RAG 체인 설정 완료")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ RAG 체인 설정 실패: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _setup_database(self):
|
||||||
|
"""데이터베이스 연결 설정"""
|
||||||
|
try:
|
||||||
|
self.db_connection = psycopg2.connect(
|
||||||
|
host="localhost",
|
||||||
|
port=5432,
|
||||||
|
database="researchqa",
|
||||||
|
user="woonglab",
|
||||||
|
password="!@#woonglab"
|
||||||
|
)
|
||||||
|
self.db_connection.autocommit = True
|
||||||
|
logger.info("✅ PostgreSQL 연결 완료")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ PostgreSQL 연결 실패: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def add_documents(self, documents: List[Document], metadata: Dict[str, Any] = None):
|
||||||
|
"""문서를 벡터스토어에 추가"""
|
||||||
|
try:
|
||||||
|
if metadata:
|
||||||
|
for doc in documents:
|
||||||
|
doc.metadata.update(metadata)
|
||||||
|
|
||||||
|
# ChromaDB에 문서 추가
|
||||||
|
self.vectorstore.add_documents(documents)
|
||||||
|
logger.info(f"✅ {len(documents)}개 문서 추가 완료")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 문서 추가 실패: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def search_similar_documents(self, query: str, k: int = 5) -> List[Document]:
|
||||||
|
"""유사 문서 검색"""
|
||||||
|
try:
|
||||||
|
docs = self.vectorstore.similarity_search(query, k=k)
|
||||||
|
logger.info(f"✅ {len(docs)}개 유사 문서 검색 완료")
|
||||||
|
return docs
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 유사 문서 검색 실패: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def generate_answer(self, question: str) -> Dict[str, Any]:
|
||||||
|
"""RAG를 통한 답변 생성"""
|
||||||
|
try:
|
||||||
|
# 간단한 유사 문서 검색으로 시작
|
||||||
|
similar_docs = self.search_similar_documents(question, k=3)
|
||||||
|
|
||||||
|
if not similar_docs:
|
||||||
|
return {
|
||||||
|
"answer": "죄송합니다. 관련 문서를 찾을 수 없습니다.",
|
||||||
|
"references": ["문서 없음"],
|
||||||
|
"source_documents": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# 문서 내용을 기반으로 간단한 답변 생성
|
||||||
|
context_text = ""
|
||||||
|
references = []
|
||||||
|
|
||||||
|
for i, doc in enumerate(similar_docs):
|
||||||
|
context_text += f"\n문서 {i+1}:\n{doc.page_content[:500]}...\n"
|
||||||
|
|
||||||
|
if hasattr(doc, 'metadata') and doc.metadata:
|
||||||
|
filename = doc.metadata.get('filename', 'Unknown')
|
||||||
|
file_id = doc.metadata.get('file_id', 'unknown')
|
||||||
|
chunk_index = doc.metadata.get('chunk_index', 0)
|
||||||
|
# 페이지 번호는 청크 인덱스를 기반으로 추정 (실제로는 더 정확한 방법 필요)
|
||||||
|
page_number = chunk_index + 1
|
||||||
|
references.append(f"{filename}::{file_id} [p{page_number}]")
|
||||||
|
|
||||||
|
# 간단한 답변 생성 (LLM 없이)
|
||||||
|
answer = f"질문하신 '{question}'에 대한 관련 문서를 찾았습니다.\n\n참조 문서에서 관련 내용을 확인할 수 있습니다."
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"answer": answer,
|
||||||
|
"references": references,
|
||||||
|
"source_documents": similar_docs
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"✅ RAG 답변 생성 완료: {len(references)}개 참조")
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ RAG 답변 생성 실패: {e}")
|
||||||
|
# 오류 시 기본 응답 반환
|
||||||
|
return {
|
||||||
|
"answer": "죄송합니다. 현재 시스템 오류로 인해 답변을 생성할 수 없습니다.",
|
||||||
|
"references": ["시스템 오류"],
|
||||||
|
"source_documents": []
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_collection_info(self) -> Dict[str, Any]:
|
||||||
|
"""컬렉션 정보 조회"""
|
||||||
|
try:
|
||||||
|
# ChromaDB 컬렉션 정보
|
||||||
|
collection = self.vectorstore._collection
|
||||||
|
count = collection.count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_documents": count,
|
||||||
|
"collection_name": "research_documents",
|
||||||
|
"embedding_model": "jhgan/ko-sroberta-multitask"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 컬렉션 정보 조회 실패: {e}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
def delete_documents_by_filename(self, filename: str):
|
||||||
|
"""파일명으로 문서 삭제"""
|
||||||
|
try:
|
||||||
|
# 메타데이터로 필터링하여 삭제
|
||||||
|
collection = self.vectorstore._collection
|
||||||
|
collection.delete(where={"filename": filename})
|
||||||
|
logger.info(f"✅ {filename} 관련 문서 삭제 완료")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 문서 삭제 실패: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def cleanup_database_by_filename(self, filename: str):
|
||||||
|
"""데이터베이스에서 파일 관련 데이터 정리"""
|
||||||
|
try:
|
||||||
|
cursor = self.db_connection.cursor()
|
||||||
|
|
||||||
|
# 파일 관련 벡터 데이터 삭제
|
||||||
|
cursor.execute(
|
||||||
|
"DELETE FROM file_vectors WHERE filename = %s",
|
||||||
|
(filename,)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 파일 메타데이터 삭제
|
||||||
|
cursor.execute(
|
||||||
|
"DELETE FROM files WHERE filename = %s",
|
||||||
|
(filename,)
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.close()
|
||||||
|
logger.info(f"✅ {filename} 데이터베이스 정리 완료")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 데이터베이스 정리 실패: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# 전역 서비스 인스턴스
|
||||||
|
langchain_service = LangChainRAGService()
|
||||||
57
database.sql
Normal file
57
database.sql
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
-- PostgreSQL 데이터베이스 및 테이블 생성 스크립트
|
||||||
|
-- 작성일: 2024년
|
||||||
|
-- 목적: researchqa 프로젝트용 데이터베이스 설정
|
||||||
|
|
||||||
|
-- 1. woonglab 사용자 생성 (이미 존재할 경우 무시)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'woonglab') THEN
|
||||||
|
CREATE USER woonglab WITH PASSWORD '!@#woonglab';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- 2. researchqa 데이터베이스 생성 (이미 존재할 경우 무시)
|
||||||
|
SELECT 'CREATE DATABASE researchqa OWNER woonglab'
|
||||||
|
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'researchqa')\gexec
|
||||||
|
|
||||||
|
-- 3. researchqa 데이터베이스에 대한 권한 부여
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE researchqa TO woonglab;
|
||||||
|
|
||||||
|
-- 4. researchqa 데이터베이스에 연결하여 테이블 생성
|
||||||
|
\c researchqa;
|
||||||
|
|
||||||
|
-- 5. files 테이블 생성 (main.py와 일치)
|
||||||
|
CREATE TABLE uploaded_file (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
file_path VARCHAR(500) NOT NULL,
|
||||||
|
status VARCHAR(10) NOT NULL,
|
||||||
|
upload_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 6. user_log 테이블 생성
|
||||||
|
CREATE TABLE IF NOT EXISTS user_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
answer TEXT NOT NULL,
|
||||||
|
like_count INTEGER DEFAULT 0,
|
||||||
|
dislike_count INTEGER DEFAULT 0,
|
||||||
|
ip VARCHAR(45),
|
||||||
|
reg_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 7. woonglab 사용자에게 테이블 권한 부여
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO woonglab;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO woonglab;
|
||||||
|
|
||||||
|
-- 8. 생성된 테이블 확인
|
||||||
|
\dt
|
||||||
|
|
||||||
|
-- 9. 테이블 구조 확인
|
||||||
|
\d files
|
||||||
|
\d user_log
|
||||||
|
|
||||||
|
-- 스크립트 실행 완료 메시지
|
||||||
|
SELECT 'Database setup completed successfully!' as message;
|
||||||
45
frontend/package.json
Normal file
45
frontend/package.json
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "researchqa-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"typescript": "^4.9.5",
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/react-dom": "^18.2.0",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"framer-motion": "^10.16.0",
|
||||||
|
"lucide-react": "^0.294.0",
|
||||||
|
"react-pdf": "^10.1.0",
|
||||||
|
"pdfjs-dist": "^5.3.93",
|
||||||
|
"tailwindcss": "^3.3.0",
|
||||||
|
"autoprefixer": "^10.4.0",
|
||||||
|
"postcss": "^8.4.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
333
frontend/public/AnnotationLayer.css
Normal file
333
frontend/public/AnnotationLayer.css
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
/* Copyright 2014 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--react-pdf-annotation-layer: 1;
|
||||||
|
--annotation-unfocused-field-background: url("data:image/svg+xml;charset=UTF-8,<svg width='1px' height='1px' xmlns='http://www.w3.org/2000/svg'><rect width='100%' height='100%' style='fill:rgba(0, 54, 255, 0.13);'/></svg>");
|
||||||
|
--input-focus-border-color: Highlight;
|
||||||
|
--input-focus-outline: 1px solid Canvas;
|
||||||
|
--input-unfocused-border-color: transparent;
|
||||||
|
--input-disabled-border-color: transparent;
|
||||||
|
--input-hover-border-color: black;
|
||||||
|
--link-outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (forced-colors: active) {
|
||||||
|
:root {
|
||||||
|
--input-focus-border-color: CanvasText;
|
||||||
|
--input-unfocused-border-color: ActiveText;
|
||||||
|
--input-disabled-border-color: GrayText;
|
||||||
|
--input-hover-border-color: Highlight;
|
||||||
|
--link-outline: 1.5px solid LinkText;
|
||||||
|
}
|
||||||
|
.annotationLayer .textWidgetAnnotation :is(input, textarea):required,
|
||||||
|
.annotationLayer .choiceWidgetAnnotation select:required,
|
||||||
|
.annotationLayer .buttonWidgetAnnotation:is(.checkBox, .radioButton) input:required {
|
||||||
|
outline: 1.5px solid selectedItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .linkAnnotation:hover {
|
||||||
|
backdrop-filter: invert(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer[data-main-rotation='90'] .norotate {
|
||||||
|
transform: rotate(270deg) translateX(-100%);
|
||||||
|
}
|
||||||
|
.annotationLayer[data-main-rotation='180'] .norotate {
|
||||||
|
transform: rotate(180deg) translate(-100%, -100%);
|
||||||
|
}
|
||||||
|
.annotationLayer[data-main-rotation='270'] .norotate {
|
||||||
|
transform: rotate(90deg) translateY(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer canvas {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer section {
|
||||||
|
position: absolute;
|
||||||
|
text-align: initial;
|
||||||
|
pointer-events: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .linkAnnotation {
|
||||||
|
outline: var(--link-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textLayer.selecting ~ .annotationLayer section {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer :is(.linkAnnotation, .buttonWidgetAnnotation.pushButton) > a {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 1em;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer :is(.linkAnnotation, .buttonWidgetAnnotation.pushButton) > a:hover {
|
||||||
|
opacity: 0.2;
|
||||||
|
background: rgba(255, 255, 0, 1);
|
||||||
|
box-shadow: 0 2px 10px rgba(255, 255, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .textAnnotation img {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .textWidgetAnnotation :is(input, textarea),
|
||||||
|
.annotationLayer .choiceWidgetAnnotation select,
|
||||||
|
.annotationLayer .buttonWidgetAnnotation:is(.checkBox, .radioButton) input {
|
||||||
|
background-image: var(--annotation-unfocused-field-background);
|
||||||
|
border: 2px solid var(--input-unfocused-border-color);
|
||||||
|
box-sizing: border-box;
|
||||||
|
font: calc(9px * var(--total-scale-factor)) sans-serif;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .textWidgetAnnotation :is(input, textarea):required,
|
||||||
|
.annotationLayer .choiceWidgetAnnotation select:required,
|
||||||
|
.annotationLayer .buttonWidgetAnnotation:is(.checkBox, .radioButton) input:required {
|
||||||
|
outline: 1.5px solid red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .choiceWidgetAnnotation select option {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.radioButton input {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .textWidgetAnnotation textarea {
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .textWidgetAnnotation :is(input, textarea)[disabled],
|
||||||
|
.annotationLayer .choiceWidgetAnnotation select[disabled],
|
||||||
|
.annotationLayer .buttonWidgetAnnotation:is(.checkBox, .radioButton) input[disabled] {
|
||||||
|
background: none;
|
||||||
|
border: 2px solid var(--input-disabled-border-color);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .textWidgetAnnotation :is(input, textarea):hover,
|
||||||
|
.annotationLayer .choiceWidgetAnnotation select:hover,
|
||||||
|
.annotationLayer .buttonWidgetAnnotation:is(.checkBox, .radioButton) input:hover {
|
||||||
|
border: 2px solid var(--input-hover-border-color);
|
||||||
|
}
|
||||||
|
.annotationLayer .textWidgetAnnotation :is(input, textarea):hover,
|
||||||
|
.annotationLayer .choiceWidgetAnnotation select:hover,
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.checkBox input:hover {
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .textWidgetAnnotation :is(input, textarea):focus,
|
||||||
|
.annotationLayer .choiceWidgetAnnotation select:focus {
|
||||||
|
background: none;
|
||||||
|
border: 2px solid var(--input-focus-border-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: var(--input-focus-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .buttonWidgetAnnotation:is(.checkBox, .radioButton) :focus {
|
||||||
|
background-image: none;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.checkBox :focus {
|
||||||
|
border: 2px solid var(--input-focus-border-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: var(--input-focus-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.radioButton :focus {
|
||||||
|
border: 2px solid var(--input-focus-border-color);
|
||||||
|
outline: var(--input-focus-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::before,
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::after,
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.radioButton input:checked::before {
|
||||||
|
background-color: CanvasText;
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::before,
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::after {
|
||||||
|
height: 80%;
|
||||||
|
left: 45%;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::before {
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::after {
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.radioButton input:checked::before {
|
||||||
|
border-radius: 50%;
|
||||||
|
height: 50%;
|
||||||
|
left: 30%;
|
||||||
|
top: 20%;
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .textWidgetAnnotation input.comb {
|
||||||
|
font-family: monospace;
|
||||||
|
padding-left: 2px;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .textWidgetAnnotation input.comb:focus {
|
||||||
|
/*
|
||||||
|
* Letter spacing is placed on the right side of each character. Hence, the
|
||||||
|
* letter spacing of the last character may be placed outside the visible
|
||||||
|
* area, causing horizontal scrolling. We avoid this by extending the width
|
||||||
|
* when the element has focus and revert this when it loses focus.
|
||||||
|
*/
|
||||||
|
width: 103%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .buttonWidgetAnnotation:is(.checkBox, .radioButton) input {
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .popupTriggerArea {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .fileAttachmentAnnotation .popupTriggerArea {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .popupWrapper {
|
||||||
|
position: absolute;
|
||||||
|
font-size: calc(9px * var(--total-scale-factor));
|
||||||
|
width: 100%;
|
||||||
|
min-width: calc(180px * var(--total-scale-factor));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .popup {
|
||||||
|
position: absolute;
|
||||||
|
max-width: calc(180px * var(--total-scale-factor));
|
||||||
|
background-color: rgba(255, 255, 153, 1);
|
||||||
|
box-shadow: 0 calc(2px * var(--total-scale-factor)) calc(5px * var(--total-scale-factor))
|
||||||
|
rgba(136, 136, 136, 1);
|
||||||
|
border-radius: calc(2px * var(--total-scale-factor));
|
||||||
|
padding: calc(6px * var(--total-scale-factor));
|
||||||
|
margin-left: calc(5px * var(--total-scale-factor));
|
||||||
|
cursor: pointer;
|
||||||
|
font: message-box;
|
||||||
|
white-space: normal;
|
||||||
|
word-wrap: break-word;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .popup > * {
|
||||||
|
font-size: calc(9px * var(--total-scale-factor));
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .popup h1 {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .popupDate {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: calc(5px * var(--total-scale-factor));
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .popupContent {
|
||||||
|
border-top: 1px solid rgba(51, 51, 51, 1);
|
||||||
|
margin-top: calc(2px * var(--total-scale-factor));
|
||||||
|
padding-top: calc(2px * var(--total-scale-factor));
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .richText > * {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: calc(9px * var(--total-scale-factor));
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .highlightAnnotation,
|
||||||
|
.annotationLayer .underlineAnnotation,
|
||||||
|
.annotationLayer .squigglyAnnotation,
|
||||||
|
.annotationLayer .strikeoutAnnotation,
|
||||||
|
.annotationLayer .freeTextAnnotation,
|
||||||
|
.annotationLayer .lineAnnotation svg line,
|
||||||
|
.annotationLayer .squareAnnotation svg rect,
|
||||||
|
.annotationLayer .circleAnnotation svg ellipse,
|
||||||
|
.annotationLayer .polylineAnnotation svg polyline,
|
||||||
|
.annotationLayer .polygonAnnotation svg polygon,
|
||||||
|
.annotationLayer .caretAnnotation,
|
||||||
|
.annotationLayer .inkAnnotation svg polyline,
|
||||||
|
.annotationLayer .stampAnnotation,
|
||||||
|
.annotationLayer .fileAttachmentAnnotation {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer section svg {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .annotationTextContent {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
color: transparent;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .annotationTextContent span {
|
||||||
|
width: 100%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
119
frontend/public/TextLayer.css
Normal file
119
frontend/public/TextLayer.css
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
/* Copyright 2014 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--react-pdf-text-layer: 1;
|
||||||
|
--highlight-bg-color: rgba(180, 0, 170, 1);
|
||||||
|
--highlight-selected-bg-color: rgba(0, 100, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (forced-colors: active) {
|
||||||
|
:root {
|
||||||
|
--highlight-bg-color: Highlight;
|
||||||
|
--highlight-selected-bg-color: ButtonText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-main-rotation='90'] {
|
||||||
|
transform: rotate(90deg) translateY(-100%);
|
||||||
|
}
|
||||||
|
[data-main-rotation='180'] {
|
||||||
|
transform: rotate(180deg) translate(-100%, -100%);
|
||||||
|
}
|
||||||
|
[data-main-rotation='270'] {
|
||||||
|
transform: rotate(270deg) translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textLayer {
|
||||||
|
position: absolute;
|
||||||
|
text-align: initial;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1;
|
||||||
|
text-size-adjust: none;
|
||||||
|
forced-color-adjust: none;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textLayer :is(span, br) {
|
||||||
|
color: transparent;
|
||||||
|
position: absolute;
|
||||||
|
white-space: pre;
|
||||||
|
cursor: text;
|
||||||
|
margin: 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only necessary in Google Chrome, see issue 14205, and most unfortunately
|
||||||
|
* the problem doesn't show up in "text" reference tests. */
|
||||||
|
.textLayer span.markedContent {
|
||||||
|
top: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textLayer .highlight {
|
||||||
|
margin: -1px;
|
||||||
|
padding: 1px;
|
||||||
|
background-color: var(--highlight-bg-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textLayer .highlight.appended {
|
||||||
|
position: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textLayer .highlight.begin {
|
||||||
|
border-radius: 4px 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textLayer .highlight.end {
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textLayer .highlight.middle {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textLayer .highlight.selected {
|
||||||
|
background-color: var(--highlight-selected-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avoids https://github.com/mozilla/pdf.js/issues/13840 in Chrome */
|
||||||
|
.textLayer br::selection {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textLayer .endOfContent {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
inset: 100% 0 0;
|
||||||
|
z-index: -1;
|
||||||
|
cursor: default;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textLayer.selecting .endOfContent {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiddenCanvasElement {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
BIN
frontend/public/images/dw_icon.png
Normal file
BIN
frontend/public/images/dw_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
BIN
frontend/public/images/woongtalk.png
Normal file
BIN
frontend/public/images/woongtalk.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 897 KiB |
BIN
frontend/public/images/woongtalk_bgremove.png
Normal file
BIN
frontend/public/images/woongtalk_bgremove.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
20
frontend/public/index.html
Normal file
20
frontend/public/index.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="연구QA Chatbot - AI 기반 연구 문서 분석 도우미"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<title>연구QA Chatbot</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>JavaScript를 활성화해주세요.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
frontend/public/pdf.worker.min.js
vendored
Normal file
29
frontend/public/pdf.worker.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
99
frontend/src/App.tsx
Normal file
99
frontend/src/App.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import ChatInterface from './components/ChatInterface';
|
||||||
|
import LoginModal from './components/LoginModal';
|
||||||
|
import FileUploadModal from './components/FileUploadModal';
|
||||||
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||||
|
import { ChatProvider } from './contexts/ChatContext';
|
||||||
|
import { FileProvider } from './contexts/FileContext';
|
||||||
|
import { Upload } from 'lucide-react';
|
||||||
|
|
||||||
|
function AppContent() {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const [showLogin, setShowLogin] = useState(false);
|
||||||
|
const [showFileUpload, setShowFileUpload] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
|
const handleFileUploadClick = () => {
|
||||||
|
// 로그인 상태 확인
|
||||||
|
if (isAuthenticated) {
|
||||||
|
// 이미 로그인되어 있으면 파일 업로드 모달 열기
|
||||||
|
setShowFileUpload(true);
|
||||||
|
} else {
|
||||||
|
// 로그인되지 않았으면 로그인 모달 열기
|
||||||
|
setShowLogin(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 flex flex-col">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<header className="bg-white/80 backdrop-blur-md border-b border-gray-200/50 flex-shrink-0">
|
||||||
|
<div className="max-w-full mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center h-16">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<img
|
||||||
|
src="/images/dw_icon.png"
|
||||||
|
alt="연구QA 아이콘"
|
||||||
|
className="w-8 h-8"
|
||||||
|
/>
|
||||||
|
<h1 className="text-2xl font-bold gradient-text">
|
||||||
|
연구QA Chatbot
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
onClick={handleFileUploadClick}
|
||||||
|
className="p-2 rounded-lg bg-blue-100 hover:bg-blue-200 transition-colors"
|
||||||
|
title="파일 업로드"
|
||||||
|
>
|
||||||
|
<Upload className="w-6 h-6 text-blue-600" />
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 메인 컨텐츠 - 전체 화면 챗봇 */}
|
||||||
|
<main className="flex-1 flex flex-col">
|
||||||
|
<ChatInterface />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* 모달들 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showLogin && (
|
||||||
|
<LoginModal
|
||||||
|
onClose={() => setShowLogin(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowLogin(false);
|
||||||
|
setShowFileUpload(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showFileUpload && (
|
||||||
|
<FileUploadModal
|
||||||
|
onClose={() => setShowFileUpload(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<ChatProvider>
|
||||||
|
<FileProvider>
|
||||||
|
<AppContent />
|
||||||
|
</FileProvider>
|
||||||
|
</ChatProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
174
frontend/src/components/ChatInterface.tsx
Normal file
174
frontend/src/components/ChatInterface.tsx
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useChat } from '../contexts/ChatContext';
|
||||||
|
import MessageBubble from './MessageBubble';
|
||||||
|
import TypingIndicator from './TypingIndicator';
|
||||||
|
import { Send } from 'lucide-react';
|
||||||
|
|
||||||
|
const ChatInterface: React.FC = () => {
|
||||||
|
const { messages, addMessage, isLoading, setIsLoading } = useChat();
|
||||||
|
const [inputMessage, setInputMessage] = useState('');
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const handleSendMessage = async () => {
|
||||||
|
if (!inputMessage.trim() || isLoading) return;
|
||||||
|
|
||||||
|
console.log('💬 챗봇 메시지 전송 시작:', inputMessage.trim());
|
||||||
|
|
||||||
|
const userMessage = inputMessage.trim();
|
||||||
|
setInputMessage('');
|
||||||
|
addMessage({ content: userMessage, isUser: true });
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('📤 챗봇 API 요청 전송 중...');
|
||||||
|
console.log('📋 요청 URL: http://localhost:8000/chat');
|
||||||
|
console.log('📋 요청 메시지:', userMessage);
|
||||||
|
|
||||||
|
// 인증 없이 요청 (챗봇은 누구나 사용 가능)
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('📋 인증 없이 챗봇 요청합니다.');
|
||||||
|
|
||||||
|
const response = await fetch('http://localhost:8000/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ message: userMessage }),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('📥 챗봇 API 응답 받음');
|
||||||
|
console.log('📋 응답 상태:', response.status, response.statusText);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log('✅ 챗봇 API 응답 성공');
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('📋 응답 데이터:', data);
|
||||||
|
console.log('📋 응답 내용:', data.response);
|
||||||
|
console.log('📋 참조 문서:', data.sources);
|
||||||
|
|
||||||
|
addMessage({
|
||||||
|
content: data.response,
|
||||||
|
isUser: false,
|
||||||
|
sources: data.sources,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('💬 챗봇 메시지 추가 완료');
|
||||||
|
} else {
|
||||||
|
console.log('❌ 챗봇 API 오류:', response.status, response.statusText);
|
||||||
|
addMessage({
|
||||||
|
content: '죄송합니다. 응답을 생성하는 중 오류가 발생했습니다.',
|
||||||
|
isUser: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 챗봇 응답 실패');
|
||||||
|
console.error('📋 오류 타입:', error instanceof Error ? error.name : typeof error);
|
||||||
|
console.error('📋 오류 메시지:', error instanceof Error ? error.message : String(error));
|
||||||
|
console.error('📋 전체 오류:', error);
|
||||||
|
|
||||||
|
addMessage({
|
||||||
|
content: '네트워크 오류가 발생했습니다. 다시 시도해주세요.',
|
||||||
|
isUser: false,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
console.log('🏁 챗봇 요청 완료');
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSendMessage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col h-full">
|
||||||
|
{/* 메시지 영역 */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-16 bg-white">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6 overflow-hidden">
|
||||||
|
<img
|
||||||
|
src="/images/woongtalk_bgremove.png"
|
||||||
|
alt="연구QA Chatbot"
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-semibold text-gray-700 mb-3">
|
||||||
|
안녕하세요! 연구QA Chatbot입니다
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 text-lg">
|
||||||
|
업로드된 문서에 대해 질문해보세요. AI가 답변을 도와드립니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full max-w-none mx-auto px-4" style={{ maxWidth: '80vw' }}>
|
||||||
|
<AnimatePresence>
|
||||||
|
{messages.map((message) => (
|
||||||
|
<motion.div
|
||||||
|
key={message.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<MessageBubble message={message} />
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{isLoading && <TypingIndicator />}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 입력 영역 */}
|
||||||
|
<div className="bg-white border-t border-gray-200 p-4">
|
||||||
|
<div className="w-full max-w-none mx-auto px-4" style={{ maxWidth: '80vw' }}>
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={inputMessage}
|
||||||
|
onChange={(e) => setInputMessage(e.target.value)}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
placeholder="질문을 입력하세요..."
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
disabled={!inputMessage.trim() || isLoading}
|
||||||
|
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-xl font-medium disabled:opacity-50 disabled:cursor-not-allowed transition-all btn-animate"
|
||||||
|
>
|
||||||
|
<Send className="w-5 h-5" />
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatInterface;
|
||||||
537
frontend/src/components/FileUploadModal.tsx
Normal file
537
frontend/src/components/FileUploadModal.tsx
Normal file
@ -0,0 +1,537 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useFiles } from '../contexts/FileContext';
|
||||||
|
import { X, Upload, Trash2, Search } from 'lucide-react';
|
||||||
|
|
||||||
|
interface FileUploadModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileUploadModal: React.FC<FileUploadModalProps> = ({ onClose }) => {
|
||||||
|
const { files, uploadFile, deleteFile, refreshFiles, searchFiles, isLoading } = useFiles();
|
||||||
|
const [dragActive, setDragActive] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [uploadStatus, setUploadStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||||
|
const [uploadMessage, setUploadMessage] = useState('');
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [lastSearchTerm, setLastSearchTerm] = useState('');
|
||||||
|
const [tooltip, setTooltip] = useState<{ show: boolean; content: string; x: number; y: number }>({
|
||||||
|
show: false,
|
||||||
|
content: '',
|
||||||
|
x: 0,
|
||||||
|
y: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// 순차적 업로드를 위한 상태
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState<{
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
currentFile: string;
|
||||||
|
progress: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 순차적 업로드 함수
|
||||||
|
const uploadFilesSequentially = async (files: File[]) => {
|
||||||
|
setIsUploading(true);
|
||||||
|
setUploadProgress({
|
||||||
|
current: 0,
|
||||||
|
total: files.length,
|
||||||
|
currentFile: files[0].name,
|
||||||
|
progress: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
|
||||||
|
setUploadProgress({
|
||||||
|
current: i + 1,
|
||||||
|
total: files.length,
|
||||||
|
currentFile: file.name,
|
||||||
|
progress: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 개별 파일 업로드
|
||||||
|
const result = await uploadFile(file);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 진행률 업데이트
|
||||||
|
setUploadProgress(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
progress: ((i + 1) / files.length) * 100
|
||||||
|
} : null);
|
||||||
|
|
||||||
|
// 파일 간 짧은 지연 (UI 업데이트를 위해)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`파일 업로드 실패: ${file.name}`, error);
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(false);
|
||||||
|
setUploadProgress(null);
|
||||||
|
|
||||||
|
// 결과 메시지 표시
|
||||||
|
if (successCount > 0 && errorCount === 0) {
|
||||||
|
setUploadStatus('success');
|
||||||
|
setUploadMessage(`${successCount}개 파일이 성공적으로 업로드되었습니다.`);
|
||||||
|
} else if (successCount > 0 && errorCount > 0) {
|
||||||
|
setUploadStatus('error');
|
||||||
|
setUploadMessage(`${successCount}개 성공, ${errorCount}개 실패`);
|
||||||
|
} else {
|
||||||
|
setUploadStatus('error');
|
||||||
|
setUploadMessage('모든 파일 업로드에 실패했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => setUploadStatus('idle'), 3000);
|
||||||
|
|
||||||
|
// 파일 목록 새로고침
|
||||||
|
await refreshFiles();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 검색 실행 함수
|
||||||
|
const handleSearch = async () => {
|
||||||
|
console.log('검색 실행:', searchTerm);
|
||||||
|
setIsSearching(true);
|
||||||
|
setLastSearchTerm(searchTerm);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (searchTerm.trim()) {
|
||||||
|
await searchFiles(searchTerm);
|
||||||
|
} else {
|
||||||
|
await refreshFiles(); // 검색어가 비어있으면 전체 목록 조회
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('검색 실패:', error);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 키보드 이벤트 핸들러
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 입력 필드 변경 핸들러 (백스페이스, Delete 키 처리)
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setSearchTerm(newValue);
|
||||||
|
|
||||||
|
// 검색어가 비어있으면 즉시 전체 목록 조회
|
||||||
|
if (newValue.trim() === '') {
|
||||||
|
console.log('검색창이 비어있음 - 전체 파일 목록 조회');
|
||||||
|
setLastSearchTerm(''); // 검색어 초기화
|
||||||
|
setIsSearching(false); // 검색 상태 초기화
|
||||||
|
refreshFiles(); // 전체 파일 목록 조회
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrag = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// 업로드 중일 때는 드래그 앤 드롭 비활성화
|
||||||
|
if (isUploading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||||
|
setDragActive(true);
|
||||||
|
} else if (e.type === 'dragleave') {
|
||||||
|
setDragActive(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = async (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragActive(false);
|
||||||
|
|
||||||
|
// 업로드 중일 때는 드롭 비활성화
|
||||||
|
if (isUploading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||||
|
const files = Array.from(e.dataTransfer.files);
|
||||||
|
const pdfFiles = files.filter(file => file.type === 'application/pdf');
|
||||||
|
|
||||||
|
// PDF 파일만 허용
|
||||||
|
if (pdfFiles.length === 0) {
|
||||||
|
setUploadStatus('error');
|
||||||
|
setUploadMessage('PDF 파일만 업로드 가능합니다.');
|
||||||
|
setTimeout(() => setUploadStatus('idle'), 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 개수 제한 (5개 이내)
|
||||||
|
if (pdfFiles.length > 5) {
|
||||||
|
setUploadStatus('error');
|
||||||
|
setUploadMessage('최대 5개 파일까지만 업로드 가능합니다.');
|
||||||
|
setTimeout(() => setUploadStatus('idle'), 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pdfFiles.length !== files.length) {
|
||||||
|
setUploadStatus('error');
|
||||||
|
setUploadMessage(`${files.length - pdfFiles.length}개의 비PDF 파일이 제외되었습니다.`);
|
||||||
|
setTimeout(() => setUploadStatus('idle'), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 순차적 업로드 실행
|
||||||
|
await uploadFilesSequentially(pdfFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 드래그 앤 드롭 후에도 input value 초기화
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
// 업로드 중일 때는 파일 선택 비활성화
|
||||||
|
if (isUploading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
const files = Array.from(e.target.files);
|
||||||
|
const pdfFiles = files.filter(file => file.type === 'application/pdf');
|
||||||
|
|
||||||
|
// PDF 파일만 허용
|
||||||
|
if (pdfFiles.length === 0) {
|
||||||
|
setUploadStatus('error');
|
||||||
|
setUploadMessage('PDF 파일만 업로드 가능합니다.');
|
||||||
|
setTimeout(() => setUploadStatus('idle'), 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 개수 제한 (5개 이내)
|
||||||
|
if (pdfFiles.length > 5) {
|
||||||
|
setUploadStatus('error');
|
||||||
|
setUploadMessage('최대 5개 파일까지만 업로드 가능합니다.');
|
||||||
|
setTimeout(() => setUploadStatus('idle'), 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pdfFiles.length !== files.length) {
|
||||||
|
setUploadStatus('error');
|
||||||
|
setUploadMessage(`${files.length - pdfFiles.length}개의 비PDF 파일이 제외되었습니다.`);
|
||||||
|
setTimeout(() => setUploadStatus('idle'), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 순차적 업로드 실행
|
||||||
|
await uploadFilesSequentially(pdfFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 선택 후 input value 초기화 (동일한 파일 재선택 가능하도록)
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteFile = async (fileId: string) => {
|
||||||
|
if (window.confirm('정말로 이 파일을 삭제하시겠습니까?')) {
|
||||||
|
await deleteFile(fileId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showTooltip = (content: string, e: React.MouseEvent) => {
|
||||||
|
setTooltip({
|
||||||
|
show: true,
|
||||||
|
content,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideTooltip = () => {
|
||||||
|
setTooltip({
|
||||||
|
show: false,
|
||||||
|
content: '',
|
||||||
|
x: 0,
|
||||||
|
y: 0
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// 서버에서 이미 필터링된 파일 목록을 사용
|
||||||
|
const filteredFiles = files;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
className="bg-white rounded-2xl shadow-2xl w-full max-w-6xl max-h-[95vh] overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800">PDF 업로드 및 관리</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* 업로드 진행 상태 */}
|
||||||
|
{uploadProgress && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center space-x-3 mb-3">
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500"></div>
|
||||||
|
<span className="text-blue-700 font-medium">
|
||||||
|
파일 업로드 중입니다... ({uploadProgress.current}/{uploadProgress.total})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-blue-600 mb-2">
|
||||||
|
현재 파일: {uploadProgress.currentFile}
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-blue-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${uploadProgress.progress}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 업로드 중일 때 전체 메시지 */}
|
||||||
|
{isUploading && !uploadProgress && (
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-yellow-500"></div>
|
||||||
|
<span className="text-yellow-700 font-medium">
|
||||||
|
파일 업로드 중입니다....
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 업로드 상태 메시지 */}
|
||||||
|
{uploadStatus !== 'idle' && !uploadProgress && (
|
||||||
|
<div className={`p-4 rounded-lg ${
|
||||||
|
uploadStatus === 'success'
|
||||||
|
? 'bg-green-50 border border-green-200 text-green-700'
|
||||||
|
: 'bg-red-50 border border-red-200 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{uploadMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 파일 업로드 영역 */}
|
||||||
|
<div
|
||||||
|
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
|
||||||
|
isUploading
|
||||||
|
? 'border-gray-200 bg-gray-50 cursor-not-allowed'
|
||||||
|
: dragActive
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
}`}
|
||||||
|
onDragEnter={handleDrag}
|
||||||
|
onDragLeave={handleDrag}
|
||||||
|
onDragOver={handleDrag}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<Upload className={`w-12 h-12 mx-auto mb-4 ${isUploading ? 'text-gray-300' : 'text-gray-400'}`} />
|
||||||
|
<h3 className={`text-lg font-medium mb-2 ${isUploading ? 'text-gray-400' : 'text-gray-700'}`}>
|
||||||
|
{isUploading ? '업로드 중입니다...' : 'PDF 파일을 드래그하거나 클릭하여 업로드'}
|
||||||
|
</h3>
|
||||||
|
<p className={`mb-4 ${isUploading ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||||
|
{isUploading ? '다른 파일 업로드는 현재 불가능합니다' : '최대 5개의 PDF 파일을 순차적으로 업로드할 수 있습니다'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={isLoading || isUploading}
|
||||||
|
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed transition-all btn-animate"
|
||||||
|
>
|
||||||
|
{isUploading ? '업로드 중...' : '파일 선택'}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".pdf"
|
||||||
|
multiple
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 및 새로고침 */}
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleKeyPress}
|
||||||
|
placeholder="문서명으로 검색... (엔터키 또는 검색어 삭제 시 자동 검색)"
|
||||||
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색/새로고침 버튼 (돋보기 아이콘) */}
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={isSearching}
|
||||||
|
className={`px-4 py-3 rounded-lg transition-colors ${
|
||||||
|
isSearching
|
||||||
|
? 'bg-blue-100 text-blue-600 cursor-not-allowed'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
title={isSearching ? "검색 중..." : "검색 및 새로고침"}
|
||||||
|
>
|
||||||
|
<Search className={`w-5 h-5 ${isSearching ? 'animate-pulse' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 상태 및 결과 정보 */}
|
||||||
|
<div className="text-sm text-gray-600 bg-blue-50 p-3 rounded-lg">
|
||||||
|
{isSearching ? (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Search className="w-4 h-4 animate-pulse" />
|
||||||
|
<span>검색 중...</span>
|
||||||
|
</div>
|
||||||
|
) : lastSearchTerm ? (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">검색 결과: "{lastSearchTerm}"</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
{filteredFiles.length}개의 파일이 검색되었습니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">전체 파일 목록</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
{files.length}개의 파일이 등록되어 있습니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 파일 목록 */}
|
||||||
|
<div className="border border-gray-200 rounded-xl overflow-hidden">
|
||||||
|
<div className="bg-gray-50 px-6 py-3 border-b border-gray-200">
|
||||||
|
<div className="grid grid-cols-12 gap-4 text-sm font-medium text-gray-700">
|
||||||
|
<div className="col-span-1">ID</div>
|
||||||
|
<div className="col-span-5">문서명</div>
|
||||||
|
<div className="col-span-3">업로드 날짜</div>
|
||||||
|
<div className="col-span-1">파일 타입</div>
|
||||||
|
<div className="col-span-1">상태</div>
|
||||||
|
<div className="col-span-1">작업</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-64 overflow-y-auto">
|
||||||
|
{filteredFiles.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
{lastSearchTerm ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-medium mb-2">검색 결과가 없습니다</div>
|
||||||
|
<div className="text-sm">"{lastSearchTerm}"에 해당하는 파일을 찾을 수 없습니다.</div>
|
||||||
|
<div className="text-xs mt-2 text-gray-400">다른 검색어를 시도해보세요.</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-medium mb-2">등록된 문서가 없습니다</div>
|
||||||
|
<div className="text-sm">PDF 파일을 업로드하여 시작하세요.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredFiles.map((file) => (
|
||||||
|
<motion.div
|
||||||
|
key={file.id}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="grid grid-cols-12 gap-4 px-6 py-4 border-b border-gray-100 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="col-span-1 text-sm text-gray-600 cursor-help"
|
||||||
|
onMouseEnter={(e) => showTooltip(file.id, e)}
|
||||||
|
onMouseLeave={hideTooltip}
|
||||||
|
onMouseMove={(e) => showTooltip(file.id, e)}
|
||||||
|
>
|
||||||
|
{file.id}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="col-span-5 text-sm text-gray-800 truncate cursor-help"
|
||||||
|
onMouseEnter={(e) => showTooltip(file.filename, e)}
|
||||||
|
onMouseLeave={hideTooltip}
|
||||||
|
onMouseMove={(e) => showTooltip(file.filename, e)}
|
||||||
|
>
|
||||||
|
{file.filename}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-3 text-sm text-gray-600">
|
||||||
|
{file.upload_date}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 text-sm text-gray-600">
|
||||||
|
{file.file_type}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 text-sm">
|
||||||
|
<span className="px-2 py-1 bg-green-100 text-green-800 rounded-full text-xs">
|
||||||
|
{file.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteFile(file.id)}
|
||||||
|
className="p-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 툴팁 */}
|
||||||
|
{tooltip.show && (
|
||||||
|
<div
|
||||||
|
className="fixed z-[60] bg-gray-900 text-white text-sm px-3 py-2 rounded-lg shadow-lg pointer-events-none max-w-xs break-words"
|
||||||
|
style={{
|
||||||
|
left: tooltip.x + 10,
|
||||||
|
top: tooltip.y - 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tooltip.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileUploadModal;
|
||||||
135
frontend/src/components/LoginModal.tsx
Normal file
135
frontend/src/components/LoginModal.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { X, Lock, User } from 'lucide-react';
|
||||||
|
|
||||||
|
interface LoginModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoginModal: React.FC<LoginModalProps> = ({ onClose, onSuccess }) => {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [username, setUsername] = useState('admin');
|
||||||
|
const [password, setPassword] = useState('researchqa');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await login(username, password);
|
||||||
|
if (success) {
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
setError('아이디 또는 비밀번호가 올바르지 않습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError('로그인 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
className="bg-white rounded-2xl shadow-2xl w-full max-w-md"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800">로그인</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 폼 */}
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="bg-red-50 border border-red-200 rounded-lg p-3 text-red-600 text-sm"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
아이디
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
|
||||||
|
placeholder="아이디를 입력하세요"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
비밀번호
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
|
||||||
|
placeholder="비밀번호를 입력하세요"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 px-4 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !username || !password}
|
||||||
|
className="flex-1 px-4 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed transition-all btn-animate"
|
||||||
|
>
|
||||||
|
{isLoading ? '로그인 중...' : '로그인'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginModal;
|
||||||
160
frontend/src/components/MessageBubble.tsx
Normal file
160
frontend/src/components/MessageBubble.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { User, Bot, FileText } from 'lucide-react';
|
||||||
|
import { Message } from '../contexts/ChatContext';
|
||||||
|
import PDFViewer from './PDFViewer';
|
||||||
|
|
||||||
|
interface MessageBubbleProps {
|
||||||
|
message: Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SourceInfo {
|
||||||
|
filename: string;
|
||||||
|
pageNumber: number;
|
||||||
|
fileId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => {
|
||||||
|
const isUser = message.isUser;
|
||||||
|
const [selectedPage, setSelectedPage] = useState<{fileId: string, filename: string, pageNumber: number} | null>(null);
|
||||||
|
|
||||||
|
// 소스 문자열을 파싱하여 파일명, file_id, 페이지 번호 추출
|
||||||
|
const parseSource = (source: string): SourceInfo | null => {
|
||||||
|
const match = source.match(/^(.+?)::(.+?)\s*\[p(\d+)\]$/);
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
filename: match[1],
|
||||||
|
fileId: match[2],
|
||||||
|
pageNumber: parseInt(match[3])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 참조 문서를 파일명별로 그룹화
|
||||||
|
const groupSourcesByFile = (sources: string[]) => {
|
||||||
|
const grouped: { [filename: string]: { fileId: string; pages: number[] } } = {};
|
||||||
|
|
||||||
|
sources.forEach(source => {
|
||||||
|
const sourceInfo = parseSource(source);
|
||||||
|
if (sourceInfo) {
|
||||||
|
if (!grouped[sourceInfo.filename]) {
|
||||||
|
grouped[sourceInfo.filename] = {
|
||||||
|
fileId: sourceInfo.fileId,
|
||||||
|
pages: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!grouped[sourceInfo.filename].pages.includes(sourceInfo.pageNumber)) {
|
||||||
|
grouped[sourceInfo.filename].pages.push(sourceInfo.pageNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 페이지 번호 정렬
|
||||||
|
Object.keys(grouped).forEach(filename => {
|
||||||
|
grouped[filename].pages.sort((a, b) => a - b);
|
||||||
|
});
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSourceClick = (source: string) => {
|
||||||
|
const sourceInfo = parseSource(source);
|
||||||
|
if (!sourceInfo) return;
|
||||||
|
|
||||||
|
// 파싱된 정보를 직접 사용
|
||||||
|
setSelectedPage({
|
||||||
|
fileId: sourceInfo.fileId,
|
||||||
|
filename: sourceInfo.filename,
|
||||||
|
pageNumber: sourceInfo.pageNumber
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-6`}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className={`flex items-start space-x-3 ${
|
||||||
|
isUser
|
||||||
|
? 'flex-row-reverse space-x-reverse w-full max-w-none'
|
||||||
|
: 'w-full'
|
||||||
|
}`}
|
||||||
|
style={isUser ? { maxWidth: '60vw' } : {}}
|
||||||
|
>
|
||||||
|
{/* 아바타 */}
|
||||||
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||||
|
isUser
|
||||||
|
? 'bg-gradient-to-r from-blue-500 to-purple-500'
|
||||||
|
: 'bg-gradient-to-r from-gray-500 to-gray-600'
|
||||||
|
}`}>
|
||||||
|
{isUser ? (
|
||||||
|
<User className="w-4 h-4 text-white" />
|
||||||
|
) : (
|
||||||
|
<Bot className="w-4 h-4 text-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메시지 내용 */}
|
||||||
|
<div className={`rounded-2xl px-4 py-3 flex-1 ${
|
||||||
|
isUser
|
||||||
|
? 'bg-gradient-to-r from-blue-500 to-purple-500 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
}`}>
|
||||||
|
<div className="whitespace-pre-wrap break-words">
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 소스 정보 */}
|
||||||
|
{!isUser && message.sources && message.sources.length > 0 && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-300/30">
|
||||||
|
<div className="flex items-center space-x-1 text-sm opacity-75">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
<span>참조 문서:</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
{Object.entries(groupSourcesByFile(message.sources)).map(([filename, fileData], index) => (
|
||||||
|
<div key={index} className="text-sm bg-white/20 rounded px-2 py-1">
|
||||||
|
<div className="flex items-center flex-wrap">
|
||||||
|
<span className="text-[rgb(90,2,200)] font-medium">{filename}</span>
|
||||||
|
<div className="flex items-center ml-2 space-x-1">
|
||||||
|
{fileData.pages.map((pageNumber, pageIndex) => (
|
||||||
|
<button
|
||||||
|
key={pageIndex}
|
||||||
|
onClick={() => {
|
||||||
|
// 해당 파일의 첫 번째 페이지로 PDF 뷰어 열기
|
||||||
|
const source = `${filename}::${fileData.fileId} [p${pageNumber}]`;
|
||||||
|
handleSourceClick(source);
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:text-blue-100 underline cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
[p{pageNumber}]
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PDF 뷰어 */}
|
||||||
|
{selectedPage && (
|
||||||
|
<PDFViewer
|
||||||
|
fileId={selectedPage.fileId}
|
||||||
|
filename={selectedPage.filename}
|
||||||
|
pageNumber={selectedPage.pageNumber}
|
||||||
|
onClose={() => setSelectedPage(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MessageBubble;
|
||||||
324
frontend/src/components/PDFViewer.tsx
Normal file
324
frontend/src/components/PDFViewer.tsx
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { X, ChevronLeft, ChevronRight, ZoomIn, ZoomOut, RotateCw } from 'lucide-react';
|
||||||
|
import { Document, Page, pdfjs } from 'react-pdf';
|
||||||
|
|
||||||
|
// PDF.js 워커 설정
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js';
|
||||||
|
|
||||||
|
interface PDFViewerProps {
|
||||||
|
fileId: string;
|
||||||
|
filename: string;
|
||||||
|
pageNumber: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PDFViewer: React.FC<PDFViewerProps> = ({ fileId, filename, pageNumber, onClose }) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||||
|
const [numPages, setNumPages] = useState<number | null>(null);
|
||||||
|
const [currentPage, setCurrentPage] = useState(pageNumber);
|
||||||
|
const [scale, setScale] = useState(1.0);
|
||||||
|
const [rotation, setRotation] = useState(0);
|
||||||
|
const [showContinuousPages, setShowContinuousPages] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadPDF = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// PDF URL 생성
|
||||||
|
const url = `http://localhost:8000/pdf/${fileId}/view`;
|
||||||
|
setPdfUrl(url);
|
||||||
|
setIsLoading(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PDF 로드 오류:', err);
|
||||||
|
setError('PDF를 불러올 수 없습니다.');
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadPDF();
|
||||||
|
}, [fileId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(pageNumber);
|
||||||
|
}, [pageNumber]);
|
||||||
|
|
||||||
|
const onDocumentLoadSuccess = useCallback(({ numPages }: { numPages: number }) => {
|
||||||
|
setNumPages(numPages);
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onDocumentLoadError = useCallback((error: Error) => {
|
||||||
|
console.error('PDF 로드 오류:', error);
|
||||||
|
setError('PDF를 불러올 수 없습니다.');
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goToPrevPage = () => {
|
||||||
|
if (currentPage > 1) {
|
||||||
|
setCurrentPage(currentPage - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToNextPage = () => {
|
||||||
|
if (numPages && currentPage < numPages) {
|
||||||
|
setCurrentPage(currentPage + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomIn = () => {
|
||||||
|
setScale(prev => Math.min(prev + 0.25, 3.0));
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomOut = () => {
|
||||||
|
setScale(prev => Math.max(prev - 0.25, 0.5));
|
||||||
|
};
|
||||||
|
|
||||||
|
const rotate = () => {
|
||||||
|
setRotation(prev => (prev + 90) % 360);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 스크롤 이벤트 핸들러 제거 - 마우스 스크롤로 페이지 이동하지 않음
|
||||||
|
|
||||||
|
// 키보드 이벤트 핸들러 (연속 페이지 모드에서도 동일하게 작동)
|
||||||
|
const handleKeyDown = useCallback((e: Event) => {
|
||||||
|
const keyEvent = e as KeyboardEvent;
|
||||||
|
if (keyEvent.key === 'ArrowUp' || keyEvent.key === 'ArrowLeft') {
|
||||||
|
keyEvent.preventDefault();
|
||||||
|
setCurrentPage((prevPage) => Math.max(1, prevPage - 1));
|
||||||
|
} else if (keyEvent.key === 'ArrowDown' || keyEvent.key === 'ArrowRight') {
|
||||||
|
keyEvent.preventDefault();
|
||||||
|
setCurrentPage((prevPage) => Math.min(numPages || 1, prevPage + 1));
|
||||||
|
} else if (keyEvent.key === 'Home') {
|
||||||
|
keyEvent.preventDefault();
|
||||||
|
setCurrentPage(1);
|
||||||
|
} else if (keyEvent.key === 'End') {
|
||||||
|
keyEvent.preventDefault();
|
||||||
|
setCurrentPage(numPages || 1);
|
||||||
|
}
|
||||||
|
}, [numPages]);
|
||||||
|
|
||||||
|
// 키보드 이벤트 리스너만 등록 (마우스 스크롤 이벤트 제거)
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [handleKeyDown]);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, y: 20 }}
|
||||||
|
animate={{ scale: 1, y: 0 }}
|
||||||
|
exit={{ scale: 0.9, y: 20 }}
|
||||||
|
className="relative bg-white rounded-lg shadow-xl w-[95vw] h-[95vh] flex flex-col"
|
||||||
|
>
|
||||||
|
{/* Adobe Reader 스타일 툴바 */}
|
||||||
|
<div className="flex items-center justify-between p-3 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{/* 페이지 네비게이션 */}
|
||||||
|
<button
|
||||||
|
onClick={goToPrevPage}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
className="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="이전 페이지"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="text-sm text-gray-700 px-2">
|
||||||
|
{currentPage} / {numPages || '?'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={goToNextPage}
|
||||||
|
disabled={!numPages || currentPage >= numPages}
|
||||||
|
className="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="다음 페이지"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-gray-300 mx-2"></div>
|
||||||
|
|
||||||
|
{/* 줌 컨트롤 */}
|
||||||
|
<button
|
||||||
|
onClick={zoomOut}
|
||||||
|
className="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded"
|
||||||
|
title="축소"
|
||||||
|
>
|
||||||
|
<ZoomOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="text-sm text-gray-700 px-2">
|
||||||
|
{Math.round(scale * 100)}%
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={zoomIn}
|
||||||
|
className="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded"
|
||||||
|
title="확대"
|
||||||
|
>
|
||||||
|
<ZoomIn className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-gray-300 mx-2"></div>
|
||||||
|
|
||||||
|
{/* 회전 */}
|
||||||
|
<button
|
||||||
|
onClick={rotate}
|
||||||
|
className="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded"
|
||||||
|
title="회전"
|
||||||
|
>
|
||||||
|
<RotateCw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-gray-300 mx-2"></div>
|
||||||
|
|
||||||
|
{/* 연속 페이지 모드 토글 */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowContinuousPages(!showContinuousPages)}
|
||||||
|
className={`p-2 rounded transition-colors ${
|
||||||
|
showContinuousPages
|
||||||
|
? 'text-blue-600 bg-blue-50'
|
||||||
|
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
|
||||||
|
}`}
|
||||||
|
title={showContinuousPages ? "단일 페이지 모드" : "연속 페이지 모드"}
|
||||||
|
>
|
||||||
|
<div className="w-4 h-4 flex flex-col space-y-0.5">
|
||||||
|
<div className="w-full h-0.5 bg-current"></div>
|
||||||
|
<div className="w-full h-0.5 bg-current"></div>
|
||||||
|
<div className="w-full h-0.5 bg-current"></div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{/* 파일명 */}
|
||||||
|
<span className="text-sm text-gray-700 truncate max-w-[300px]">
|
||||||
|
{filename}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-gray-300 mx-2"></div>
|
||||||
|
|
||||||
|
{/* 사용법 안내 */}
|
||||||
|
<div className="text-xs text-gray-500 px-2">
|
||||||
|
{showContinuousPages
|
||||||
|
? "연속 페이지 모드 - 화살표키로 페이지 이동"
|
||||||
|
: "단일 페이지 모드 - 화살표키로 페이지 이동"
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-gray-300 mx-2"></div>
|
||||||
|
|
||||||
|
{/* 액션 버튼들 제거됨 */}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded"
|
||||||
|
title="닫기"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PDF 컨텐츠 */}
|
||||||
|
<div className="pdf-viewer-container flex-1 relative bg-gray-100 overflow-auto">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-100">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-blue-500 border-t-transparent mx-auto mb-4"></div>
|
||||||
|
<p className="text-lg font-medium text-gray-700">PDF를 불러오는 중...</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">잠시만 기다려주세요...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-100">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-red-500 text-6xl mb-4">⚠️</div>
|
||||||
|
<p className="text-red-600 text-lg font-medium">{error}</p>
|
||||||
|
<p className="text-gray-500 mt-2">파일을 다시 확인해주세요.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pdfUrl && !isLoading && !error && (
|
||||||
|
<div className="flex justify-center p-4">
|
||||||
|
<Document
|
||||||
|
file={pdfUrl}
|
||||||
|
onLoadSuccess={onDocumentLoadSuccess}
|
||||||
|
onLoadError={onDocumentLoadError}
|
||||||
|
loading={
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-4 border-blue-500 border-t-transparent mx-auto mb-2"></div>
|
||||||
|
<p className="text-sm text-gray-600">페이지 로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showContinuousPages ? (
|
||||||
|
// 연속 페이지 모드: 현재 페이지가 위쪽에 크게, 다음 페이지가 아래쪽에 작게 표시
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 현재 페이지 (위쪽에 크게) */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-xs text-gray-500 mb-2">페이지 {currentPage}</div>
|
||||||
|
<Page
|
||||||
|
pageNumber={currentPage}
|
||||||
|
scale={scale}
|
||||||
|
rotate={rotation}
|
||||||
|
className="shadow-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 다음 페이지 (아래쪽에 작게) */}
|
||||||
|
{numPages && currentPage < numPages && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-xs text-gray-500 mb-2">페이지 {currentPage + 1}</div>
|
||||||
|
<Page
|
||||||
|
pageNumber={currentPage + 1}
|
||||||
|
scale={scale * 0.7}
|
||||||
|
rotate={rotation}
|
||||||
|
className="shadow-md opacity-60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 단일 페이지 모드
|
||||||
|
<Page
|
||||||
|
pageNumber={currentPage}
|
||||||
|
scale={scale}
|
||||||
|
rotate={rotation}
|
||||||
|
className="shadow-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Document>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PDFViewer;
|
||||||
35
frontend/src/components/TypingIndicator.tsx
Normal file
35
frontend/src/components/TypingIndicator.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
const TypingIndicator: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gradient-to-r from-gray-500 to-gray-600 flex items-center justify-center">
|
||||||
|
<div className="w-4 h-4 bg-white rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-100 rounded-2xl px-4 py-3">
|
||||||
|
<div className="flex space-x-1">
|
||||||
|
<motion.div
|
||||||
|
className="w-2 h-2 bg-gray-400 rounded-full"
|
||||||
|
animate={{ scale: [1, 1.2, 1] }}
|
||||||
|
transition={{ duration: 0.6, repeat: Infinity, delay: 0 }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="w-2 h-2 bg-gray-400 rounded-full"
|
||||||
|
animate={{ scale: [1, 1.2, 1] }}
|
||||||
|
transition={{ duration: 0.6, repeat: Infinity, delay: 0.2 }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="w-2 h-2 bg-gray-400 rounded-full"
|
||||||
|
animate={{ scale: [1, 1.2, 1] }}
|
||||||
|
transition={{ duration: 0.6, repeat: Infinity, delay: 0.4 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TypingIndicator;
|
||||||
122
frontend/src/contexts/AuthContext.tsx
Normal file
122
frontend/src/contexts/AuthContext.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<boolean>;
|
||||||
|
logout: () => void;
|
||||||
|
token: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkTokenValidity = () => {
|
||||||
|
console.log('🔍 인증 토큰 유효성 검사 시작');
|
||||||
|
const savedToken = localStorage.getItem('auth_token');
|
||||||
|
const tokenExpiry = localStorage.getItem('auth_token_expiry');
|
||||||
|
|
||||||
|
console.log('📋 저장된 토큰 존재:', !!savedToken);
|
||||||
|
console.log('📋 토큰 만료 시간:', tokenExpiry);
|
||||||
|
|
||||||
|
if (savedToken && tokenExpiry) {
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const expiryTime = parseInt(tokenExpiry);
|
||||||
|
|
||||||
|
console.log('📋 현재 시간:', now);
|
||||||
|
console.log('📋 만료 시간:', expiryTime);
|
||||||
|
console.log('📋 시간 차이:', expiryTime - now);
|
||||||
|
|
||||||
|
if (now < expiryTime) {
|
||||||
|
// 토큰이 아직 유효함
|
||||||
|
setToken(savedToken);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
console.log('✅ 토큰이 유효합니다.');
|
||||||
|
} else {
|
||||||
|
// 토큰이 만료됨
|
||||||
|
console.log('❌ 토큰이 만료되었습니다. 로그인이 필요합니다.');
|
||||||
|
console.log('⏰ 로그인 권한이 만료되었습니다. 다시 로그인해주세요.');
|
||||||
|
setToken(null);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
localStorage.removeItem('auth_token_expiry');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('📋 저장된 토큰이 없습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기 토큰 체크
|
||||||
|
checkTokenValidity();
|
||||||
|
|
||||||
|
// 1분마다 토큰 만료 체크 (더 정확한 만료 시간 관리)
|
||||||
|
const interval = setInterval(checkTokenValidity, 1 * 60 * 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (username: string, password: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
console.log('🔐 로그인 시도 시작');
|
||||||
|
console.log('📋 사용자명:', username);
|
||||||
|
console.log('📋 비밀번호 존재:', !!password);
|
||||||
|
|
||||||
|
// 하드코딩된 인증
|
||||||
|
const validUsername = 'admin';
|
||||||
|
const validPassword = 'researchqa';
|
||||||
|
|
||||||
|
if (username === validUsername && password === validPassword) {
|
||||||
|
console.log('✅ 로그인 성공');
|
||||||
|
const fakeToken = 'hardcoded_auth_token_' + Date.now();
|
||||||
|
setToken(fakeToken);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
|
||||||
|
// 토큰과 만기 시간 저장 (1시간 후)
|
||||||
|
const expiryTime = new Date().getTime() + (60 * 60 * 1000); // 1시간 = 60분 * 60초 * 1000ms
|
||||||
|
localStorage.setItem('auth_token', fakeToken);
|
||||||
|
localStorage.setItem('auth_token_expiry', expiryTime.toString());
|
||||||
|
|
||||||
|
console.log('💾 토큰 저장 완료');
|
||||||
|
console.log('📋 만료 시간:', new Date(expiryTime).toLocaleString());
|
||||||
|
console.log('⏰ 로그인 권한이 1시간 동안 유지됩니다.');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.log('❌ 로그인 실패 - 잘못된 자격 증명');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 로그인 네트워크 오류');
|
||||||
|
console.error('📋 오류 타입:', error instanceof Error ? error.name : typeof error);
|
||||||
|
console.error('📋 오류 메시지:', error instanceof Error ? error.message : String(error));
|
||||||
|
console.error('📋 전체 오류:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
console.log('🚪 로그아웃 시작');
|
||||||
|
setToken(null);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
localStorage.removeItem('auth_token_expiry');
|
||||||
|
console.log('✅ 로그아웃 완료');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ isAuthenticated, login, logout, token }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
51
frontend/src/contexts/ChatContext.tsx
Normal file
51
frontend/src/contexts/ChatContext.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import React, { createContext, useContext, useState } from 'react';
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
isUser: boolean;
|
||||||
|
timestamp: Date;
|
||||||
|
sources?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatContextType {
|
||||||
|
messages: Message[];
|
||||||
|
addMessage: (message: Omit<Message, 'id' | 'timestamp'>) => void;
|
||||||
|
clearMessages: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
setIsLoading: (loading: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatContext = createContext<ChatContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useChat = () => {
|
||||||
|
const context = useContext(ChatContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useChat must be used within a ChatProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const addMessage = (message: Omit<Message, 'id' | 'timestamp'>) => {
|
||||||
|
const newMessage: Message = {
|
||||||
|
...message,
|
||||||
|
id: Date.now().toString(),
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
setMessages(prev => [...prev, newMessage]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearMessages = () => {
|
||||||
|
setMessages([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChatContext.Provider value={{ messages, addMessage, clearMessages, isLoading, setIsLoading }}>
|
||||||
|
{children}
|
||||||
|
</ChatContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
266
frontend/src/contexts/FileContext.tsx
Normal file
266
frontend/src/contexts/FileContext.tsx
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export interface FileInfo {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
upload_date: string;
|
||||||
|
file_type: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileContextType {
|
||||||
|
files: FileInfo[];
|
||||||
|
uploadFile: (file: File) => Promise<boolean>;
|
||||||
|
uploadMultipleFiles: (files: FileList) => Promise<{success: number, error: number, results: any[]}>;
|
||||||
|
deleteFile: (fileId: string) => Promise<boolean>;
|
||||||
|
refreshFiles: () => Promise<void>;
|
||||||
|
searchFiles: (searchTerm: string) => Promise<void>;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileContext = createContext<FileContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useFiles = () => {
|
||||||
|
const context = useContext(FileContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useFiles must be used within a FileProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FileProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [files, setFiles] = useState<FileInfo[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchFiles = async (searchTerm?: string) => {
|
||||||
|
try {
|
||||||
|
console.log('📁 파일 목록 조회 시작');
|
||||||
|
|
||||||
|
let url = 'http://localhost:8000/files';
|
||||||
|
|
||||||
|
console.log(`📋 검색어: '${searchTerm}'`);
|
||||||
|
|
||||||
|
// 검색어가 있으면 쿼리 파라미터 추가
|
||||||
|
if (searchTerm && searchTerm.trim()) {
|
||||||
|
const encodedSearch = encodeURIComponent(searchTerm.trim());
|
||||||
|
url += `?search=${encodedSearch}`;
|
||||||
|
console.log(`🔍 검색 URL: ${url}`);
|
||||||
|
console.log(`📋 원본 검색어: '${searchTerm}'`);
|
||||||
|
console.log(`📋 인코딩된 검색어: '${encodedSearch}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📤 최종 요청 URL: ${url}`);
|
||||||
|
|
||||||
|
// 인증 없이 요청 (파일 목록은 누구나 조회 가능)
|
||||||
|
console.log('📋 인증 없이 파일 목록 요청합니다.');
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
console.log(`📥 응답 받음: ${response.status} ${response.statusText}`);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log('✅ 파일 조회 성공');
|
||||||
|
const data = await response.json();
|
||||||
|
const filesArray = data.files || []; // Extract files array
|
||||||
|
setFiles(filesArray);
|
||||||
|
console.log(`📁 파일 조회 완료: ${filesArray.length}개 (검색어: ${searchTerm || '전체'})`);
|
||||||
|
console.log(`📋 반환된 파일들:`, filesArray.map((f: FileInfo) => f.filename));
|
||||||
|
} else {
|
||||||
|
console.error('❌ 파일 조회 실패');
|
||||||
|
console.error(`📋 상태 코드: ${response.status} ${response.statusText}`);
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error(`📋 오류 내용: ${errorText}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 파일 목록 조회 네트워크 오류');
|
||||||
|
console.error('📋 오류 타입:', error instanceof Error ? error.name : typeof error);
|
||||||
|
console.error('📋 오류 메시지:', error instanceof Error ? error.message : String(error));
|
||||||
|
console.error('📋 전체 오류:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchFiles = async (searchTerm: string) => {
|
||||||
|
console.log('🔍 서버 검색 실행:', searchTerm);
|
||||||
|
await fetchFiles(searchTerm);
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFile = async (file: File): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
console.log('📤 파일 업로드 시작');
|
||||||
|
console.log('📋 파일명:', file.name);
|
||||||
|
console.log('📋 파일 크기:', file.size, 'bytes');
|
||||||
|
console.log('📋 파일 타입:', file.type);
|
||||||
|
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
|
||||||
|
// 파일 업로드는 로그인이 필요함
|
||||||
|
if (!token) {
|
||||||
|
console.log('🔒 파일 업로드를 위해서는 로그인이 필요합니다.');
|
||||||
|
alert('파일 업로드를 위해서는 로그인이 필요합니다.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
console.log('📤 업로드 요청 전송 중...');
|
||||||
|
console.log('📋 요청 URL: http://localhost:8000/upload');
|
||||||
|
console.log('📋 인증 토큰 존재:', !!token);
|
||||||
|
|
||||||
|
const response = await fetch('http://localhost:8000/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('📥 업로드 응답 받음');
|
||||||
|
console.log('📋 응답 상태:', response.status, response.statusText);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log('✅ 파일 업로드 성공');
|
||||||
|
// 업로드 성공 후 파일 목록 새로고침
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000)); // 1초 대기
|
||||||
|
await fetchFiles();
|
||||||
|
console.log('📁 파일 목록 새로고침 완료');
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.log('❌ 파일 업로드 실패');
|
||||||
|
const errorData = await response.json();
|
||||||
|
console.error('📋 오류 데이터:', errorData);
|
||||||
|
alert(`파일 업로드 실패: ${errorData.detail || '알 수 없는 오류가 발생했습니다.'}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 파일 업로드 네트워크 오류');
|
||||||
|
console.error('📋 오류 타입:', error instanceof Error ? error.name : typeof error);
|
||||||
|
console.error('📋 오류 메시지:', error instanceof Error ? error.message : String(error));
|
||||||
|
console.error('📋 전체 오류:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.';
|
||||||
|
alert(`파일 업로드 실패: ${errorMessage}`);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
console.log('🏁 파일 업로드 완료');
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadMultipleFiles = async (files: FileList): Promise<{success: number, error: number, results: any[]}> => {
|
||||||
|
try {
|
||||||
|
console.log('멀티파일 업로드 시작:', files.length, '개 파일');
|
||||||
|
setIsLoading(true);
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
// 모든 파일을 FormData에 추가
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
formData.append('files', files[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('멀티파일 업로드 요청 전송 중...');
|
||||||
|
const response = await fetch('http://localhost:8000/upload-multiple', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('멀티파일 업로드 응답 받음:', response.status, response.statusText);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('멀티파일 업로드 결과:', result);
|
||||||
|
|
||||||
|
// 업로드 성공 후 파일 목록 새로고침
|
||||||
|
await fetchFiles();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: result.success_count,
|
||||||
|
error: result.error_count,
|
||||||
|
results: result.results
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
console.error('멀티파일 업로드 실패:', response.status, response.statusText);
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('오류 내용:', errorText);
|
||||||
|
return {success: 0, error: files.length, results: []};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('멀티파일 업로드 중 오류 발생:', error);
|
||||||
|
return {success: 0, error: files.length, results: []};
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteFile = async (fileId: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
console.log('🗑️ 파일 삭제 시작');
|
||||||
|
console.log('📋 파일 ID:', fileId);
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
|
||||||
|
// 파일 삭제는 로그인이 필요함
|
||||||
|
if (!token) {
|
||||||
|
console.log('🔒 파일 삭제를 위해서는 로그인이 필요합니다.');
|
||||||
|
alert('파일 삭제를 위해서는 로그인이 필요합니다.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📤 삭제 요청 전송 중...');
|
||||||
|
console.log('📋 요청 URL:', `http://localhost:8000/files/${fileId}`);
|
||||||
|
console.log('📋 인증 토큰 존재:', !!token);
|
||||||
|
|
||||||
|
const response = await fetch(`http://localhost:8000/files/${fileId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('📥 삭제 응답 받음');
|
||||||
|
console.log('📋 응답 상태:', response.status, response.statusText);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log('✅ 파일 삭제 성공');
|
||||||
|
console.log('📁 목록 새로고침 중...');
|
||||||
|
// 즉시 UI 업데이트를 위해 파일 목록에서 해당 파일 제거
|
||||||
|
setFiles(prevFiles => prevFiles.filter(file => file.id !== fileId));
|
||||||
|
// 백그라운드에서 파일 목록 새로고침
|
||||||
|
fetchFiles();
|
||||||
|
console.log('📁 파일 목록 새로고침 완료');
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.log('❌ 파일 삭제 실패');
|
||||||
|
const errorData = await response.json();
|
||||||
|
console.error('📋 오류 데이터:', errorData);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 파일 삭제 네트워크 오류');
|
||||||
|
console.error('📋 오류 타입:', error instanceof Error ? error.name : typeof error);
|
||||||
|
console.error('📋 오류 메시지:', error instanceof Error ? error.message : String(error));
|
||||||
|
console.error('📋 전체 오류:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshFiles = async () => {
|
||||||
|
console.log('🔄 파일 목록 새로고침 시작');
|
||||||
|
await fetchFiles();
|
||||||
|
console.log('✅ 파일 목록 새로고침 완료');
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('🚀 FileContext 초기화 - 파일 목록 조회 시작');
|
||||||
|
fetchFiles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FileContext.Provider value={{ files, uploadFile, uploadMultipleFiles, deleteFile, refreshFiles, searchFiles, isLoading }}>
|
||||||
|
{children}
|
||||||
|
</FileContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
166
frontend/src/index.css
Normal file
166
frontend/src/index.css
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 스크롤바 스타일링 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #c1c1c1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #a8a8a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 채팅 메시지 애니메이션 */
|
||||||
|
.message-enter {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-enter-active {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
transition: opacity 300ms, transform 300ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 로딩 애니메이션 */
|
||||||
|
.typing-indicator {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #9ca3af;
|
||||||
|
animation: typing 1.4s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator:nth-child(1) {
|
||||||
|
animation-delay: -0.32s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator:nth-child(2) {
|
||||||
|
animation-delay: -0.16s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes typing {
|
||||||
|
0%, 80%, 100% {
|
||||||
|
transform: scale(0);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 그라데이션 텍스트 */
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 카드 호버 효과 */
|
||||||
|
.card-hover {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 버튼 애니메이션 */
|
||||||
|
.btn-animate {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-animate:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-animate:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PDF 뷰어 스타일 */
|
||||||
|
.react-pdf__Page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-pdf__Page__canvas {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-pdf__Page__textContent {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
transform: scale(1);
|
||||||
|
transform-origin: 0 0;
|
||||||
|
white-space: pre;
|
||||||
|
cursor: text;
|
||||||
|
color: transparent;
|
||||||
|
font-family: sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-pdf__Page__textContent span {
|
||||||
|
color: transparent;
|
||||||
|
position: absolute;
|
||||||
|
white-space: pre;
|
||||||
|
cursor: text;
|
||||||
|
transform-origin: 0% 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-pdf__Page__annotations {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
transform: scale(1);
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-pdf__Page__annotation {
|
||||||
|
position: absolute;
|
||||||
|
transform: scale(1);
|
||||||
|
transform-origin: 0% 0%;
|
||||||
|
}
|
||||||
13
frontend/src/index.tsx
Normal file
13
frontend/src/index.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(
|
||||||
|
document.getElementById('root') as HTMLElement
|
||||||
|
);
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
56
frontend/tailwind.config.js
Normal file
56
frontend/tailwind.config.js
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./src/**/*.{js,jsx,ts,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#f0f9ff',
|
||||||
|
100: '#e0f2fe',
|
||||||
|
200: '#bae6fd',
|
||||||
|
300: '#7dd3fc',
|
||||||
|
400: '#38bdf8',
|
||||||
|
500: '#0ea5e9',
|
||||||
|
600: '#0284c7',
|
||||||
|
700: '#0369a1',
|
||||||
|
800: '#075985',
|
||||||
|
900: '#0c4a6e',
|
||||||
|
},
|
||||||
|
gray: {
|
||||||
|
50: '#f9fafb',
|
||||||
|
100: '#f3f4f6',
|
||||||
|
200: '#e5e7eb',
|
||||||
|
300: '#d1d5db',
|
||||||
|
400: '#9ca3af',
|
||||||
|
500: '#6b7280',
|
||||||
|
600: '#4b5563',
|
||||||
|
700: '#374151',
|
||||||
|
800: '#1f2937',
|
||||||
|
900: '#111827',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||||
|
'slide-up': 'slideUp 0.3s ease-out',
|
||||||
|
'bounce-gentle': 'bounceGentle 2s infinite',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
|
slideUp: {
|
||||||
|
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||||
|
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||||
|
},
|
||||||
|
bounceGentle: {
|
||||||
|
'0%, 100%': { transform: 'translateY(0)' },
|
||||||
|
'50%': { transform: 'translateY(-5px)' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
26
frontend/tsconfig.json
Normal file
26
frontend/tsconfig.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"es6"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
17
start_backend.sh
Executable file
17
start_backend.sh
Executable file
@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "연구QA Chatbot 백엔드 서버를 시작합니다..."
|
||||||
|
|
||||||
|
# 백엔드 디렉토리로 이동
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Python 가상환경 활성화 (선택사항)
|
||||||
|
# source venv/bin/activate
|
||||||
|
|
||||||
|
# 의존성 설치
|
||||||
|
echo "Python 의존성을 설치합니다..."
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 백엔드 서버 시작
|
||||||
|
echo "백엔드 서버를 시작합니다..."
|
||||||
|
python main.py
|
||||||
14
start_frontend.sh
Executable file
14
start_frontend.sh
Executable file
@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "연구QA Chatbot 프론트엔드를 시작합니다..."
|
||||||
|
|
||||||
|
# 프론트엔드 디렉토리로 이동
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Node.js 의존성 설치
|
||||||
|
echo "Node.js 의존성을 설치합니다..."
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 프론트엔드 개발 서버 시작
|
||||||
|
echo "프론트엔드 개발 서버를 시작합니다..."
|
||||||
|
npm start
|
||||||
Loading…
x
Reference in New Issue
Block a user