553 lines
22 KiB
JavaScript
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>
|
|
</>
|
|
);
|
|
} |