init
This commit is contained in:
124
.gitignore
vendored
Normal file
124
.gitignore
vendored
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# ---> Python
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
.idea
|
||||||
|
.idea/*
|
||||||
|
*.gguf
|
||||||
|
*.db
|
||||||
|
*.pdf
|
||||||
|
|
||||||
|
dist/*
|
||||||
|
node_modules/*
|
||||||
|
.env
|
||||||
|
chroma_db/*
|
||||||
|
uploads/*
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# 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/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*,cover
|
||||||
|
.hypothesis/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# celery beat schedule file
|
||||||
|
celerybeat-schedule
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# dotenv
|
||||||
|
.env
|
||||||
|
|
||||||
|
# virtualenv
|
||||||
|
.venv
|
||||||
|
.venv_python3.11
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# apple dsstore ignore
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Cursor Rule Ignore
|
||||||
|
.cursor/rules
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
tests/*
|
||||||
|
run_tests.py
|
||||||
|
pytest.ini
|
||||||
|
|
||||||
|
backend/engines/chatbot_gxp/alembic
|
||||||
|
|
||||||
|
*.pdf
|
||||||
|
|
||||||
|
backend/engines/research_qa/data/*
|
||||||
94
README.md
Normal file
94
README.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# 웅톡 프론트엔드 (React + Vite)
|
||||||
|
|
||||||
|
웅톡(WoongTalk)은 다양한 AI 엔진(챗봇·PDF QA·Text-to-SQL 등)을 한 화면에서 사용할 수 있게 해 주는 통합 프론트엔드입니다. 기존 jQuery SPA 구조를 **React 18 / Vite 7** 기반으로 전면 개편하였습니다.
|
||||||
|
|
||||||
|
## 폴더 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
woongtalk_frontend/
|
||||||
|
├── index.html # Vite 진입점 (root div 및 module script)
|
||||||
|
├── style.css # 전역 스타일 – 기존 CSS 그대로 유지
|
||||||
|
├── package.json # 의존성 & 스크립트
|
||||||
|
├── vite.config.js # Vite 설정
|
||||||
|
├── src/ # React 소스 코드
|
||||||
|
│ ├── main.jsx # React DOM 마운트
|
||||||
|
│ ├── App.jsx # 라우팅·레이아웃 컨테이너
|
||||||
|
│ ├── context/
|
||||||
|
│ │ └── ToolContext.jsx # 도구 상태 전역 관리(Context API)
|
||||||
|
│ ├── components/
|
||||||
|
│ │ └── Sidebar.jsx # 좌측 메뉴 바
|
||||||
|
│ ├── pages/ # 상단 메뉴별 화면
|
||||||
|
│ │ ├── ChatPage.jsx # 채팅 화면(일반/iframe/dev_chatbot)
|
||||||
|
│ │ ├── ToolsPage.jsx # 도구 목록/필터/즐겨찾기
|
||||||
|
│ │ ├── LecturePage.jsx # 강의 모음(정적)
|
||||||
|
│ │ └── CommunityPage.jsx# 커뮤니티(정적)
|
||||||
|
│ └── tools/ # 개별 도구 전용 컴포넌트·헬퍼
|
||||||
|
│ └── dev_chatbot/
|
||||||
|
│ ├── constants.js # 백엔드 API base URL 등
|
||||||
|
│ ├── AIService.js # /chat 호출 래퍼
|
||||||
|
│ ├── OCRService.js # Tesseract.js 기반 OCR
|
||||||
|
│ ├── ChatHandler.js # 파일/OCR 처리 후 AIService 호출
|
||||||
|
│ └── ChatInput.jsx # dev_chatbot 전용 채팅 UI
|
||||||
|
└── frontend/ # (구)폴더 – iframe 전용 도구의 README만 보관
|
||||||
|
├── lims_text2sql/
|
||||||
|
└── research_qa/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주요 흐름
|
||||||
|
1. **ToolsPage** 가 `/tools` API를 조회해 카드 목록을 표시
|
||||||
|
2. 카드 클릭 → 선택 도구가 `ToolContext` 에 저장되며 `ChatPage` 로 이동
|
||||||
|
3. ChatPage
|
||||||
|
* dev_chatbot → `DevChatInput` 렌더링 (OCR + 멀티파트 업로드 지원)
|
||||||
|
* research_qa / lims_text2sql → 외부 URL을 iframe 으로 임베드
|
||||||
|
* 그 외 → 일반 채팅 UI (textarea + API `/chat` 호출)
|
||||||
|
|
||||||
|
## 환경 변수 설정
|
||||||
|
|
||||||
|
프로젝트 루트 또는 쉘 설정(`~/.zshrc`)에 다음 값을 지정하세요.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# OpenAI Key (필수) – 브라우저에서 직접 호출할 때 사용
|
||||||
|
OPENAI_API_KEY="sk-..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite 설정에서 `envPrefix` 를 `OPENAI_` 로 포함했으므로 `VITE_` 접두어 없이도 노출됩니다.
|
||||||
|
|
||||||
|
## 실행
|
||||||
|
```bash
|
||||||
|
npm install # 의존성 설치
|
||||||
|
npm run dev # 개발 서버 (http://localhost:5173)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 주요 기능
|
||||||
|
1. React 18 + Vite 7 SPA 구조
|
||||||
|
2. ChatGPT 도구
|
||||||
|
- 모델 선택: GPT-4o(기본), GPT-4.1-mini, o4-mini
|
||||||
|
- 긴 한국어 답변 + 이모지 + Markdown 렌더링
|
||||||
|
- 입력 후 "생각중… (경과초)" 로딩 표시
|
||||||
|
3. 개발챗봇 도구
|
||||||
|
- PDF 파일 사이드바(업로드·삭제)
|
||||||
|
- 이미지 OCR(Tesseract.js) 지원
|
||||||
|
4. 강의·커뮤니티 페이지 정적 콘텐츠 표시
|
||||||
|
|
||||||
|
## 의존성 주요 목록
|
||||||
|
| 라이브러리 | 용도 |
|
||||||
|
|------------|------|
|
||||||
|
| React 18 | UI |
|
||||||
|
| Vite 7 | 번들러 |
|
||||||
|
| marked 5 | Markdown → HTML 변환 |
|
||||||
|
| tesseract.js| 이미지 OCR |
|
||||||
|
|
||||||
|
## 빌드 & 미리보기
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run preview # http://localhost:4173
|
||||||
|
```
|
||||||
|
|
||||||
|
## 빌드 산출물 구조
|
||||||
|
프로덕션 빌드시 `dist/` 폴더가 생성되며, 정적 파일을 Nginx·S3 등에 바로 서빙할 수 있습니다.
|
||||||
|
|
||||||
|
## 기여 & 문의
|
||||||
|
Pull Request 환영합니다. 버그·제안은 Issue로 등록해 주세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
© 2025 Daewoong AI Lab – All rights reserved.
|
||||||
14
index.html
Normal file
14
index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>웅톡 AI엔진</title>
|
||||||
|
<link rel="stylesheet" href="/style.css" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1840
package-lock.json
generated
Normal file
1840
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "woongtalk-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite build && vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"tesseract.js": "^5.1.0",
|
||||||
|
"marked": "^5.1.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.0.4",
|
||||||
|
"vite": "^7.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
113
src/App.jsx
Normal file
113
src/App.jsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import Sidebar from './components/Sidebar';
|
||||||
|
import ChatPage from './pages/ChatPage';
|
||||||
|
import ToolsPage from './pages/ToolsPage';
|
||||||
|
import LecturePage from './pages/LecturePage';
|
||||||
|
import AiNewsPage from './pages/AiNewsPage';
|
||||||
|
import QnaPage from './pages/QnaPage';
|
||||||
|
import { ToolProvider } from './context/ToolContext';
|
||||||
|
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||||
|
import LoginPage from './pages/LoginPage';
|
||||||
|
import { useTool } from './context/ToolContext';
|
||||||
|
|
||||||
|
function TopbarTitle({ currentPage, onNavigate }) {
|
||||||
|
const { selectedTool } = useTool();
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const titleMap = { tools: '도구', lecture: '강의', ai_news: 'AI뉴스', qna: '질의응답' };
|
||||||
|
const title = currentPage === 'chat' ? (selectedTool?.name || '채팅') : (titleMap[currentPage] || '');
|
||||||
|
return (
|
||||||
|
<header className="topbar">
|
||||||
|
<span className="page-title">{title}</span>
|
||||||
|
<UserBadge user={user} onLogout={logout} onNavigate={onNavigate} />
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserBadge({ user, onLogout, onNavigate }) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
if (!user) return <a className="login-btn" href="/login" onClick={(e)=>{ e.preventDefault(); onNavigate('login'); }}>로그인</a>;
|
||||||
|
return (
|
||||||
|
<div style={{ position:'relative' }}>
|
||||||
|
<button className="login-btn" onClick={()=>setOpen(!open)}>{user.user_id}</button>
|
||||||
|
{open && (
|
||||||
|
<div style={{ position:'absolute', right:0, top:36, background:'#fff', border:'1px solid #eee', borderRadius:12, padding:'10px 12px', boxShadow:'0 4px 16px rgba(0,0,0,.08)', zIndex: 1000, pointerEvents:'auto' }}>
|
||||||
|
<div style={{ fontSize:'.95rem', color:'#333', marginBottom:8 }}>{user.email}</div>
|
||||||
|
<button onClick={(e)=>{ e.stopPropagation(); onLogout(); setOpen(false); }} style={{ background:'#fff6f6', color:'#e53935', border:'1px solid #ffd6d6', padding:'6px 10px', borderRadius:8, cursor:'pointer' }}>logout</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [currentPage, setCurrentPage] = useState('chat');
|
||||||
|
|
||||||
|
const PAGES = {
|
||||||
|
chat: <ChatPage />,
|
||||||
|
tools: <ToolsPage onNavigate={handleNavigate} />,
|
||||||
|
lecture: <LecturePage />,
|
||||||
|
// community 제거
|
||||||
|
login: <LoginPage onLoggedIn={() => { handleNavigate('chat'); }} />,
|
||||||
|
ai_news: <AiNewsPage />,
|
||||||
|
qna: <QnaPage />,
|
||||||
|
};
|
||||||
|
|
||||||
|
// URL → 상태 동기화 (초기 진입 및 뒤로가기 대응)
|
||||||
|
useEffect(() => {
|
||||||
|
const path = window.location.pathname.replace(/^\/+/, '');
|
||||||
|
const menuPaths = { chatting: 'chat', tools: 'tools', lecture: 'lecture', ai_news: 'ai_news', qna: 'qna', login: 'login' };
|
||||||
|
const maybeMenu = menuPaths[path];
|
||||||
|
if (maybeMenu) {
|
||||||
|
setCurrentPage(maybeMenu);
|
||||||
|
} else if (path === '') {
|
||||||
|
setCurrentPage('chat');
|
||||||
|
} else {
|
||||||
|
// 카드 경로일 때는 채팅 페이지로 전환
|
||||||
|
setCurrentPage('chat');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 브라우저 뒤/앞으로가기(popstate) 시 URL에 맞게 화면 전환
|
||||||
|
useEffect(() => {
|
||||||
|
const onPopState = () => {
|
||||||
|
const path = window.location.pathname.replace(/^\/+/, '');
|
||||||
|
const menuPaths = { chatting: 'chat', tools: 'tools', lecture: 'lecture', ai_news: 'ai_news', qna: 'qna', login: 'login' };
|
||||||
|
const maybeMenu = menuPaths[path];
|
||||||
|
if (maybeMenu) {
|
||||||
|
setCurrentPage(maybeMenu);
|
||||||
|
} else if (path === '') {
|
||||||
|
setCurrentPage('chat');
|
||||||
|
} else {
|
||||||
|
// 카드 경로는 채팅 화면으로
|
||||||
|
setCurrentPage('chat');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('popstate', onPopState);
|
||||||
|
return () => window.removeEventListener('popstate', onPopState);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 메뉴 전환 시 URL 경로 갱신
|
||||||
|
function handleNavigate(page) {
|
||||||
|
setCurrentPage(page);
|
||||||
|
const menuToPath = { chat: 'chatting', tools: 'tools', lecture: 'lecture', ai_news: 'ai_news', qna: 'qna', login: 'login' };
|
||||||
|
const p = menuToPath[page] || '';
|
||||||
|
const newPath = `/${p}`;
|
||||||
|
if (window.location.pathname !== newPath) {
|
||||||
|
window.history.pushState({}, '', newPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<ToolProvider>
|
||||||
|
<div className="container">
|
||||||
|
<Sidebar current={currentPage} onChange={handleNavigate} />
|
||||||
|
<main className="main-content">
|
||||||
|
<TopbarTitle currentPage={currentPage} onNavigate={handleNavigate} />
|
||||||
|
{PAGES[currentPage]}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</ToolProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/components/Sidebar.jsx
Normal file
32
src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useTool } from '../context/ToolContext';
|
||||||
|
|
||||||
|
const MENU = [
|
||||||
|
{ id: 'chat', label: '채팅' },
|
||||||
|
{ id: 'tools', label: '도구' },
|
||||||
|
{ id: 'lecture', label: '강의' },
|
||||||
|
{ id: 'ai_news', label: 'AI뉴스' },
|
||||||
|
{ id: 'qna', label: '질의응답' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Sidebar({ current, onChange }) {
|
||||||
|
const { setSelectedTool } = useTool();
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="logo" onClick={() => { setSelectedTool(null); try { localStorage.removeItem('selectedToolId'); } catch {}; onChange('chat'); }} style={{cursor:'pointer'}}>웅톡</div>
|
||||||
|
<nav className="menu">
|
||||||
|
<ul>
|
||||||
|
{MENU.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className={`menu-item ${current === item.id ? 'active' : ''}`}
|
||||||
|
onClick={() => { onChange(item.id); }}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/context/AuthContext.jsx
Normal file
52
src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const AuthContext = createContext();
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null); // { user_id, email }
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem('auth_user') || 'null');
|
||||||
|
if (saved && saved.user_id) setUser(saved);
|
||||||
|
} catch {}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (email, password) => {
|
||||||
|
const res = await fetch('http://localhost:8010/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
// 서버가 에러 메시지를 JSON 으로 보낼 수도 있으므로 파싱 시도
|
||||||
|
let msg = '로그인 실패';
|
||||||
|
try { msg = (await res.json())?.detail || msg; } catch { /* ignore */ }
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
const data = await res.json(); // { user_id, email }
|
||||||
|
setUser(data);
|
||||||
|
try { localStorage.setItem('auth_user', JSON.stringify(data)); } catch {}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('http://localhost:8010/auth/logout', { method: 'POST', credentials: 'include' });
|
||||||
|
} catch {}
|
||||||
|
setUser(null);
|
||||||
|
try { localStorage.removeItem('auth_user'); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = { user, loading, login, logout };
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
95
src/context/ToolContext.jsx
Normal file
95
src/context/ToolContext.jsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import React, { createContext, useState, useContext, useEffect } from 'react';
|
||||||
|
|
||||||
|
const ToolContext = createContext();
|
||||||
|
|
||||||
|
export function ToolProvider({ children }) {
|
||||||
|
const [tools, setTools] = useState([]);
|
||||||
|
const [selectedTool, setSelectedTool] = useState(null);
|
||||||
|
const [favorites, setFavorites] = useState(() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem('favorites')) || [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('http://localhost:8010/tools');
|
||||||
|
const data = await res.json();
|
||||||
|
// 'GxP 챗봇'과 'chatgpt' 카드는 도구 목록에서 제거
|
||||||
|
const filtered = (Array.isArray(data) ? data : []).filter((tool) => !['chatbot_gxp','chatgpt'].includes(tool.id));
|
||||||
|
const mapped = filtered.map((tool) => {
|
||||||
|
const category = ['전체'];
|
||||||
|
if (['dev_chatbot', 'doc_translation'].includes(tool.id)) {
|
||||||
|
category.push('오픈AI');
|
||||||
|
} else if (['lims_text2sql', 'research_qa'].includes(tool.id)) {
|
||||||
|
category.push('내부AI');
|
||||||
|
}
|
||||||
|
return { ...tool, category };
|
||||||
|
});
|
||||||
|
setTools(mapped);
|
||||||
|
// 선택된 도구 복원 (마지막 카드 유지)
|
||||||
|
try {
|
||||||
|
const savedId = localStorage.getItem('selectedToolId');
|
||||||
|
if (savedId) {
|
||||||
|
const found = mapped.find((t) => t.id === savedId);
|
||||||
|
if (found) setSelectedTool(found);
|
||||||
|
}
|
||||||
|
// URL 경로가 카드 ID일 경우 해당 카드로 강제 설정 (예: /dev_chatbot)
|
||||||
|
const path = window.location.pathname.replace(/^\/+/, '');
|
||||||
|
const menuPaths = new Set(['chatting', 'tools', 'lecture', 'community', '']);
|
||||||
|
if (path && !menuPaths.has(path)) {
|
||||||
|
const fromUrl = mapped.find((t) => t.id === path);
|
||||||
|
if (fromUrl) setSelectedTool(fromUrl);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
// 즐겨찾기에 남아 있을 수 있는 제거 대상 정리
|
||||||
|
setFavorites((prev) => prev.filter((id) => !['chatbot_gxp','chatgpt'].includes(id)));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('favorites', JSON.stringify(favorites));
|
||||||
|
}, [favorites]);
|
||||||
|
|
||||||
|
// 선택된 도구 지속화: 메뉴 이동 후에도 마지막 카드가 유지되도록 저장
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
if (selectedTool?.id) localStorage.setItem('selectedToolId', selectedTool.id);
|
||||||
|
} catch {}
|
||||||
|
}, [selectedTool?.id]);
|
||||||
|
|
||||||
|
const toggleFavorite = (id) => {
|
||||||
|
setFavorites((prev) => (prev.includes(id) ? prev.filter((f) => f !== id) : [...prev, id]));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카드 선택 시 URL을 카드 경로로 반영 (메뉴 페이지가 아닌 경우)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedTool?.id) return;
|
||||||
|
const path = `/${selectedTool.id}`;
|
||||||
|
const menuPaths = new Set(['/chatting','/tools','/lecture','/community','/']);
|
||||||
|
if (!menuPaths.has(window.location.pathname) && window.location.pathname === path) return;
|
||||||
|
// 도구 화면에서 카드 선택 시 URL을 카드 경로로 업데이트
|
||||||
|
if (window.location.pathname !== path) {
|
||||||
|
window.history.pushState({}, '', path);
|
||||||
|
}
|
||||||
|
}, [selectedTool?.id]);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
tools,
|
||||||
|
selectedTool,
|
||||||
|
setSelectedTool,
|
||||||
|
favorites,
|
||||||
|
toggleFavorite,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <ToolContext.Provider value={value}>{children}</ToolContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTool() {
|
||||||
|
return useContext(ToolContext);
|
||||||
|
}
|
||||||
9
src/main.jsx
Normal file
9
src/main.jsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
173
src/pages/AiNewsPage.jsx
Normal file
173
src/pages/AiNewsPage.jsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
export default function AiNewsPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [news, setNews] = useState([]);
|
||||||
|
const [newsOffset, setNewsOffset] = useState(0);
|
||||||
|
const [newsLoading, setNewsLoading] = useState(false);
|
||||||
|
const [showNewsEditor, setShowNewsEditor] = useState(false);
|
||||||
|
const [newsUrl, setNewsUrl] = useState('');
|
||||||
|
const contentRef = useRef(null);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
const [readyForScroll, setReadyForScroll] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
await loadNews(0, false);
|
||||||
|
// 초기 진입 시 리스트 하단(최신)으로 이동
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = contentRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
// 이미지 로드 등으로 높이가 늘어나는 경우를 대비해 한 번 더 보정
|
||||||
|
setTimeout(() => {
|
||||||
|
const el2 = contentRef.current;
|
||||||
|
if (el2) el2.scrollTop = el2.scrollHeight;
|
||||||
|
// 한 프레임 더 보장 후 스크롤 핸들러 활성화
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setInitialized(true);
|
||||||
|
setReadyForScroll(true);
|
||||||
|
});
|
||||||
|
}, 60);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialized) return;
|
||||||
|
const el = contentRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onScroll = () => {
|
||||||
|
if (!readyForScroll) return; // 초기 자동 스크롤 완료 전에는 무시
|
||||||
|
if (el.scrollTop <= 20 && !newsLoading) {
|
||||||
|
loadNews(newsOffset, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
el.addEventListener('scroll', onScroll);
|
||||||
|
return () => el.removeEventListener('scroll', onScroll);
|
||||||
|
}, [initialized, readyForScroll, newsOffset, newsLoading]);
|
||||||
|
|
||||||
|
const loadNews = async (offset = 0, prepend = false) => {
|
||||||
|
if (newsLoading) return;
|
||||||
|
setNewsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://localhost:8010/community/ai_news?offset=${offset}&limit=10`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
const items = (data.items || []).reverse();
|
||||||
|
if (prepend) {
|
||||||
|
const el = contentRef.current;
|
||||||
|
const prevH = el ? el.scrollHeight : document.documentElement.scrollHeight;
|
||||||
|
setNews((prev) => [...items, ...prev]);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (el) {
|
||||||
|
el.scrollTop = el.scrollHeight - prevH;
|
||||||
|
} else {
|
||||||
|
const newH = document.documentElement.scrollHeight;
|
||||||
|
window.scrollTo(0, newH - prevH);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
} else {
|
||||||
|
setNews(items);
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = contentRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight; else window.scrollTo(0, document.documentElement.scrollHeight);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
const nextOffset = data.nextOffset || 0;
|
||||||
|
setNewsOffset(nextOffset);
|
||||||
|
return nextOffset;
|
||||||
|
} finally {
|
||||||
|
setNewsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기 스크롤 보정 루프는 제거: 진입 직후 하단으로 고정만 수행
|
||||||
|
|
||||||
|
const submitNews = async () => {
|
||||||
|
if (!newsUrl.trim()) return;
|
||||||
|
await fetch('http://localhost:8010/community/ai_news', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: newsUrl, author_id: String(user?.user_id || ''), author_email: user?.email || null }),
|
||||||
|
});
|
||||||
|
setShowNewsEditor(false);
|
||||||
|
setNewsUrl('');
|
||||||
|
loadNews(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page page-ai-news" style={{ flex: 1, display: 'flex', flexDirection: 'column', paddingTop: '24px' }}>
|
||||||
|
<main className="community-content" ref={contentRef} style={{ overflowY: 'auto', flex: 1 }}>
|
||||||
|
<div style={{ padding: '0 20px 50px 20px' }}>
|
||||||
|
{news.map((n) => (
|
||||||
|
<div key={n.id} style={{ background: '#fff', border: '1px solid #eee', borderRadius: 12, padding: 12, marginBottom: 12 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
{n.meta?.image ? (
|
||||||
|
<img src={n.meta.image} alt="thumb" style={{ width: 96, height: 96, objectFit: 'cover', borderRadius: 8 }} />
|
||||||
|
) : null}
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<a href={n.meta?.url || n.url} target="_blank" rel="noreferrer" style={{ fontWeight: 600, color: '#1976d2', textDecoration: 'none' }}>
|
||||||
|
{n.meta?.title || n.url}
|
||||||
|
</a>
|
||||||
|
<div style={{ marginTop: 6, color: '#555', lineHeight: 1.5 }}>{n.meta?.description}</div>
|
||||||
|
<div style={{ marginTop: 6, color: '#999', fontSize: '.9rem' }}>{new Date(n.created_at).toISOString().slice(0, 10)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{newsLoading && (
|
||||||
|
<div className="loading-overlay">
|
||||||
|
<span className="loading-text">뉴스 로딩중</span>
|
||||||
|
<span className="loading-dots">
|
||||||
|
<span className="loading-dot"></span>
|
||||||
|
<span className="loading-dot"></span>
|
||||||
|
<span className="loading-dot"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{user && !showNewsEditor && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewsEditor(true)}
|
||||||
|
style={{ position: 'fixed', right: 24, bottom: 24, background: '#ff9800', color: '#fff', border: 'none', padding: '12px 16px', borderRadius: 18, cursor: 'pointer', boxShadow: '0 4px 16px rgba(0,0,0,.15)', zIndex: 1000 }}
|
||||||
|
>
|
||||||
|
뉴스등록
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showNewsEditor && user && (
|
||||||
|
<div
|
||||||
|
style={{ position: 'fixed', right: 24, bottom: 84, zIndex: 1001, width: 'min(560px, calc(100vw - 40px))', background: '#fff', border: '1px solid #ffd6c2', borderRadius: 12, padding: 12, boxShadow: '0 6px 18px rgba(0,0,0,.15)' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={newsUrl}
|
||||||
|
onChange={(e) => setNewsUrl(e.target.value)}
|
||||||
|
placeholder="뉴스 URL을 입력하세요"
|
||||||
|
style={{ width: 'calc(100% - 25px)', marginBottom: 8, padding: '10px 12px', borderRadius: 8, border: '1px solid #ffd6c2' }}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
submitNews();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowNewsEditor(false);
|
||||||
|
setNewsUrl('');
|
||||||
|
}}
|
||||||
|
style={{ background: '#fff', border: '1px solid #ddd', padding: '8px 12px', borderRadius: 8 }}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button onClick={submitNews} style={{ background: '#ff9800', color: '#fff', border: 'none', padding: '8px 12px', borderRadius: 8 }}>등록</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
547
src/pages/ChatPage.jsx
Normal file
547
src/pages/ChatPage.jsx
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useTool } from '../context/ToolContext';
|
||||||
|
import DevChatInput from '../tools/dev_chatbot/ChatInput.jsx';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
marked.setOptions({ mangle:false, headerIds:false });
|
||||||
|
import { fetchOpenAIChat, fetchOpenAIImage, classifyRequest } from '../services/openaiService';
|
||||||
|
|
||||||
|
// === Chat History Panel 컴포넌트 추가 ===
|
||||||
|
function ChatHistoryPanel({ toolId }) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [messages, setMessages] = React.useState([]);
|
||||||
|
|
||||||
|
// 패널이 열릴 때마다 로컬스토리지에서 메시지 불러오기
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem(`messages_${toolId}`) || '[]');
|
||||||
|
setMessages(Array.isArray(saved) ? saved : []);
|
||||||
|
} catch {
|
||||||
|
setMessages([]);
|
||||||
|
}
|
||||||
|
}, [toolId, open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button className="chat-history-toggle" onClick={() => setOpen(!open)} aria-label="대화 히스토리 토글">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</button>
|
||||||
|
<div className={`chat-history-panel ${open ? 'show' : ''}`}>
|
||||||
|
<div style={{ padding: '12px', fontWeight: 'bold', borderBottom: '1px solid #ffe8d5' }}>대화 히스토리</div>
|
||||||
|
<ul className="chat-history-list">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<li style={{ padding: '12px', color: '#999' }}>대화 기록이 없습니다.</li>
|
||||||
|
)}
|
||||||
|
{messages.map((m, idx) => (
|
||||||
|
<li key={idx} className="history-item">
|
||||||
|
<div className="history-content">{String(m.content).slice(0, 36)}{String(m.content).length > 36 ? '…' : ''}</div>
|
||||||
|
<div className="history-time">{m.time}</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileSidebar() {
|
||||||
|
const [files, setFiles] = useState([]);
|
||||||
|
|
||||||
|
const loadFiles = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('http://localhost:8010/files');
|
||||||
|
const data = await res.json();
|
||||||
|
setFiles(data.files || []);
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFiles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDelete = async (fname) => {
|
||||||
|
if (!window.confirm(`${fname} 파일을 삭제하시겠습니까?`)) return;
|
||||||
|
await fetch(`http://localhost:8010/file?filename=${encodeURIComponent(fname)}`, { method: 'DELETE' });
|
||||||
|
loadFiles();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async (e) => {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
if (!files.length) return;
|
||||||
|
const formData = new FormData();
|
||||||
|
files.forEach((f) => formData.append('files', f));
|
||||||
|
await fetch('http://localhost:8010/upload_pdf', { method: 'POST', body: formData });
|
||||||
|
loadFiles();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="file-sidebar">
|
||||||
|
<div className="file-sidebar-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span>파일 리스트</span>
|
||||||
|
<button className="file-add-btn" onClick={() => document.getElementById('dev-file-input').click()}>+</button>
|
||||||
|
<input id="dev-file-input" type="file" multiple style={{ display: 'none' }} onChange={handleUpload} />
|
||||||
|
</div>
|
||||||
|
<ul className="file-list">
|
||||||
|
{files.map((f) => (
|
||||||
|
<li key={f} title={f} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f}</span>
|
||||||
|
<button className="file-del-btn" onClick={() => handleDelete(f)}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
const { selectedTool } = useTool();
|
||||||
|
const [messages, setMessages] = useState(() => {
|
||||||
|
try {
|
||||||
|
const toolIdInitial = selectedTool?.id || 'chatgpt';
|
||||||
|
const saved = JSON.parse(localStorage.getItem(`messages_${toolIdInitial}`) || '[]');
|
||||||
|
return Array.isArray(saved) ? saved.filter(m=>m.type!=='loading') : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [chatModel, setChatModel] = useState('GPT-5');
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const chatEndRef = useRef(null);
|
||||||
|
|
||||||
|
// 기본 카드: ChatGPT (도구 목록에서 제거되었기 때문에 selectedTool 이 없으면 ChatGPT로 동작)
|
||||||
|
const defaultChatTool = { id: 'chatgpt', name: 'ChatGPT', description: '일반 대화 및 모델 선택 기능을 제공합니다.' };
|
||||||
|
const activeTool = selectedTool || defaultChatTool;
|
||||||
|
|
||||||
|
// 히스토리 패널용 공통 값
|
||||||
|
const toolId = selectedTool?.id || 'chatgpt';
|
||||||
|
const historyPanel = <ChatHistoryPanel toolId={toolId} />;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 카드 전환 시 해당 카드의 저장된 대화 불러오기 (기본: chatgpt)
|
||||||
|
const toolId = selectedTool?.id || 'chatgpt';
|
||||||
|
try {
|
||||||
|
const key = `messages_${toolId}`;
|
||||||
|
const saved = JSON.parse(localStorage.getItem(key) || '[]');
|
||||||
|
setMessages(Array.isArray(saved) ? saved.filter(m=>m.type!=='loading') : []);
|
||||||
|
} catch {
|
||||||
|
setMessages([]);
|
||||||
|
}
|
||||||
|
}, [selectedTool?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// scroll to bottom
|
||||||
|
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// 대화 변경 시 로컬스토리지에 저장 (기본: chatgpt)
|
||||||
|
useEffect(() => {
|
||||||
|
const toolId = selectedTool?.id || 'chatgpt';
|
||||||
|
try { localStorage.setItem(`messages_${toolId}`, JSON.stringify(messages)); } catch {}
|
||||||
|
}, [messages, selectedTool?.id]);
|
||||||
|
|
||||||
|
|
||||||
|
// iframe 도구 처리
|
||||||
|
const iframeTools = {
|
||||||
|
research_qa: 'http://yongin-qa-chatbot.daewoongai.com/',
|
||||||
|
lims_text2sql: 'http://3.38.184.255:8080/',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (selectedTool && iframeTools[selectedTool.id]) {
|
||||||
|
return (
|
||||||
|
<section className="page page-chat" style={{ flex: 1, display: 'flex', flexDirection: 'column', position:'relative' }}>
|
||||||
|
{historyPanel}
|
||||||
|
<iframe
|
||||||
|
src={iframeTools[selectedTool.id]}
|
||||||
|
className="external-iframe"
|
||||||
|
title="external"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTool && selectedTool.id === 'dev_chatbot') {
|
||||||
|
return (
|
||||||
|
<section className="page page-chat has-file-sidebar" style={{ flex: 1, position: 'relative', display:'flex', justifyContent:'flex-start', alignItems:'stretch' }}>
|
||||||
|
{historyPanel}
|
||||||
|
<FileSidebar />
|
||||||
|
{/* DevChatInput renders header, messages, input */}
|
||||||
|
<DevChatInput />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 문서번역 도구: Word 파일 업로드/목록/다운로드/삭제 + 실시간 텍스트 번역 지원
|
||||||
|
if (selectedTool && selectedTool.id === 'doc_translation') {
|
||||||
|
return (
|
||||||
|
<section className="page page-chat has-file-sidebar" style={{ flex: 1, position: 'relative', display:'flex', justifyContent:'flex-start', alignItems:'stretch' }}>
|
||||||
|
{historyPanel}
|
||||||
|
<DocTranslationSidebar />
|
||||||
|
<DocTranslationChat />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendMessage = async () => {
|
||||||
|
if (!input.trim()) return;
|
||||||
|
const userMsg = { type: 'user', content: input.trim(), time: new Date().toLocaleTimeString() };
|
||||||
|
setMessages((prev) => [...prev, userMsg]);
|
||||||
|
setInput('');
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.style.height = '32px';
|
||||||
|
}
|
||||||
|
|
||||||
|
// loading placeholder
|
||||||
|
const loadingMsg = { type: 'loading', start: Date.now() };
|
||||||
|
setMessages((prev) => [...prev, loadingMsg]);
|
||||||
|
|
||||||
|
// interval to update elapsed seconds
|
||||||
|
const loadingInterval = setInterval(() => {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
if (last && last.type === 'loading') return [...prev]; // trigger rerender
|
||||||
|
clearInterval(loadingInterval);
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// backend 호출
|
||||||
|
try {
|
||||||
|
let botContent = '';
|
||||||
|
if (activeTool.id === 'chatgpt') {
|
||||||
|
const modelMap = {
|
||||||
|
'GPT-5': 'gpt-5',
|
||||||
|
'GPT-5-mini': 'gpt-5-mini',
|
||||||
|
'GPT-5-nano': 'gpt-5-nano',
|
||||||
|
'GPT-4o': 'gpt-4o',
|
||||||
|
'GPT-4.1-mini': 'gpt-4o-mini',
|
||||||
|
'o4-mini': 'o4-mini',
|
||||||
|
};
|
||||||
|
const model = modelMap[chatModel] || modelMap['GPT-5'];
|
||||||
|
const sysPrompt = `당신은 한국어로 답하는 뛰어난 AI 어시스턴트입니다.
|
||||||
|
|
||||||
|
✅ 반드시 사실(팩트)에 기반하여 거짓 정보 없이 답하십시오.
|
||||||
|
✅ 먼저 내부적으로 생각(Chain-of-Thought)을 수행한 뒤, 이용자에게는 정리된 결과만 보여 주세요.
|
||||||
|
✅ 충분한 길이(최소 5문단)로, 필요 시 제목·소제목·순서/불릿 목록·코드 블록을 포함한 **Markdown** 형식을 사용하십시오.
|
||||||
|
✅ 이해를 돕기 위해 😃, 📌, 💡 등 적절한 이모지를 활용하십시오.
|
||||||
|
✅ 요약만 제시하지 말고, 예시·배경 설명·추가 정보를 상세히 제공합니다.
|
||||||
|
✅ 가능하다면 신뢰할 수 있는 공개 출처 URL 또는 참고문헌을 맨 아래에 제시하십시오.`;
|
||||||
|
// 이미지 생성 의도 간단 감지 (예: 이미지/그려줘/생성해줘/그림/사진 등 키워드)
|
||||||
|
// 1차: 키워드 룰기반, 2차: 모델 분류기로 보강 (샘플코드 방식)
|
||||||
|
let wantsImage = /\b(이미지|그려줘|그림|사진|image|draw|generate\s+image|그려|create\s+an?\s+image|render)\b/i.test(userMsg.content);
|
||||||
|
if (!wantsImage) {
|
||||||
|
try {
|
||||||
|
const cls = await classifyRequest(userMsg.content, 'gpt-5');
|
||||||
|
wantsImage = cls === 'image';
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
if (wantsImage) {
|
||||||
|
try {
|
||||||
|
const dataUrl = await fetchOpenAIImage(userMsg.content, 'gpt-image-1', { size: '1024x1024' });
|
||||||
|
// 이미지 응답은 전용 타입으로 렌더링
|
||||||
|
clearInterval(loadingInterval);
|
||||||
|
const botMsg = { type: 'image', content: dataUrl, time: new Date().toLocaleTimeString() };
|
||||||
|
setMessages((prev) => {
|
||||||
|
const copy = [...prev];
|
||||||
|
const idx = copy.findIndex((m) => m.type === 'loading');
|
||||||
|
if (idx !== -1) copy[idx] = botMsg; else copy.push(botMsg);
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
return; // 조기 반환 (텍스트 처리 불필요)
|
||||||
|
} catch (err) {
|
||||||
|
// 이미지 생성 실패 시 텍스트 답변으로 폴백
|
||||||
|
botContent = await fetchOpenAIChat([
|
||||||
|
{ role: 'system', content: sysPrompt },
|
||||||
|
{ role: 'user', content: userMsg.content },
|
||||||
|
], model, { });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
botContent = await fetchOpenAIChat([
|
||||||
|
{ role: 'system', content: sysPrompt },
|
||||||
|
{ role: 'user', content: userMsg.content },
|
||||||
|
], model, { });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('message', userMsg.content);
|
||||||
|
formData.append('tool_id', activeTool.id);
|
||||||
|
const res = await fetch('http://localhost:8010/chat', { method: 'POST', body: formData });
|
||||||
|
const data = await res.json();
|
||||||
|
botContent = data.response;
|
||||||
|
}
|
||||||
|
clearInterval(loadingInterval);
|
||||||
|
const botMsg = { type: 'bot', content: botContent, time: new Date().toLocaleTimeString(), markdown:true };
|
||||||
|
// replace loading placeholder
|
||||||
|
setMessages((prev) => {
|
||||||
|
const copy = [...prev];
|
||||||
|
const idx = copy.findIndex((m) => m.type === 'loading');
|
||||||
|
if (idx !== -1) copy[idx] = botMsg;
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setMessages((prev) => [...prev, { type: 'bot', content: '서버 오류', time: new Date().toLocaleTimeString() }]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page page-chat" style={{ flex: 1, display:'flex', flexDirection:'column', position:'relative' }}>
|
||||||
|
{historyPanel}
|
||||||
|
<div className="chat-center" style={{ width: '100%', maxWidth: 800 }}>
|
||||||
|
<div className="tool-chat-header">
|
||||||
|
<div className="tool-chat-title">{activeTool.name}</div>
|
||||||
|
<div className="tool-chat-desc">{activeTool.description}</div>
|
||||||
|
</div>
|
||||||
|
{messages.map((m, idx) => (
|
||||||
|
<div key={idx} className={`chat-message ${
|
||||||
|
m.type === 'user' ? 'user-message' : (
|
||||||
|
m.type === 'bot' || m.type === 'image' ? 'bot-message' : 'bot-message progress'
|
||||||
|
)}`}>
|
||||||
|
{m.type==='loading' ? (
|
||||||
|
<div className="message-content" style={{color:'#888',fontStyle:'italic'}}>생각중...</div>
|
||||||
|
) : m.type==='image' ? (
|
||||||
|
<div className="message-content"><img src={m.content} alt="generated" style={{ maxWidth:'100%', borderRadius:12 }}/></div>
|
||||||
|
) : m.markdown ? (
|
||||||
|
<div className="message-content" dangerouslySetInnerHTML={{ __html: marked.parse(m.content) }} />
|
||||||
|
) : (
|
||||||
|
<div className="message-content">{m.content}</div>
|
||||||
|
)}
|
||||||
|
<div className="message-time">{ m.type==='loading' ? `${Math.floor((Date.now()-m.start)/1000)}s` : m.time}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={chatEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chat-input-wrapper">
|
||||||
|
<div className="chat-input-area">
|
||||||
|
{activeTool.id==='chatgpt' && (
|
||||||
|
<select value={chatModel} onChange={(e)=>setChatModel(e.target.value)} style={{ marginRight:12, height:36, borderRadius:12, border:'1px solid #ffd6c2', color:'#ff6b4a' }}>
|
||||||
|
<option value="GPT-5">GPT-5</option>
|
||||||
|
<option value="GPT-5-mini">GPT-5-mini</option>
|
||||||
|
<option value="GPT-5-nano">GPT-5-nano</option>
|
||||||
|
<option value="GPT-4o">GPT-4o</option>
|
||||||
|
<option value="GPT-4.1-mini">GPT-4.1-mini</option>
|
||||||
|
<option value="o4-mini">o4-mini</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
className="chat-input"
|
||||||
|
rows={1}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onInput={(e)=>{ e.target.style.height='auto'; e.target.style.height=Math.min(e.target.scrollHeight,240)+"px"; }}
|
||||||
|
placeholder="메시지 입력"
|
||||||
|
style={{ flex: 1, resize: 'none', border: 'none', outline: 'none', overflowY:'auto' }}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className="chat-send" onClick={sendMessage} disabled={!input.trim()}>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocTranslationSidebar() {
|
||||||
|
const [files, setFiles] = useState([]);
|
||||||
|
|
||||||
|
const extractRealFilename = (filename) => {
|
||||||
|
const regex = /^\[doc_translation\]_admin_\d{14,17}_(.+)$/;
|
||||||
|
const match = filename.match(regex);
|
||||||
|
return match ? match[1] : filename;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadFiles = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('http://localhost:8010/doc_translation/files');
|
||||||
|
const data = await res.json();
|
||||||
|
setFiles(Array.isArray(data) ? data : []);
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { loadFiles(); }, []);
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = '.doc,.docx';
|
||||||
|
input.multiple = true;
|
||||||
|
input.style.display = 'none';
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.onchange = async () => {
|
||||||
|
const files = Array.from(input.files || []);
|
||||||
|
if (!files.length) return;
|
||||||
|
const formData = new FormData();
|
||||||
|
files.forEach((f) => formData.append('files', f));
|
||||||
|
try {
|
||||||
|
await fetch('http://localhost:8010/doc_translation/upload_doc', { method: 'POST', body: formData });
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(input);
|
||||||
|
loadFiles();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (serverFilename) => {
|
||||||
|
if (!window.confirm(`${extractRealFilename(serverFilename)} 파일을 삭제하시겠습니까?`)) return;
|
||||||
|
await fetch(`http://localhost:8010/doc_translation/files/${encodeURIComponent(serverFilename)}`, { method: 'DELETE' });
|
||||||
|
loadFiles();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (filename) => {
|
||||||
|
const res = await fetch(`http://localhost:8010/doc_translation/download/${encodeURIComponent(filename)}`);
|
||||||
|
if (!res.ok) return alert('다운로드 실패');
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = extractRealFilename(filename);
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="file-sidebar">
|
||||||
|
<div className="file-sidebar-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span>파일 리스트</span>
|
||||||
|
<button className="file-add-btn" onClick={handleUpload}>+</button>
|
||||||
|
</div>
|
||||||
|
<ul className="file-list">
|
||||||
|
{files.map((f) => (
|
||||||
|
<li key={f.filename} className={f.has_result ? 'translation-result-item' : ''} data-fname={f.filename} title={extractRealFilename(f.filename)}>
|
||||||
|
{f.has_result ? (
|
||||||
|
<div className="file-item-content">
|
||||||
|
<div className="original-file">{extractRealFilename(f.filename)}</div>
|
||||||
|
<div className="file-actions">
|
||||||
|
<a href="#" className="download-original-link" onClick={(e)=>{e.preventDefault(); handleDownload(f.filename);}}>[원본다운]</a>
|
||||||
|
<a href="#" className="download-result-link" onClick={(e)=>{e.preventDefault(); handleDownload(f.result_filename);}}>[결과다운]</a>
|
||||||
|
<button className="file-del-btn" onClick={()=>handleDelete(f.filename)}>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="translating">번역중...</span>
|
||||||
|
<button className="file-del-btn" onClick={()=>handleDelete(f.filename)}>✕</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocTranslationChat() {
|
||||||
|
const { selectedTool } = useTool();
|
||||||
|
const [messages, setMessages] = useState([]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [model, setModel] = useState('internal'); // internal | external
|
||||||
|
const inputRef2 = useRef(null);
|
||||||
|
const chatEndRef = useRef(null);
|
||||||
|
|
||||||
|
// 카드 전환 시 저장된 대화 불러오기
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem('messages_doc_translation') || '[]');
|
||||||
|
setMessages(Array.isArray(saved) ? saved : []);
|
||||||
|
} catch { setMessages([]); }
|
||||||
|
}, [selectedTool?.id]);
|
||||||
|
useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
|
||||||
|
// 변경 시 저장
|
||||||
|
useEffect(() => {
|
||||||
|
try { localStorage.setItem('messages_doc_translation', JSON.stringify(messages)); } catch {}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const sendMessage = async () => {
|
||||||
|
if (!input.trim()) return;
|
||||||
|
const userMsg = { type: 'user', content: input.trim(), time: new Date().toLocaleTimeString() };
|
||||||
|
setMessages((prev) => [...prev, userMsg, { type: 'loading', start: Date.now() }]);
|
||||||
|
setInput('');
|
||||||
|
if (inputRef2.current) inputRef2.current.style.height = '32px';
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('message', userMsg.content);
|
||||||
|
// ChatPage 공통 /chat 엔드포인트의 문서번역 분기와 동일 동작을 위해 model 전달
|
||||||
|
form.append('tool_id', 'doc_translation');
|
||||||
|
form.append('model', model === 'internal' ? 'internal' : 'gpt-4o');
|
||||||
|
const res = await fetch('http://localhost:8010/chat', { method: 'POST', body: form });
|
||||||
|
const data = await res.json();
|
||||||
|
const botContent = data.response;
|
||||||
|
setMessages((prev) => {
|
||||||
|
const copy = [...prev];
|
||||||
|
const idx = copy.findIndex((m) => m.type === 'loading');
|
||||||
|
const botMsg = { type: 'bot', content: botContent, time: new Date().toLocaleTimeString(), markdown: true };
|
||||||
|
if (idx !== -1) copy[idx] = botMsg; else copy.push(botMsg);
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setMessages((prev) => [...prev, { type: 'bot', content: '서버 오류', time: new Date().toLocaleTimeString() }]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="chat-center" style={{ width: '100%', maxWidth: 800 }}>
|
||||||
|
<div className="tool-chat-header">
|
||||||
|
<div className="tool-chat-title">문서번역</div>
|
||||||
|
<div className="tool-chat-desc">MS Word 문서를 업로드하면 한글을 영어로 번역합니다.</div>
|
||||||
|
</div>
|
||||||
|
{messages.map((m, idx) => (
|
||||||
|
<div key={idx} className={`chat-message ${m.type === 'user' ? 'user-message' : (m.type==='bot' ? 'bot-message' : 'bot-message progress')}`}>
|
||||||
|
{m.type==='loading' ? (
|
||||||
|
<div className="message-content" style={{color:'#888',fontStyle:'italic'}}>생각중...</div>
|
||||||
|
) : m.markdown ? (
|
||||||
|
<div className="message-content" dangerouslySetInnerHTML={{ __html: marked.parse(m.content) }} />
|
||||||
|
) : (
|
||||||
|
<div className="message-content">{m.content}</div>
|
||||||
|
)}
|
||||||
|
<div className="message-time">{ m.type==='loading' ? `${Math.floor((Date.now()-m.start)/1000)}s` : m.time}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={chatEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chat-input-wrapper">
|
||||||
|
<div className="chat-input-area">
|
||||||
|
<select value={model} onChange={(e)=>setModel(e.target.value)} style={{ marginRight:12, height:36, borderRadius:12, border:'1px solid #ffd6c2', color:'#ff6b4a' }}>
|
||||||
|
<option value="internal">자체모델</option>
|
||||||
|
<option value="external">외부모델</option>
|
||||||
|
</select>
|
||||||
|
<textarea
|
||||||
|
ref={inputRef2}
|
||||||
|
className="chat-input"
|
||||||
|
rows={1}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onInput={(e)=>{ e.target.style.height='auto'; e.target.style.height=Math.min(e.target.scrollHeight,240)+"px"; }}
|
||||||
|
placeholder="번역할 한국어 텍스트를 입력하세요"
|
||||||
|
style={{ flex: 1, resize: 'none', border: 'none', outline: 'none', overflowY:'auto' }}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className="chat-send" onClick={sendMessage} disabled={!input.trim()}>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/pages/LecturePage.jsx
Normal file
79
src/pages/LecturePage.jsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{
|
||||||
|
title: '강수진박사의 프롬프트 엔지니어링의 세계',
|
||||||
|
videos: [
|
||||||
|
'https://www.youtube.com/watch?v=GlvOHXJT_gI&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=14&t=26s',
|
||||||
|
'https://www.youtube.com/watch?v=nl34M5bKkVM&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=12',
|
||||||
|
'https://www.youtube.com/watch?v=0sRlrW_UyLk&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=11',
|
||||||
|
'https://www.youtube.com/watch?v=MtTAprzHOBg&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=10',
|
||||||
|
'https://www.youtube.com/watch?v=CyWcZWVwCjQ&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=9',
|
||||||
|
'https://www.youtube.com/watch?v=X7ycln4JREM&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=8',
|
||||||
|
'https://www.youtube.com/watch?v=rrCmsOFt2UU&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=7',
|
||||||
|
'https://www.youtube.com/watch?v=7cyDMjzcdxM&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=6',
|
||||||
|
'https://www.youtube.com/watch?v=Sr4MEivnt4M&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=5',
|
||||||
|
'https://www.youtube.com/watch?v=F4ExQ3P_A5w&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=4',
|
||||||
|
'https://www.youtube.com/watch?v=tiC5k9P93cE&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=3',
|
||||||
|
'https://www.youtube.com/watch?v=nfPXfsVz6jM&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=2',
|
||||||
|
'https://www.youtube.com/watch?v=9NH3FhBX2ng&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=1',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'AI, 안해보면 모른다',
|
||||||
|
videos: [
|
||||||
|
'https://www.youtube.com/watch?v=menXWx89QFg&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=14',
|
||||||
|
'https://www.youtube.com/watch?v=6IsYJy3ussQ&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=13',
|
||||||
|
'https://www.youtube.com/watch?v=Mpk4LNZ_P4c&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=12',
|
||||||
|
'https://www.youtube.com/watch?v=r2UMvkwJTRc&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=11',
|
||||||
|
'https://www.youtube.com/watch?v=4uc58O4poJE&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=10',
|
||||||
|
'https://www.youtube.com/watch?v=njAu-W-JA6c&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=9',
|
||||||
|
'https://www.youtube.com/watch?v=SaYxoKVYgsk&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=8',
|
||||||
|
'https://www.youtube.com/watch?v=sDDW5w2jD2Q&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=7',
|
||||||
|
'https://www.youtube.com/watch?v=Ext47QeBCbY&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=6',
|
||||||
|
'https://www.youtube.com/watch?v=og0mGD29Hw8&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=5',
|
||||||
|
'https://www.youtube.com/watch?v=GkZgTth3guQ&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=4',
|
||||||
|
'https://www.youtube.com/watch?v=oSZBGZmaTTw&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=3',
|
||||||
|
'https://www.youtube.com/watch?v=t6Xc1Ey5PMY&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=2',
|
||||||
|
'https://www.youtube.com/watch?v=aFw4Z9F4Ens&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=1',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'MCP를 배워보자',
|
||||||
|
videos: [
|
||||||
|
'https://www.youtube.com/watch?v=kEAV-PqWD_4',
|
||||||
|
'https://www.youtube.com/watch?v=ha9kn0qe4Mc',
|
||||||
|
'https://www.youtube.com/watch?v=ISrYHGg2C2c',
|
||||||
|
'https://www.youtube.com/watch?v=cxOoV2guNQQ',
|
||||||
|
'https://www.youtube.com/watch?v=oAxunD8k0C8',
|
||||||
|
'https://www.youtube.com/watch?v=oZ1O6Z9HwW0',
|
||||||
|
'https://www.youtube.com/watch?v=nyZnrKVaIXU',
|
||||||
|
'https://www.youtube.com/watch?v=BzwhskWZ-CQ',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function toThumb(url) {
|
||||||
|
const match = url.match(/(?:v=|\.be\/|embed\/)([\w-]{11})/);
|
||||||
|
const id = match ? match[1] : '';
|
||||||
|
return id ? `https://img.youtube.com/vi/${id}/hqdefault.jpg` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LecturePage() {
|
||||||
|
return (
|
||||||
|
<section className="page page-lecture" style={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '24px' }}>
|
||||||
|
{SECTIONS.map((sec) => (
|
||||||
|
<React.Fragment key={sec.title}>
|
||||||
|
<h3 className="lecture-topic" style={{ marginTop: sec.title===SECTIONS[0].title ? 0 : 24 }}>{sec.title}</h3>
|
||||||
|
<div className="lecture-video-list">
|
||||||
|
{sec.videos.map((url) => (
|
||||||
|
<a key={url} href={url} target="_blank" rel="noreferrer" className="lecture-video">
|
||||||
|
<img src={toThumb(url)} alt="video thumbnail" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/pages/LoginPage.jsx
Normal file
39
src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
export default function LoginPage({ onLoggedIn }) {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await login(email.trim(), password);
|
||||||
|
onLoggedIn?.();
|
||||||
|
} catch (err) {
|
||||||
|
setError('이메일 또는 비밀번호가 올바르지 않습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page" style={{ flex: 1, display:'flex', alignItems:'center', justifyContent:'center' }}>
|
||||||
|
<form onSubmit={handleSubmit} style={{ width:'100%', maxWidth:480, padding:'0 16px' }}>
|
||||||
|
<div style={{ marginBottom:16, background:'#f7f7f7', borderRadius:12, padding:'16px 18px' }}>
|
||||||
|
<input type="email" placeholder="이메일" value={email} onChange={(e)=>setEmail(e.target.value)}
|
||||||
|
style={{ width:'100%', background:'transparent', border:'none', outline:'none', fontSize:'1.1rem' }} required />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom:24, background:'#f7f7f7', borderRadius:12, padding:'16px 18px' }}>
|
||||||
|
<input type="password" placeholder="비밀번호" value={password} onChange={(e)=>setPassword(e.target.value)}
|
||||||
|
style={{ width:'100%', background:'transparent', border:'none', outline:'none', fontSize:'1.1rem' }} required />
|
||||||
|
</div>
|
||||||
|
{error && <div style={{ color:'#e53935', marginBottom:12, textAlign:'center' }}>{error}</div>}
|
||||||
|
<button type="submit" style={{ width:'100%', height:64, borderRadius:18, border:'none', background:'#111', color:'#fff', fontSize:'1.4rem', fontWeight:700, cursor:'pointer' }}>로그인</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
107
src/pages/QnaPage.jsx
Normal file
107
src/pages/QnaPage.jsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
async function fetchQnaList() {
|
||||||
|
const res = await fetch('http://localhost:8010/community/qna');
|
||||||
|
if (!res.ok) throw new Error('QNA 목록 조회 실패');
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QnaPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [qna, setQna] = useState([]);
|
||||||
|
const [openId, setOpenId] = useState(null);
|
||||||
|
const [openContent, setOpenContent] = useState('');
|
||||||
|
const [showEditor, setShowEditor] = useState(false);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchQnaList().then(setQna).catch(() => setQna([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpen = async (id) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://localhost:8010/community/qna/${id}?increase=1`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
setOpenId(id);
|
||||||
|
setOpenContent(data.content);
|
||||||
|
fetchQnaList().then(setQna).catch(() => {});
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitQna = async () => {
|
||||||
|
if (!title.trim() || !content.trim()) return;
|
||||||
|
const res = await fetch('http://localhost:8010/community/qna', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title, content, author_id: String(user?.user_id || ''), author_email: user?.email || null }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setTitle('');
|
||||||
|
setContent('');
|
||||||
|
setShowEditor(false);
|
||||||
|
fetchQnaList().then(setQna).catch(() => {});
|
||||||
|
} else {
|
||||||
|
const errText = await res.text().catch(() => '');
|
||||||
|
alert('등록 실패: ' + errText);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page page-qna" style={{ flex: 1, display: 'block', paddingTop: '24px' }}>
|
||||||
|
<div style={{ width: '100%', display: 'flex', justifyContent: 'flex-end', margin: '0 20px 12px 20px' }}>
|
||||||
|
<button onClick={() => setShowEditor(true)} style={{ background: '#fff8ef', color: '#ff9800', border: '1px solid #FFE0B2', padding: '8px 12px', borderRadius: 8, cursor: 'pointer', marginRight: 40 }}>글쓰기</button>
|
||||||
|
</div>
|
||||||
|
<table className="qna-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 60 }}>번호</th>
|
||||||
|
<th>제목</th>
|
||||||
|
<th style={{ width: 120 }}>등록일</th>
|
||||||
|
<th style={{ width: 80 }}>조회수</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{qna.map((r) => (
|
||||||
|
<React.Fragment key={r.id}>
|
||||||
|
<tr>
|
||||||
|
<td>{r.id}</td>
|
||||||
|
<td className="qna-title">
|
||||||
|
<a href="#" onClick={(e) => { e.preventDefault(); handleOpen(r.id); }}>{r.title}</a>
|
||||||
|
</td>
|
||||||
|
<td>{new Date(r.created_at).toISOString().slice(0, 10)}</td>
|
||||||
|
<td>{r.views}</td>
|
||||||
|
</tr>
|
||||||
|
{openId === r.id && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4}>
|
||||||
|
<div style={{ background: '#fff8ef', border: '1px solid #FFE0B2', borderRadius: 12, padding: 16 }}>
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap', lineHeight: 1.5 }}>{openContent}</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 8 }}>
|
||||||
|
<button onClick={() => setOpenId(null)} style={{ background: '#fff', border: '1px solid #ddd', padding: '6px 10px', borderRadius: 8 }}>닫기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{showEditor && (
|
||||||
|
<div style={{ width: 'calc(100% - 80px)', margin: '16px 20px 0 20px', background: '#fff', border: '1px solid #ffd6c2', borderRadius: 12, padding: 16 }}>
|
||||||
|
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="제목" style={{ width: 'calc(100% - 40px)', marginBottom: 8, padding: '10px 12px', borderRadius: 8, border: '1px solid #ffd6c2' }} />
|
||||||
|
<textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder="내용" rows={6} style={{ width: 'calc(100% - 40px)', padding: '10px 12px', borderRadius: 8, border: '1px solid #ffd6c2' }} />
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
|
||||||
|
<button onClick={() => setShowEditor(false)} style={{ background: '#fff', border: '1px solid #ddd', padding: '8px 12px', borderRadius: 8 }}>취소</button>
|
||||||
|
<button onClick={submitQna} style={{ background: '#ff9800', color: '#fff', border: 'none', padding: '8px 12px', borderRadius: 8 }}>등록</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
63
src/pages/ToolsPage.jsx
Normal file
63
src/pages/ToolsPage.jsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useTool } from '../context/ToolContext';
|
||||||
|
|
||||||
|
export default function ToolsPage({ onNavigate }) {
|
||||||
|
const { tools, favorites, toggleFavorite, setSelectedTool } = useTool();
|
||||||
|
const [filter, setFilter] = useState('all');
|
||||||
|
|
||||||
|
const filtered = tools.filter((tool) => {
|
||||||
|
if (filter === 'all') return true;
|
||||||
|
if (filter === 'favorite') return favorites.includes(tool.id);
|
||||||
|
return tool.category?.includes(filter);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSelect = (tool) => {
|
||||||
|
setSelectedTool(tool);
|
||||||
|
onNavigate && onNavigate('chat');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page page-tools" style={{ flex: 1, display:'flex', flexDirection:'column' }}>
|
||||||
|
<div className="tools-header">
|
||||||
|
<span className="tools-title">도구 목록</span>
|
||||||
|
<div className="tools-filters">
|
||||||
|
{['all', 'favorite', '오픈AI', '내부AI'].map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
className={`filter-btn ${filter === f ? 'active' : ''}`}
|
||||||
|
onClick={() => setFilter(f)}
|
||||||
|
data-filter={f}
|
||||||
|
>
|
||||||
|
{f === 'all' ? '전체' : f === 'favorite' ? '즐겨찾기' : f}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="tools-list">
|
||||||
|
{filtered.map((tool) => {
|
||||||
|
const fav = favorites.includes(tool.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="tool-card"
|
||||||
|
key={tool.id}
|
||||||
|
data-tool-id={tool.id}
|
||||||
|
onClick={() => handleSelect(tool)}
|
||||||
|
>
|
||||||
|
<div className="tool-title">{tool.name}</div>
|
||||||
|
<div className="tool-desc">{tool.description}</div>
|
||||||
|
<div
|
||||||
|
className={`tool-fav ${fav ? 'active' : ''}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleFavorite(tool.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/services/openaiService.js
Normal file
72
src/services/openaiService.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
export async function fetchOpenAIChat(messages, model="gpt-3.5-turbo", options={}) {
|
||||||
|
const apiKey = import.meta.env.VITE_OPENAI_API_KEY || import.meta.env.OPENAI_API_KEY;
|
||||||
|
if (!apiKey) throw new Error('OPENAI API KEY not set');
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`OpenAI error ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return data.choices?.[0]?.message?.content || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 샘플코드 방식: GPT-5로 이미지/텍스트 분류
|
||||||
|
export async function classifyRequest(userInput, model = 'gpt-5') {
|
||||||
|
const content = await fetchOpenAIChat(
|
||||||
|
[
|
||||||
|
{ role: 'system', content: '너는 요청 분류기다. 사용자가 이미지 생성을 원하면 image, 아니면 text라고만 답해라.' },
|
||||||
|
{ role: 'user', content: `사용자가 다음과 같은 요청을 했습니다:\n"${userInput}"\n이 요청이 '이미지 생성'에 해당하면 'image'라고만 답하고, 그렇지 않으면 'text'라고만 답하세요.` },
|
||||||
|
],
|
||||||
|
model,
|
||||||
|
{ temperature: 0 }
|
||||||
|
);
|
||||||
|
const answer = (content || '').trim().toLowerCase();
|
||||||
|
return answer.includes('image') ? 'image' : 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미지 생성 API 호출 (gpt-image-1 또는 호환 모델)
|
||||||
|
export async function fetchOpenAIImage(prompt, model = "gpt-image-1", options = {}) {
|
||||||
|
const apiKey = import.meta.env.VITE_OPENAI_API_KEY || import.meta.env.OPENAI_API_KEY;
|
||||||
|
if (!apiKey) throw new Error('OPENAI API KEY not set');
|
||||||
|
|
||||||
|
const runOnce = async (mdl) => {
|
||||||
|
const body = {
|
||||||
|
model: mdl,
|
||||||
|
prompt,
|
||||||
|
size: options.size || '1024x1024',
|
||||||
|
response_format: 'b64_json',
|
||||||
|
};
|
||||||
|
const res = await fetch('https://api.openai.com/v1/images/generations', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`OpenAI image error ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const item = data?.data?.[0] || {};
|
||||||
|
if (item.b64_json) return `data:image/png;base64,${item.b64_json}`;
|
||||||
|
if (item.url) return item.url; // some models may return URL
|
||||||
|
throw new Error('No image returned');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 우선 gpt-image-1 시도 → 실패 시 dall-e-3 폴백
|
||||||
|
try {
|
||||||
|
return await runOnce(model || 'gpt-image-1');
|
||||||
|
} catch (_) {
|
||||||
|
return await runOnce('dall-e-3');
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/style.css
Normal file
12
src/style.css
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/* Copied from original style.css */
|
||||||
|
html, body {
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'Noto Sans KR', sans-serif;
|
||||||
|
background: #FFF3E0;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
29
src/tools/dev_chatbot/AIService.js
Normal file
29
src/tools/dev_chatbot/AIService.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// AIService.js – dev_chatbot 전용
|
||||||
|
import { API_BASE_URL } from './constants';
|
||||||
|
|
||||||
|
const AIService = async (question, toolId, sessionId = null, files = []) => {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('message', question);
|
||||||
|
formData.append('tool_id', toolId);
|
||||||
|
if (sessionId) formData.append('session_id', sessionId);
|
||||||
|
files.forEach((file) => formData.append('image', file));
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const data = await response.json();
|
||||||
|
return {
|
||||||
|
response: data.response?.trim() || 'AI 응답을 생성하지 못했습니다.',
|
||||||
|
sessionId: data.session_id,
|
||||||
|
toolName: data.tool_name,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error('AIService 오류:', e);
|
||||||
|
return { response: 'AI 응답을 생성하지 못했습니다.', sessionId: null, toolName: '' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { AIService };
|
||||||
20
src/tools/dev_chatbot/ChatHandler.js
Normal file
20
src/tools/dev_chatbot/ChatHandler.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { OCRService } from './OCRService';
|
||||||
|
import { AIService } from './AIService';
|
||||||
|
|
||||||
|
const ChatHandler = async ({ text, files = [], toolId, sessionId = null }) => {
|
||||||
|
let question = text;
|
||||||
|
const imageFiles = files.filter((f) => f.type?.startsWith('image/'));
|
||||||
|
if (imageFiles.length) {
|
||||||
|
const ocrTexts = [];
|
||||||
|
for (const img of imageFiles) {
|
||||||
|
const ocrText = await OCRService(img);
|
||||||
|
if (ocrText) ocrTexts.push(ocrText);
|
||||||
|
}
|
||||||
|
if (ocrTexts.length) {
|
||||||
|
question = `${text}\n\n이미지에서 추출한 텍스트:\n${ocrTexts.join('\n')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return AIService(question, toolId, sessionId, files);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ChatHandler };
|
||||||
151
src/tools/dev_chatbot/ChatInput.jsx
Normal file
151
src/tools/dev_chatbot/ChatInput.jsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { ChatHandler } from './ChatHandler';
|
||||||
|
import { useTool } from '../../context/ToolContext';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
marked.setOptions({ mangle:false, headerIds:false });
|
||||||
|
|
||||||
|
export default function DevChatInput() {
|
||||||
|
const { selectedTool } = useTool();
|
||||||
|
const [inputText, setInputText] = useState('');
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState([]);
|
||||||
|
const [messages, setMessages] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const chatEndRef = useRef(null);
|
||||||
|
const [isComposing, setIsComposing] = useState(false);
|
||||||
|
const [enterWhileComposing, setEnterWhileComposing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Load saved messages when tool active
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem('messages_dev_chatbot') || '[]');
|
||||||
|
setMessages(Array.isArray(saved) ? saved : []);
|
||||||
|
} catch { setMessages([]); }
|
||||||
|
}, [selectedTool?.id]);
|
||||||
|
|
||||||
|
// Persist messages on change
|
||||||
|
useEffect(() => {
|
||||||
|
try { localStorage.setItem('messages_dev_chatbot', JSON.stringify(messages)); } catch {}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const handleTextChange = (e) => setInputText(e.target.value);
|
||||||
|
const handleFilesChange = (e) => {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
if (files.length) setSelectedFiles((prev) => [...prev, ...files]);
|
||||||
|
};
|
||||||
|
const handleFileRemove = (idx) => setSelectedFiles((prev) => prev.filter((_, i) => i !== idx));
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!inputText && selectedFiles.length === 0) return;
|
||||||
|
setLoading(true);
|
||||||
|
const userMessage = { text: inputText, files: selectedFiles, sender: 'user', time: new Date().toLocaleTimeString() };
|
||||||
|
setMessages((prev) => [...prev, userMessage]);
|
||||||
|
const { response } = await ChatHandler({ text: inputText, files: selectedFiles, toolId: 'dev_chatbot' });
|
||||||
|
setMessages((prev) => [...prev, { text: response, sender: 'ai', time: new Date().toLocaleTimeString() }]);
|
||||||
|
setInputText('');
|
||||||
|
setSelectedFiles([]);
|
||||||
|
setLoading(false);
|
||||||
|
if (inputRef.current) inputRef.current.style.height = '32px';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', position:'relative' }}>
|
||||||
|
<div className="chat-center" style={{ flex: 1, overflowY: 'auto' }}>
|
||||||
|
{/* Tool header */}
|
||||||
|
{selectedTool && (
|
||||||
|
<div className="tool-chat-header">
|
||||||
|
<div className="tool-chat-title">{selectedTool.name}</div>
|
||||||
|
<div className="tool-chat-desc">{selectedTool.description}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
{messages.map((msg, idx) => (
|
||||||
|
<div key={idx} className={`chat-message ${msg.sender === 'user' ? 'user-message' : 'bot-message'}`} style={{ maxWidth: '80%' }}>
|
||||||
|
{msg.sender === 'ai' ? (
|
||||||
|
<div className="message-content" dangerouslySetInnerHTML={{ __html: marked.parse(msg.text) }} />
|
||||||
|
) : (
|
||||||
|
<div className="message-content">{msg.text}</div>
|
||||||
|
)}
|
||||||
|
<div className="message-time">{msg.time}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{loading && (
|
||||||
|
<div className="chat-message bot-message loading" style={{ maxWidth: '80%' }}>
|
||||||
|
<div className="message-content">AI가 응답을 생성하고 있습니다...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={chatEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input bar */}
|
||||||
|
<div className="chat-input-wrapper">
|
||||||
|
{selectedFiles.length > 0 && (
|
||||||
|
<div className="image-preview-area" style={{ marginBottom: 8 }}>
|
||||||
|
{selectedFiles.map((file, idx) => (
|
||||||
|
<div key={idx} className="image-preview-box">
|
||||||
|
<span className="image-preview-name">{file.name}</span>
|
||||||
|
<button className="image-preview-remove" onClick={() => handleFileRemove(idx)}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="chat-input-area" style={{ width: '100%' }}>
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
className="chat-input"
|
||||||
|
rows={1}
|
||||||
|
value={inputText}
|
||||||
|
onChange={handleTextChange}
|
||||||
|
onInput={(e)=>{ e.target.style.height='auto'; e.target.style.height=Math.min(e.target.scrollHeight,240)+"px"; }}
|
||||||
|
placeholder="메시지 입력"
|
||||||
|
style={{ flex: 1, resize: 'none', border: 'none', outline: 'none', overflowY:'auto' }}
|
||||||
|
onCompositionStart={() => {
|
||||||
|
setIsComposing(true);
|
||||||
|
}}
|
||||||
|
onCompositionEnd={() => {
|
||||||
|
setIsComposing(false);
|
||||||
|
if (enterWhileComposing) {
|
||||||
|
setEnterWhileComposing(false);
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
if (isComposing) {
|
||||||
|
setEnterWhileComposing(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
onChange={handleFilesChange}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
id="dev-hidden-file-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="chat-send"
|
||||||
|
onClick={() => document.getElementById('dev-hidden-file-input').click()}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
>
|
||||||
|
📎
|
||||||
|
</button>
|
||||||
|
<button className="chat-send" onClick={handleSend} disabled={loading || !inputText.trim()}>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/tools/dev_chatbot/OCRService.js
Normal file
14
src/tools/dev_chatbot/OCRService.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Tesseract from 'tesseract.js';
|
||||||
|
|
||||||
|
const OCRService = async (imageFile) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
data: { text },
|
||||||
|
} = await Tesseract.recognize(imageFile, 'kor+eng');
|
||||||
|
return text.trim();
|
||||||
|
} catch (e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { OCRService };
|
||||||
1
src/tools/dev_chatbot/constants.js
Normal file
1
src/tools/dev_chatbot/constants.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const API_BASE_URL = 'http://localhost:8010';
|
||||||
873
style.css
Normal file
873
style.css
Normal file
@@ -0,0 +1,873 @@
|
|||||||
|
/* prevent horizontal layout shift due to scrollbar */
|
||||||
|
html, body {
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'Noto Sans KR', sans-serif;
|
||||||
|
background: #FFF3E0;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
width: 80px;
|
||||||
|
background: linear-gradient(180deg, #FFE0B2 0%, #FFCC80 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 20px;
|
||||||
|
box-shadow: 1px 0 8px #FFD180;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #FF9800;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
.menu {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.menu ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.menu-item {
|
||||||
|
padding: 18px 0;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #333;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
.menu-item.active, .menu-item:hover {
|
||||||
|
background: #FFF3E0;
|
||||||
|
color: #FF9800;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #FFF3E0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 18px 24px 0 24px;
|
||||||
|
background: transparent;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.login-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #b2b2b2;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 6px 18px;
|
||||||
|
color: #00b6f0;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.login-btn:hover {
|
||||||
|
background: #e6f7ff;
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
display: none;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.page.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
/* 채팅 화면 */
|
||||||
|
.page-chat {
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.chat-center {
|
||||||
|
position: absolute;
|
||||||
|
top: 35%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: calc(100vh - 200px);
|
||||||
|
padding: 20px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
.chat-logo-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.chat-logo {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
background: radial-gradient(circle, #FF9800 70%, #FFE0B2 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 16px #FFD180;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.chat-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #222;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
margin: 0;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
writing-mode: initial;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.chat-subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.chat-input-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 60px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.chat-input-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 2px 8px #FFD180;
|
||||||
|
padding: 8px 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.chat-input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: #222;
|
||||||
|
resize: none;
|
||||||
|
min-height: 32px;
|
||||||
|
max-height: 240px; /* 최대 약 10줄 */
|
||||||
|
line-height: 1.5;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.chat-input:empty:before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: #bbb;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.chat-plus, .chat-hash, .chat-send {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-left: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #FF9800;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.chat-send:disabled {
|
||||||
|
color: #FF9800;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #b2b2b2;
|
||||||
|
}
|
||||||
|
/* 도구 화면 */
|
||||||
|
.page-tools {
|
||||||
|
padding: 24px 32px 0 32px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.tools-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.tools-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 24px;
|
||||||
|
}
|
||||||
|
.tools-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.filter-btn {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #FFE0B2;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
color: #FF9800;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
.filter-btn.active, .filter-btn:hover {
|
||||||
|
background: #FFCC80;
|
||||||
|
color: #FF9800;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.tools-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.tool-card {
|
||||||
|
position: relative;
|
||||||
|
background: #FFF8E1;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 2px 8px #FFE0B2;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.tool-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 16px #FFD180;
|
||||||
|
border-color: #FFB74D;
|
||||||
|
}
|
||||||
|
.tool-card.selected {
|
||||||
|
border-color: #ff6b4a;
|
||||||
|
background: linear-gradient(135deg, #fff6f3 0%, #fff 100%);
|
||||||
|
box-shadow: 0 4px 20px rgba(255, 107, 74, 0.2);
|
||||||
|
}
|
||||||
|
.tool-card:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 2px 8px #ffe0d3;
|
||||||
|
}
|
||||||
|
.tool-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
.tool-desc {
|
||||||
|
font-size: 0.98rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.tool-fav {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
right: 14px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #FFD6C2;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.tool-fav.active {
|
||||||
|
color: #FF9800;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.tools-list {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
}
|
||||||
|
.chat-input-wrapper { width: calc(95vw - 0px); max-width: calc(95vw - 0px); }
|
||||||
|
.chat-input-area {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.image-preview-area {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* 채팅 메시지 스타일 */
|
||||||
|
.chat-message {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 15px;
|
||||||
|
max-width: 80%;
|
||||||
|
word-wrap: break-word;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-message {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
margin-left: auto;
|
||||||
|
border-bottom-right-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-message {
|
||||||
|
background-color: #FFF7ED; /* 주 배경 톤과 어울리는 밝은 주황 계열 */
|
||||||
|
color: #333;
|
||||||
|
margin-right: auto;
|
||||||
|
border-bottom-left-radius: 5px;
|
||||||
|
border: 1px solid #FFE0B2;
|
||||||
|
}
|
||||||
|
.bot-message img { display:block; max-width:100%; border-radius:12px; }
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap; /* 입력 줄바꿈 그대로 표시 */
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-message .message-time {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 로딩 애니메이션 */
|
||||||
|
@keyframes typing {
|
||||||
|
0%, 20% { opacity: 0; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-message.loading .message-content::after {
|
||||||
|
content: '...';
|
||||||
|
animation: typing 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 이미지 미리보기 영역 */
|
||||||
|
.image-preview-area {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.image-preview-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
position: relative;
|
||||||
|
min-width: 120px;
|
||||||
|
max-width: 220px;
|
||||||
|
box-shadow: 0 2px 8px #ffe0d3;
|
||||||
|
}
|
||||||
|
.image-preview-thumb {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-right: 10px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.image-preview-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
.image-preview-name {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
max-width: 100px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.image-preview-size {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.image-preview-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
background: #fff0ee;
|
||||||
|
border: none;
|
||||||
|
color: #FF9800;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.image-preview-remove:hover {
|
||||||
|
background: #FF9800;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
/* 도구 채팅 헤더 */
|
||||||
|
.tool-chat-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 2px 12px rgba(255, 107, 74, 0.1);
|
||||||
|
}
|
||||||
|
.tool-chat-title {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #FF9800;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.tool-chat-desc {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
width: 60px;
|
||||||
|
height: 6px;
|
||||||
|
background: #eee;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-top: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-bar-inner {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #FF9800 0%, #FFD6C2 100%);
|
||||||
|
animation: progressBarAnim 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes progressBarAnim {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
.model-toggle-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.model-toggle-btn {
|
||||||
|
background: #FFE0D3;
|
||||||
|
border: 1.5px solid #FFD6C2;
|
||||||
|
color: #FF9800;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 4px 16px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s, border 0.2s;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.model-toggle-btn.active,
|
||||||
|
.model-toggle-btn:focus {
|
||||||
|
background: #FF9800;
|
||||||
|
color: #fff;
|
||||||
|
border: 1.5px solid #FF9800;
|
||||||
|
}
|
||||||
|
.model-toggle-btn:hover:not(.active) {
|
||||||
|
background: #FFD6C2;
|
||||||
|
color: #FF9800;
|
||||||
|
}
|
||||||
|
.external-iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* iframe 모드 시 채팅 영역 전체 확장 */
|
||||||
|
.chat-center.iframe-mode {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
transform: none;
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 파일 사이드바 */
|
||||||
|
.file-sidebar {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 220px;
|
||||||
|
height: 100%;
|
||||||
|
background: #fff8ef;
|
||||||
|
border-right: 1px solid #ffd6c2;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.file-sidebar-header {
|
||||||
|
padding: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 1px solid #ffd6c2;
|
||||||
|
}
|
||||||
|
.file-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.file-list li {
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid #ffe8d5;
|
||||||
|
display:flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items:center;
|
||||||
|
}
|
||||||
|
.file-del-btn{
|
||||||
|
background:none;
|
||||||
|
border:none;
|
||||||
|
color:#ff6b4a;
|
||||||
|
cursor:pointer;
|
||||||
|
font-size:1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* (추가) 긴 파일명도 버튼이 가려지지 않도록 처리 */
|
||||||
|
.file-list li span{
|
||||||
|
flex:1 1 auto; /* 남는 공간을 차지하면서 필요하면 축소 */
|
||||||
|
min-width:0; /* flex-shrink 가 동작하도록 최소폭 0 */
|
||||||
|
overflow:hidden; /* 넘치는 텍스트 숨김 */
|
||||||
|
text-overflow:ellipsis; /* 말줄임표 표시 */
|
||||||
|
white-space:nowrap; /* 한 줄 유지 */
|
||||||
|
}
|
||||||
|
.file-list li:hover { background: #fff1e6; }
|
||||||
|
|
||||||
|
/* 파일 슬라이드 팝업 */
|
||||||
|
.file-slide {
|
||||||
|
position: fixed;
|
||||||
|
left: 220px; /* 옆 사이드바 너비 */
|
||||||
|
bottom: -60%;
|
||||||
|
width: calc(100% - 220px);
|
||||||
|
height: 60%;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
|
||||||
|
border-top-left-radius: 12px;
|
||||||
|
border-top-right-radius: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
transition: bottom 0.3s ease;
|
||||||
|
z-index: 4;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.file-slide.show { bottom: 0; }
|
||||||
|
.file-progress {
|
||||||
|
padding:8px 12px;
|
||||||
|
color:#aaa;
|
||||||
|
font-style:italic;
|
||||||
|
}
|
||||||
|
@keyframes pulseProgress {
|
||||||
|
0% { opacity: 0.3; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
100% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
.file-progress {
|
||||||
|
animation: pulseProgress 1.5s infinite;
|
||||||
|
}
|
||||||
|
.file-slide-close {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
top: 6px; /* 팝업 내부 상단 */
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid #ff6b4a;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
line-height: 28px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #ff6b4a;
|
||||||
|
font-weight: bold;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
.file-slide-close:hover { background:#ff6b4a; color:#fff; }
|
||||||
|
.file-slide-content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: "Noto Sans KR", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PDF 로딩 오버레이 */
|
||||||
|
.file-loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #ff6b4a;
|
||||||
|
font-style: italic;
|
||||||
|
z-index: 4; /* iframe 위에 표시 */
|
||||||
|
animation: pulseProgress 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Sidebar fixed width to prevent layout shift --- */
|
||||||
|
.file-sidebar {
|
||||||
|
width: 260px;
|
||||||
|
flex: 0 0 260px;
|
||||||
|
}
|
||||||
|
.file-sidebar .file-list li {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Community page adjustments --- */
|
||||||
|
.page-community {
|
||||||
|
overflow-y: visible; /* use outer body scrollbar only */
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
.page-community.active {
|
||||||
|
display: block !important; /* show only when active; override .page.active flex */
|
||||||
|
}
|
||||||
|
#community-img { display:none; }
|
||||||
|
|
||||||
|
/* 커뮤니티 내부 레이아웃 */
|
||||||
|
.community-container { display:flex; height:100%; }
|
||||||
|
.community-sidebar { width:180px; background:#fff8ef; border-right:1px solid #ffd6c2; }
|
||||||
|
.community-sidebar ul{ list-style:none; margin:0; padding:0; }
|
||||||
|
.community-menu-item{ padding:12px 18px; cursor:pointer; color:#333; font-size:1rem; }
|
||||||
|
.community-menu-item.active,.community-menu-item:hover{ background:#fff1e6; color:#ff9800; font-weight:bold; }
|
||||||
|
.community-content{ flex:1; overflow-y:auto; }
|
||||||
|
.newsletter-list{ list-style:none; margin:0; padding:0 0 0 20px; }
|
||||||
|
.newsletter-list li{ padding:6px 0; }
|
||||||
|
.newsletter-list a{ color:#007acc; text-decoration:none; font-size:1rem; }
|
||||||
|
.newsletter-list a:hover{ text-decoration:underline; }
|
||||||
|
.newsletter-title{ margin:16px 0 8px 20px; font-size:1.4rem; }
|
||||||
|
.newsletter-content-area{ height: calc(100% - 56px); /* adapt below title */ }
|
||||||
|
.newsletter-pdf-wrapper{ position:relative; height:100%; }
|
||||||
|
.newsletter-iframe{ width:100%; height:100%; border:none; }
|
||||||
|
.newsletter-close{ position:absolute; top:-16px; left:50%; transform:translateX(-50%); width:32px; height:32px; background:#fff; border:2px solid #ff6b4a; border-radius:50%; font-size:1.3rem; line-height:26px; text-align:center; color:#ff6b4a; cursor:pointer; z-index:5; }
|
||||||
|
.newsletter-close:hover{ background:#ff6b4a; color:#fff; }
|
||||||
|
|
||||||
|
/* Lecture styles */
|
||||||
|
.lecture-title{ margin:16px 0 8px 20px; font-size:1.4rem; }
|
||||||
|
.lecture-topic{ margin:8px 0 12px 20px; font-size:1.2rem; color:#555; }
|
||||||
|
.lecture-video-list{ display:flex; flex-wrap:wrap; gap:12px; padding:0 0 0 20px; }
|
||||||
|
.lecture-video{ width:140px; display:block; }
|
||||||
|
.lecture-video img{ width:100%; border-radius:8px; box-shadow:0 2px 6px rgba(0,0,0,0.1); }
|
||||||
|
.lecture-video:hover img{ transform:scale(1.03); transition:transform .2s; }
|
||||||
|
|
||||||
|
/* QnA board */
|
||||||
|
.qna-title-head{ margin:16px 0 8px 20px; font-size:1.4rem; }
|
||||||
|
.qna-table{ width:calc(100% - 40px); margin:0 20px; border-collapse:collapse; }
|
||||||
|
.qna-table th,.qna-table td{ border-bottom:1px solid #eee; padding:10px; text-align:left; }
|
||||||
|
.qna-table th{ background:#fff8ef; font-weight:600; color:#333; }
|
||||||
|
.qna-table tr:hover td{ background:#fff1e6; }
|
||||||
|
.qna-title{ color:#007acc; cursor:pointer; }
|
||||||
|
.qna-title:hover{ text-decoration:underline; }
|
||||||
|
|
||||||
|
/* Document Translation styles */
|
||||||
|
.download-original-link,
|
||||||
|
.download-result-link {
|
||||||
|
color: #007acc;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #007acc;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.download-original-link:hover,
|
||||||
|
.download-result-link:hover {
|
||||||
|
background: #007acc;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.download-original-link {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-color: #6c757d;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
.download-original-link:hover {
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.translating {
|
||||||
|
color: #ff6b4a;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list li.translation-result-item {
|
||||||
|
display: block !important;
|
||||||
|
align-items: initial !important;
|
||||||
|
justify-content: initial !important;
|
||||||
|
}
|
||||||
|
.file-item-content { width: 100%; }
|
||||||
|
.original-file {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.result-file {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------- Loading Overlay for AI뉴스 ------- */
|
||||||
|
.loading-overlay {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
border: 1px solid #ffd6c2;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
z-index: 2000;
|
||||||
|
box-shadow: 0 8px 22px rgba(0,0,0,.12);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.loading-text {
|
||||||
|
color: #ff6b4a;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .4px;
|
||||||
|
animation: textPulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.loading-dots {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.loading-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ff6b4a;
|
||||||
|
animation: loadingBlink 1s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
.loading-dot:nth-child(2) { animation-delay: .2s; }
|
||||||
|
.loading-dot:nth-child(3) { animation-delay: .4s; }
|
||||||
|
|
||||||
|
@keyframes loadingBlink {
|
||||||
|
0%, 80%, 100% { opacity: .25; transform: scale(.85); }
|
||||||
|
40% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes textPulse {
|
||||||
|
0%, 100% { opacity: .7; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When file sidebar is visible, shift chat area and input to the right */
|
||||||
|
.page-chat.has-file-sidebar .chat-center {
|
||||||
|
left: calc(50% + 130px); /* half of file sidebar(260px) */
|
||||||
|
max-width: calc(95vw - 340px); /* main sidebar(80px) + file sidebar(260px) */
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-chat.has-file-sidebar .chat-input-wrapper {
|
||||||
|
left: calc(50% + 130px); /* half of file sidebar(260px) */
|
||||||
|
max-width: calc(95vw - 340px); /* main sidebar(80px) + file sidebar(260px) */
|
||||||
|
width: calc(100% - 260px); /* exclude file sidebar area */
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Chat History Panel & Toggle --- */
|
||||||
|
.chat-history-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
left: 18px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 6px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 6;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.chat-history-toggle span {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
background: #ff6b4a;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.chat-history-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 78px;
|
||||||
|
left: 0;
|
||||||
|
width: 360px;
|
||||||
|
/* 상단 헤더(top)과 하단 입력 영역(bottom) 사이에 꽉 채움 */
|
||||||
|
bottom: 0px; /* chat-input-wrapper 의 bottom 값과 동일 */
|
||||||
|
background: #ffffff;
|
||||||
|
border-right: 1px solid #ffd6c2;
|
||||||
|
box-shadow: 2px 0 12px rgba(0,0,0,0.05);
|
||||||
|
z-index: 5;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: none; /* 기본 숨김 */
|
||||||
|
}
|
||||||
|
.chat-history-panel.show { display: block; }
|
||||||
|
.chat-history-list { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.history-item { padding: 10px 14px; border-bottom: 1px solid #ffe8d5; }
|
||||||
|
.history-content { font-size: 0.95rem; color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.history-time { font-size: 0.8rem; color: #aaa; }
|
||||||
|
|
||||||
|
/* 파일 사이드바가 있는 경우 히스토리 패널 위치 보정 */
|
||||||
|
.page-chat.has-file-sidebar .chat-history-panel { left: 260px; }
|
||||||
|
.page-chat.has-file-sidebar .chat-history-toggle { left: 278px; }
|
||||||
19
vite.config.js
Normal file
19
vite.config.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
envPrefix: ['VITE_', 'OPENAI_'],
|
||||||
|
server: {
|
||||||
|
// SPA 라우팅 경로(/tools, /lecture 등)와 충돌을 피하기 위해 API 프록시 경로를 '/api' 계열로만 사용합니다.
|
||||||
|
// 현재 프론트엔드는 절대 경로(http://localhost:8010/...)를 사용하므로 실제로는 프록시가 필요 없습니다.
|
||||||
|
proxy: {
|
||||||
|
// 예: 필요한 경우 다음과 같이 사용하세요
|
||||||
|
// '/api': {
|
||||||
|
// target: 'http://localhost:8010',
|
||||||
|
// changeOrigin: true,
|
||||||
|
// rewrite: (path) => path.replace(/^\/api/, '')
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user