This commit is contained in:
dsyoon
2025-12-27 14:07:27 +09:00
parent 976191d314
commit 58606b7eab
35 changed files with 5133 additions and 1 deletions

3
.gitignore vendored
View File

@@ -1,4 +1,7 @@
# ---> Python
dist/*
node_modules/*
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

7
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.9" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.9" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/ncuetalk_frontend.iml" filepath="$PROJECT_DIR$/.idea/ncuetalk_frontend.iml" />
</modules>
</component>
</project>

8
.idea/ncuetalk_frontend.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Python 3.9" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -1,2 +1,97 @@
# ncuetalk_frontend
# 엔큐톡 프론트엔드 (React + Vite)
엔큐톡(ncuetalk)은 다양한 AI 엔진(챗봇·PDF QA·Text-to-SQL 등)을 한 화면에서 사용할 수 있게 해 주는 통합 프론트엔드입니다. 기존 jQuery SPA 구조를 **React 18 / Vite 7** 기반으로 전면 개편하였습니다.
## 폴더 구조
```
ncuetalk_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
nvm install 20
nvm use 20
node -v # 20.x 확인
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.

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

14
index.html Normal file
View 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>NCue Recipe Engine</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>

1843
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "ncuetalk-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
View File

@@ -0,0 +1 @@

119
src/App.jsx Normal file
View File

@@ -0,0 +1,119 @@
import React, { useEffect, useState } from 'react';
import Sidebar from './components/Sidebar';
import HomePage from './pages/HomePage';
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 = {
home: '',
chat: '데모',
tools: '도구',
lecture: '교육',
ai_news: '인사이트',
qna: 'FAQ',
login: '로그인',
};
const title = currentPage === 'chat' ? (selectedTool?.name || titleMap.chat) : (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('home');
const PAGES = {
home: <HomePage onNavigate={handleNavigate} />,
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 = { '': 'home', chatting: 'chat', tools: 'tools', lecture: 'lecture', ai_news: 'ai_news', qna: 'qna', login: 'login' };
const maybeMenu = menuPaths[path];
if (maybeMenu) {
setCurrentPage(maybeMenu);
} else {
// 카드 경로일 때는 채팅 페이지로 전환
setCurrentPage('chat');
}
}, []);
// 브라우저 뒤/앞으로가기(popstate) 시 URL에 맞게 화면 전환
useEffect(() => {
const onPopState = () => {
const path = window.location.pathname.replace(/^\/+/, '');
const menuPaths = { '': 'home', chatting: 'chat', tools: 'tools', lecture: 'lecture', ai_news: 'ai_news', qna: 'qna', login: 'login' };
const maybeMenu = menuPaths[path];
if (maybeMenu) {
setCurrentPage(maybeMenu);
} else {
// 카드 경로는 채팅 화면으로
setCurrentPage('chat');
}
};
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
// 메뉴 전환 시 URL 경로 갱신
function handleNavigate(page) {
setCurrentPage(page);
const menuToPath = { home: '', 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>
);
}

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { useTool } from '../context/ToolContext';
const MENU = [
{ id: 'home', label: '홈', sub: 'Engine' },
{ id: 'chat', label: '데모', sub: 'Playground' },
{ id: 'tools', label: '도구', sub: 'Tools' },
{ id: 'lecture', label: '교육', sub: 'Workshops' },
{ id: 'ai_news', label: '인사이트', sub: 'Insights' },
{ id: 'qna', label: 'FAQ', sub: 'Support' },
];
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('home');
}}
style={{ cursor: 'pointer' }}
>
<div className="logo-mark">NCue</div>
<div className="logo-sub">Recipe Engine</div>
</div>
<nav className="menu">
<ul>
{MENU.map((item) => (
<li
key={item.id}
className={`menu-item ${current === item.id ? 'active' : ''}`}
onClick={() => { onChange(item.id); }}
>
<div className="menu-item-label">{item.label}</div>
<div className="menu-item-sub">{item.sub}</div>
</li>
))}
</ul>
</nav>
</aside>
);
}

3
src/config.js Normal file
View File

@@ -0,0 +1,3 @@
export const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL ||
`${window.location.protocol}//${window.location.hostname}:8010`;

View File

@@ -0,0 +1,53 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { API_BASE_URL } from '../config';
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(`${API_BASE_URL}/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(`${API_BASE_URL}/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);
}

View File

@@ -0,0 +1,94 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
import { API_BASE_URL } from '../config';
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(`${API_BASE_URL}/tools`);
const data = await res.json();
// 'GxP 챗봇'과 'chatgpt' 카드는 도구 목록에서 제거
const filtered = (Array.isArray(data) ? data : []).filter((tool) => !['chatbot_gxp','chatgpt','lims_text2sql','research_qa'].includes(tool.id));
const mapped = filtered.map((tool) => {
const category = ['전체'];
if (['dev_chatbot', 'doc_translation'].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','lims_text2sql','research_qa'].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
View 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>
);

171
src/pages/AiNewsPage.jsx Normal file
View File

@@ -0,0 +1,171 @@
import React, { useEffect, useRef, useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { API_BASE_URL } from '../config';
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 sentinelRef = useRef(null);
useEffect(() => {
(async () => {
await loadNews(0, false);
// 최초 로드 후 최신 위치로 한 번 스크롤
requestAnimationFrame(() => {
const el = contentRef.current;
if (el) el.scrollTop = el.scrollHeight;
});
})();
}, []);
// IntersectionObserver로 상단 도달 시 로드
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !newsLoading) {
loadNews(newsOffset, true);
}
},
{ root: contentRef.current, threshold: 0.1 }
);
if (sentinelRef.current) observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [newsOffset, newsLoading]);
const loadNews = async (offset = 0, prepend = false) => {
if (newsLoading) return;
setNewsLoading(true);
try {
const res = await fetch(`${API_BASE_URL}/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);
}
};
// 기존 스크롤 보정 루프 제거 IntersectionObserver 사용
const submitNews = async () => {
if (!newsUrl.trim()) return;
await fetch(`${API_BASE_URL}/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' }}>
<div className="section" style={{ paddingBottom: 8 }}>
<div className="section-head" style={{ marginBottom: 0 }}>
<h2 className="section-title">인사이트</h2>
<p className="section-desc">업계 동향/레퍼런스 링크를 모아두고, /고객과 빠르게 공유합니다.</p>
</div>
</div>
<main className="community-content" ref={contentRef} style={{ overflowY: 'auto', flex: 1 }}>
<div ref={sentinelRef} />
<div style={{ padding: '0 20px 50px 20px' }}>
{news.map((n) => (
<div key={n.id} className="card" style={{ 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)}
className="btn btn-primary"
style={{ position: 'fixed', right: 24, bottom: 24, 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>
);
}

553
src/pages/ChatPage.jsx Normal file
View File

@@ -0,0 +1,553 @@
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';
import { useAuth } from '../context/AuthContext';
import { API_BASE_URL } from '../config';
// === 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(`${API_BASE_URL}/files`);
const data = await res.json();
setFiles(data.files || []);
} catch (_) {}
};
useEffect(() => {
loadFiles();
}, []);
const handleDelete = async (fname) => {
if (!window.confirm(`${fname} 파일을 삭제하시겠습니까?`)) return;
await fetch(`${API_BASE_URL}/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(`${API_BASE_URL}/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 { user } = useAuth();
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);
const [sending, setSending] = useState(false);
// 기본 카드: ChatGPT (도구 목록에서 제거되었기 때문에 selectedTool 이 없으면 ChatGPT로 동작)
const defaultChatTool = {
id: 'chatgpt',
name: 'Recipe Engine Demo',
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으로 제공하는 도구는 더 이상 없음)
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 (sending) return;
if (!user) {
alert('로그인이 필요합니다');
return;
}
setSending(true);
if (!input.trim()) { setSending(false); 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(`${API_BASE_URL}/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() }]);
}
setSending(false);
};
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="조건을 입력해 보세요 (예: 40대 남성 · 당뇨 전단계 · 저녁 · 나트륨 제한 · 한식 선호)"
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() || sending}>
</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(`${API_BASE_URL}/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(`${API_BASE_URL}/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(`${API_BASE_URL}/doc_translation/files/${encodeURIComponent(serverFilename)}`, { method: 'DELETE' });
loadFiles();
};
const handleDownload = async (filename) => {
const res = await fetch(`${API_BASE_URL}/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 { user } = useAuth();
const { selectedTool } = useTool();
const [messages, setMessages] = useState([]);
const [sending, setSending] = useState(false);
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 (sending) return;
if (!user) {
alert('로그인이 필요합니다');
return;
}
setSending(true);
if (!input.trim()) { setSending(false); 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(`${API_BASE_URL}/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() }]);
}
setSending(false);
};
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() || sending}>
</button>
</div>
</div>
</>
);
}

275
src/pages/HomePage.jsx Normal file
View File

@@ -0,0 +1,275 @@
import React from 'react';
function scrollToId(id) {
const el = document.getElementById(id);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function MiniCode({ children }) {
return (
<pre className="mini-code">
<code>{children}</code>
</pre>
);
}
export default function HomePage({ onNavigate }) {
return (
<section className="page page-home" style={{ flex: 1, display: 'block' }}>
<div className="home-hero">
<div className="home-hero-inner">
<div className="pill-row">
<span className="pill">B2B / API-first</span>
<span className="pill">한국형 영양·질환·약물 고려</span>
<span className="pill">Prompt Pack · Fine-tuned LLM · API</span>
</div>
<h1 className="hero-title">
Korean Nutrition-aware
<br />
Recipe Recommendation Engine
</h1>
<p className="hero-subtitle">
요리 아니라, <b>레시피 추천을 가능하게 하는 AI 엔진</b> 제품으로 제공합니다.
<br />
혈당/칼로리/나트륨/질환/약물 복용 여부까지 고려해, 안전하고 현실적인 한식 추천을 만듭니다.
</p>
<div className="hero-cta">
<button className="btn btn-primary" onClick={() => onNavigate?.('chat')}>데모 체험</button>
<button className="btn btn-ghost" onClick={() => scrollToId('products')}>제품 보기</button>
<button className="btn btn-ghost" onClick={() => scrollToId('api')}>API 예시</button>
</div>
</div>
</div>
<div className="section" id="products">
<div className="section-head">
<h2 className="section-title">Products</h2>
<p className="section-desc">혼자서도 출시 가능한 즉시 판매형 API 확장형 제품 구성입니다.</p>
</div>
<div className="cards grid-3">
<div className="card">
<div className="card-kicker">Product 1</div>
<div className="card-title">레시피 추천 프롬프트 패키지</div>
<div className="card-body">
<ul className="list">
<li>조건 기반(혈당/칼로리/나트륨/질환/약물) 템플릿</li>
<li>한식/식사 시간대/조리 난이도/재료 제한 반영</li>
<li>기업 도입용 규정/가드레일 포함</li>
</ul>
<div className="price-row">
<span className="price">90,000 ~ 290,000</span>
<span className="price-sub">개인용</span>
</div>
<div className="price-row">
<span className="price">1,000,000 ~ 5,000,000</span>
<span className="price-sub">기업 라이선스</span>
</div>
</div>
</div>
<div className="card">
<div className="card-kicker">Product 2</div>
<div className="card-title">Fine-tuned Korean Recipe LLM</div>
<div className="card-body">
<ul className="list">
<li>레시피 + 영양성분 + 의약지식 구조화 데이터</li>
<li>질병/약물 × 음식 상호작용 설명 특화</li>
<li>LoRA 기반(LLaMA/Mistral) 또는 튜닝+프롬프트 결합</li>
</ul>
<div className="tag-row">
<span className="tag">API 과금</span>
<span className="tag">기업 맞춤 파인튜닝</span>
<span className="tag">Private deployment</span>
</div>
</div>
</div>
<div className="card">
<div className="card-kicker">Product 3</div>
<div className="card-title">Recipe Recommendation API</div>
<div className="card-body">
<ul className="list">
<li>헬스케어/식단/병원·약국 IT 벤더용 API-first</li>
<li>규정 준수/설명 가능성(Why this recipe?) 중심</li>
<li>캐시/배치/비용 최적화 운영 패키지</li>
</ul>
<button className="btn btn-small" onClick={() => scrollToId('api')}>API 예시 보기</button>
</div>
</div>
</div>
</div>
<div className="section section-alt" id="api">
<div className="section-head">
<h2 className="section-title">API Example</h2>
<p className="section-desc"> 아니라, 추천 엔진을 다른 서비스에 붙일 있도록 설계합니다.</p>
</div>
<div className="cards grid-2">
<div className="card">
<div className="card-title">Request</div>
<MiniCode>{`POST /recommend/recipe
{
"age": 45,
"conditions": ["pre-diabetes"],
"medications": ["metformin"],
"preferences": ["korean"],
"meal": "dinner",
"constraints": {
"sodium_mg_max": 1500,
"calories_kcal_max": 650
}
}`}</MiniCode>
</div>
<div className="card">
<div className="card-title">Response (shape)</div>
<MiniCode>{`{
"recipes": [
{
"name": "두부버섯된장국 + 보리밥",
"nutrition": { "calories_kcal": 580, "sodium_mg": 980, "carbs_g": 62 },
"why": ["저GI 탄수화물 비중", "단백질 보강", "나트륨 상한 내 조정"],
"warnings": ["된장/국물 섭취량에 따라 나트륨 상승 가능"]
}
],
"explainability": {
"risk_flags": ["sodium_sensitive"],
"next_questions": ["저녁 후 혈당 측정 여부", "신장질환 유무"]
}
}`}</MiniCode>
</div>
</div>
</div>
<div className="section" id="education">
<div className="section-head">
<h2 className="section-title">Education & Workshops</h2>
<p className="section-desc"> 단독 수익이 아니라, 교육 + 제품 판매 조합으로 강력한 퍼널을 만듭니다.</p>
</div>
<div className="cards grid-2">
<div className="card">
<div className="card-kicker">Course 1</div>
<div className="card-title">LLM 서비스 실전 구축</div>
<div className="card-body">
<ul className="list">
<li>API vs OSS 아키텍처 선택</li>
<li>Prompt 제품화, Text2SQL / RAG 실전</li>
<li>비용 최적화(GPU/캐시/배치) & 운영/장애 대응</li>
<li>레시피/헬스케어 사례로 실습</li>
</ul>
<div className="tag-row">
<span className="tag">개인 30~70만원</span>
<span className="tag">기업 1,000~3,000만원</span>
</div>
<button className="btn btn-small" onClick={() => onNavigate?.('lecture')}>교육 페이지로</button>
</div>
</div>
<div className="card">
<div className="card-kicker">Course 2</div>
<div className="card-title">프롬프트를 상품으로 만드는 </div>
<div className="card-body">
<ul className="list">
<li>프롬프트 IP화 & 라이선스 전략</li>
<li>마켓 판매 / 기업 계약 구조</li>
<li>프롬프트팩 모델/API로 확장하는 로드맵</li>
</ul>
<div className="callout">
책은 수익 아니라 <b>신뢰 + 리드 생성</b> 장치로 활용합니다.
</div>
</div>
</div>
</div>
</div>
<div className="section section-alt" id="roadmap">
<div className="section-head">
<h2 className="section-title">12 실행 로드맵</h2>
<p className="section-desc">4·5·6(제품·현금화·교육) 동시에 굴리는 1 실행 플랜.</p>
</div>
<div className="timeline">
<div className="timeline-item">
<div className="timeline-title">Phase 1 (1~4)</div>
<div className="timeline-body">레시피 프롬프트 패키지 완성 · 기술 블로그/강의 예고</div>
</div>
<div className="timeline-item">
<div className="timeline-title">Phase 2 (5~8)</div>
<div className="timeline-body">온라인 강의 1 런칭 · 레시피 API MVP</div>
</div>
<div className="timeline-item">
<div className="timeline-title">Phase 3 (9~12)</div>
<div className="timeline-body">로컬 음식점 5~10 제휴 · 추천+홍보 PoC · B2B 제안서</div>
</div>
</div>
</div>
<div className="section" id="contact">
<div className="section-head">
<h2 className="section-title">Contact</h2>
<p className="section-desc">
엔진 도입, 기업 라이선스, 맞춤 튜닝/PoC, 교육 문의를 받습니다.
<span className="section-desc-note"> (참고: `ncue.net` Contact Us 구성과 톤을 반영)</span>
</p>
</div>
<div className="cards grid-2">
<div className="card">
<div className="card-title">빠른 문의</div>
<div className="card-body">
<div className="info-row">
<div className="info-k">Email</div>
<div className="info-v"><a className="link" href="mailto:dosangyoon@gmail.com">dosangyoon@gmail.com</a></div>
</div>
<div className="info-row">
<div className="info-k">Phone</div>
<div className="info-v"><a className="link" href="tel:+821028276537">+82 10 2827 6537</a></div>
</div>
<div className="info-row">
<div className="info-k">Location</div>
<div className="info-v">경기도 용인시 수지구</div>
</div>
<div style={{ marginTop: 14, display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={() => scrollToId('products')}>제품 자료 요청</button>
<button className="btn btn-ghost" onClick={() => onNavigate?.('qna')}>FAQ 보기</button>
</div>
</div>
</div>
<div className="card">
<div className="card-title">간단 메시지 (메일로 연결)</div>
<div className="card-body">
<form
onSubmit={(e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const name = String(fd.get('name') || '').trim();
const email = String(fd.get('email') || '').trim();
const msg = String(fd.get('message') || '').trim();
const subject = encodeURIComponent(`[문의] Recipe Engine - ${name || '익명'}`);
const body = encodeURIComponent(`이름: ${name}\n이메일: ${email}\n\n내용:\n${msg}`);
window.location.href = `mailto:dosangyoon@gmail.com?subject=${subject}&body=${body}`;
}}
>
<div className="form-row">
<input className="input" name="name" placeholder="이름" />
<input className="input" name="email" placeholder="email" type="email" />
</div>
<textarea className="textarea" name="message" placeholder="내용" rows={4} />
<div className="form-actions">
<button className="btn btn-primary" type="submit">전송</button>
<button className="btn btn-ghost" type="button" onClick={() => scrollToId('roadmap')}>로드맵 보기</button>
</div>
</form>
</div>
</div>
</div>
<div className="footer-note">© {new Date().getFullYear()} NCue. ALL RIGHT RESERVED</div>
</div>
</section>
);
}

106
src/pages/LecturePage.jsx Normal file
View File

@@ -0,0 +1,106 @@
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' }}>
<div className="section" style={{ paddingBottom: 10 }}>
<div className="section-head" style={{ marginBottom: 0 }}>
<h2 className="section-title">교육 / 워크숍</h2>
<p className="section-desc">
입문 강의 아니라, <b>LLM을 실제 서비스로 벌게 만드는 </b> 초점을 둡니다.
</p>
</div>
<div className="cards grid-2">
<div className="card">
<div className="card-kicker">Course 1</div>
<div className="card-title">LLM 서비스 실전 구축</div>
<div className="card-body">
Prompt 제품화 · RAG/Text2SQL · 비용 최적화 · 운영/장애 대응까지 엔터프라이즈 실무 중심으로 진행합니다.
</div>
</div>
<div className="card">
<div className="card-kicker">Course 2</div>
<div className="card-title">프롬프트를 상품으로 만드는 </div>
<div className="card-body">
프롬프트 IP/라이선스/계약 구조를 정리하고, 프롬프트팩 모델/API로 확장하는 로드맵을 제공합니다.
</div>
</div>
</div>
</div>
<div className="section section-alt" style={{ paddingTop: 18 }}>
{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>
))}
</div>
</section>
);
}

39
src/pages/LoginPage.jsx Normal file
View 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>
);
}

117
src/pages/QnaPage.jsx Normal file
View File

@@ -0,0 +1,117 @@
import React, { useEffect, useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { API_BASE_URL } from '../config';
async function fetchQnaList() {
const res = await fetch(`${API_BASE_URL}/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(`${API_BASE_URL}/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(`${API_BASE_URL}/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' }}>
<div className="section" style={{ paddingBottom: 10 }}>
<div className="section-head" style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 12 }}>
<div>
<h2 className="section-title">FAQ / 문의</h2>
<p className="section-desc">자주 묻는 질문과 제품/도입 문의를 모아 관리합니다.</p>
</div>
<button className="btn btn-ghost" onClick={() => setShowEditor(true)}>글쓰기</button>
</div>
</div>
<div className="card" style={{ margin: '0 20px 14px 20px' }}>
<table className="qna-table" style={{ width: '100%', margin: 0 }}>
<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>
</div>
{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
View 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>
);
}

View 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
View 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;
}

View 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 };

View 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 };

View File

@@ -0,0 +1,158 @@
import React, { useState, useRef, useEffect } from 'react';
import { ChatHandler } from './ChatHandler';
import { useTool } from '../../context/ToolContext';
import { useAuth } from '../../context/AuthContext';
import { marked } from 'marked';
marked.setOptions({ mangle:false, headerIds:false });
export default function DevChatInput() {
const { selectedTool } = useTool();
const { user } = useAuth();
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 (!user) {
alert('로그인이 필요합니다');
return;
}
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 (loading) return;
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>
);
}

View 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 };

View File

@@ -0,0 +1,3 @@
import { API_BASE_URL as API_BASE } from '../../config';
export const API_BASE_URL = API_BASE;

1139
style.css Normal file

File diff suppressed because it is too large Load Diff

17
vite.config.mjs Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
envPrefix: ['VITE_', 'OPENAI_'],
server: {
host: '0.0.0.0',
port: 5173,
allowedHosts: ['ncue.net'],
// SPA 라우팅 경로(/tools, /lecture 등)와 충돌을 피하기 위해 API 프록시 경로를 '/api' 계열로만 사용합니다.
// 현재 프론트엔드는 절대 경로(http://localhost:8010/...)를 사용하므로 실제로는 프록시가 필요 없습니다.
proxy: {},
},
});