Files
ncuetalk_frontend/src/pages/ChatPage.jsx
dsyoon 58606b7eab init
2025-12-27 14:07:27 +09:00

553 lines
22 KiB
JavaScript

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>
</>
);
}