// App.js // React 단일 파일로 "ChatGPT 유사" 2-패널 UI (사이드바 + 채팅창) 구성 예시 // - 추가 라이브러리 없이 React만 사용 // - 대화목록(세로 ... 메뉴 + 삭제), 새 대화, 검색, 메시지 스트리밍 흉내, 입력 비활성/로딩 등 포함 // // 사용 전제: CRA(create-react-app) 또는 Vite React 환경에서 App.js로 교체 // 주의: 실제 “완전 동일”은 ChatGPT 내부 UI 자산/폰트/세부 인터랙션에 의존하므로 // 본 코드는 구조/레이아웃/톤을 유사하게 재현한 실무용 템플릿입니다. import React, { useEffect, useMemo, useRef, useState } from "react"; const uid = () => Math.random().toString(36).slice(2) + Date.now().toString(36); const DEFAULT_CHATS = [ { id: uid(), title: "새 대화", updatedAt: Date.now(), messages: [ { id: uid(), role: "assistant", content: "안녕하세요. 무엇을 도와드릴까요?\n\n(예: “정책 분석 요약”, “RAG 설계 점검”, “코드 리뷰”)", createdAt: Date.now(), }, ], }, ]; function formatTime(ts) { const d = new Date(ts); const yy = String(d.getFullYear()).slice(2); const mm = String(d.getMonth() + 1).padStart(2, "0"); const dd = String(d.getDate()).padStart(2, "0"); const hh = String(d.getHours()).padStart(2, "0"); const mi = String(d.getMinutes()).padStart(2, "0"); return `${yy}.${mm}.${dd} ${hh}:${mi}`; } function clampTitle(s) { const t = (s || "").trim().replace(/\s+/g, " "); if (!t) return "새 대화"; return t.length > 28 ? t.slice(0, 28) + "…" : t; } function splitFirstLine(s) { const t = (s || "").trim(); if (!t) return ""; const line = t.split("\n").find((x) => x.trim()) || t; return line; } function toParagraphs(text) { // 단순한 개행 기반 렌더링 (마크다운 미사용) const parts = String(text ?? "").split("\n"); return parts.map((p, i) => p.trim() === "" ?
:

{p}

); } function Icon({ name, size = 18 }) { const common = { width: size, height: size, viewBox: "0 0 24 24", fill: "none" }; switch (name) { case "plus": return ( ); case "search": return ( ); case "dots": return ( ); case "trash": return ( ); case "send": return ( ); case "chevron": return ( ); default: return null; } } function useOutsideClick(ref, handler) { useEffect(() => { const onDown = (e) => { if (!ref.current) return; if (!ref.current.contains(e.target)) handler?.(); }; document.addEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown); }, [ref, handler]); } export default function App() { const [sidebarOpen, setSidebarOpen] = useState(true); const [chatSearch, setChatSearch] = useState(""); const [chats, setChats] = useState(DEFAULT_CHATS); const [activeChatId, setActiveChatId] = useState(DEFAULT_CHATS[0].id); const [input, setInput] = useState(""); const [isGenerating, setIsGenerating] = useState(false); const [menuOpenFor, setMenuOpenFor] = useState(null); const menuRef = useRef(null); const scrollRef = useRef(null); const endRef = useRef(null); const textareaRef = useRef(null); useOutsideClick(menuRef, () => setMenuOpenFor(null)); const activeChat = useMemo( () => chats.find((c) => c.id === activeChatId) || chats[0], [chats, activeChatId] ); const filteredChats = useMemo(() => { const q = chatSearch.trim().toLowerCase(); if (!q) return [...chats].sort((a, b) => b.updatedAt - a.updatedAt); return [...chats] .filter((c) => { const inTitle = (c.title || "").toLowerCase().includes(q); const inMsgs = (c.messages || []).some((m) => String(m.content || "").toLowerCase().includes(q) ); return inTitle || inMsgs; }) .sort((a, b) => b.updatedAt - a.updatedAt); }, [chats, chatSearch]); // 메시지 변경 시 자동 스크롤 useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); }, [activeChat?.messages?.length, isGenerating]); // textarea 자동 높이 useEffect(() => { const el = textareaRef.current; if (!el) return; el.style.height = "0px"; const next = Math.min(el.scrollHeight, 160); el.style.height = next + "px"; }, [input]); const newChat = () => { const id = uid(); const c = { id, title: "새 대화", updatedAt: Date.now(), messages: [ { id: uid(), role: "assistant", content: "새 대화를 시작합니다. 요청하실 내용을 입력해 주세요.", createdAt: Date.now(), }, ], }; setChats((prev) => [c, ...prev]); setActiveChatId(id); setSidebarOpen(true); setMenuOpenFor(null); setInput(""); setIsGenerating(false); }; const deleteChat = (chatId) => { setChats((prev) => { const next = prev.filter((c) => c.id !== chatId); if (next.length === 0) return DEFAULT_CHATS; return next; }); setMenuOpenFor(null); setActiveChatId((prevId) => { if (prevId !== chatId) return prevId; const remain = chats.filter((c) => c.id !== chatId); return (remain[0] && remain[0].id) || DEFAULT_CHATS[0].id; }); }; const renameChatByFirstUserMsg = (chatId, userText) => { const t = clampTitle(splitFirstLine(userText)); setChats((prev) => prev.map((c) => (c.id === chatId ? { ...c, title: c.title === "새 대화" ? t : c.title } : c)) ); }; // 실제 LLM 호출은 사용 환경에 맞게 교체하십시오. // 아래는 “스트리밍처럼 보이게” 더미 응답을 조각내는 예시입니다. const fakeStreamAssistant = async (chatId, userText) => { const base = "요청하신 내용을 바탕으로 핵심을 간략히 정리해 드리겠습니다.\n\n" + "1) 목적/범위: 입력하신 문장을 기준으로 핵심 요구를 분해합니다.\n" + "2) 근거/구조: 필요한 경우 항목화하고, 단계별 산출물을 명확히 합니다.\n" + "3) 다음 단계: 추가 입력이 있다면 반영 가능한 형태로 질문을 제시합니다.\n\n" + "원하시면 출력 형식(보고서/표/코드)과 톤(공문/기술 문서/요약)을 지정해 주시면 그에 맞추겠습니다."; const chunks = base.split(""); let acc = ""; for (let i = 0; i < chunks.length; i++) { acc += chunks[i]; await new Promise((r) => setTimeout(r, 8)); // 타이핑 효과 setChats((prev) => prev.map((c) => { if (c.id !== chatId) return c; const msgs = [...c.messages]; const last = msgs[msgs.length - 1]; if (!last || last.role !== "assistant" || last.id !== "stream") return c; msgs[msgs.length - 1] = { ...last, content: acc }; return { ...c, messages: msgs, updatedAt: Date.now() }; }) ); } }; const send = async () => { const text = input.trim(); if (!text || isGenerating || !activeChat) return; setIsGenerating(true); setInput(""); setMenuOpenFor(null); const chatId = activeChat.id; renameChatByFirstUserMsg(chatId, text); const userMsg = { id: uid(), role: "user", content: text, createdAt: Date.now() }; const assistantStub = { id: "stream", role: "assistant", content: "", createdAt: Date.now() }; setChats((prev) => prev.map((c) => c.id === chatId ? { ...c, messages: [...c.messages, userMsg, assistantStub], updatedAt: Date.now() } : c ) ); try { // TODO: 실제 API로 교체하려면 여기에서 fetch 호출 후 스트리밍/완료 응답 반영 await fakeStreamAssistant(chatId, text); // 스트림 메시지 id 정리 setChats((prev) => prev.map((c) => { if (c.id !== chatId) return c; const msgs = [...c.messages]; const last = msgs[msgs.length - 1]; if (last?.id === "stream") msgs[msgs.length - 1] = { ...last, id: uid() }; return { ...c, messages: msgs, updatedAt: Date.now() }; }) ); } finally { setIsGenerating(false); // 입력창 포커스 복귀 setTimeout(() => textareaRef.current?.focus(), 0); } }; const onKeyDown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }; const MessageBubble = ({ role, content }) => { const isUser = role === "user"; return (
{isUser ? "나" : "AI"}
{toParagraphs(content)}
); }; return (
{/* Sidebar */} {/* Main */}
{!sidebarOpen && ( )}
{activeChat?.title || "대화"}
{isGenerating ? "응답 생성 중" : "대기"}
{(activeChat?.messages || []).map((m) => ( ))}