// 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 */}
{(activeChat?.messages || []).map((m) => (
))}
);
}
const css = `
:root{
--bg: #0f1115;
--panel: #12151c;
--panel2: #0f131a;
--stroke: rgba(255,255,255,.08);
--stroke2: rgba(255,255,255,.12);
--text: rgba(255,255,255,.92);
--muted: rgba(255,255,255,.60);
--muted2: rgba(255,255,255,.45);
--accent: #ffffff;
--danger: #ff5c5c;
--shadow: 0 10px 30px rgba(0,0,0,.35);
--radius: 16px;
--radius2: 12px;
--font: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
}
*{box-sizing:border-box}
html,body,#root{height:100%}
body{
margin:0;
background: radial-gradient(1000px 600px at 60% -10%, rgba(255,255,255,.06), transparent 55%),
radial-gradient(900px 500px at -10% 40%, rgba(255,255,255,.05), transparent 55%),
var(--bg);
color:var(--text);
font-family: var(--font);
}
.app{
height:100%;
display:flex;
overflow:hidden;
}
/* Sidebar */
.sidebar{
width: 320px;
background: linear-gradient(180deg, rgba(255,255,255,.03), transparent 35%),
var(--panel);
border-right: 1px solid var(--stroke);
display:flex;
flex-direction:column;
transition: width .2s ease, transform .2s ease;
position:relative;
}
.sidebar.closed{
width: 72px;
}
.sbTop{
padding: 14px 12px 10px;
display:flex;
gap:10px;
align-items:center;
}
.iconBtn{
width: 38px;
height: 38px;
border-radius: 12px;
border: 1px solid var(--stroke);
background: rgba(255,255,255,.03);
color: var(--text);
display:flex;
align-items:center;
justify-content:center;
cursor:pointer;
transition: background .15s ease, transform .15s ease, border-color .15s ease;
}
.iconBtn:hover{ background: rgba(255,255,255,.06); border-color: var(--stroke2); }
.iconBtn:active{ transform: scale(.98); }
.primaryBtn{
flex:1;
height: 38px;
border-radius: 12px;
border: 1px solid var(--stroke);
background: rgba(255,255,255,.04);
color: var(--text);
display:flex;
align-items:center;
gap:10px;
padding: 0 12px;
cursor:pointer;
transition: background .15s ease, border-color .15s ease, transform .15s ease;
white-space:nowrap;
}
.primaryBtn:hover{ background: rgba(255,255,255,.07); border-color: var(--stroke2); }
.primaryBtn:active{ transform: scale(.99); }
.sidebar.closed .primaryBtn span{ display:none; }
.sidebar.closed .primaryBtn{ justify-content:center; padding:0; }
.sidebar.closed .sbSearch{ display:none; }
.sidebar.closed .sbBottom{ display:none; }
.sidebar.closed .chatMeta{ display:none; }
.sbSearch{
padding: 0 12px 10px;
}
.searchBox{
height: 38px;
border-radius: 12px;
border: 1px solid var(--stroke);
background: rgba(255,255,255,.03);
display:flex;
align-items:center;
gap:8px;
padding: 0 10px;
color: var(--muted);
}
.searchBox input{
flex:1;
border:none;
outline:none;
background:transparent;
color: var(--text);
font-size: 14px;
}
.sbList{
flex:1;
overflow:auto;
padding: 6px 8px 12px;
}
.sbList::-webkit-scrollbar{ width:10px; }
.sbList::-webkit-scrollbar-thumb{
background: rgba(255,255,255,.08);
border-radius: 10px;
border: 2px solid transparent;
background-clip: content-box;
}
.chatItem{
position:relative;
display:flex;
align-items:center;
gap:10px;
padding: 10px 10px;
border-radius: 14px;
cursor:pointer;
border: 1px solid transparent;
}
.chatItem:hover{
background: rgba(255,255,255,.04);
border-color: rgba(255,255,255,.06);
}
.chatItem.active{
background: rgba(255,255,255,.06);
border-color: rgba(255,255,255,.10);
}
.chatMeta{
min-width:0;
flex:1;
}
.chatTitle{
font-size: 14px;
line-height: 1.2;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.chatTime{
margin-top: 4px;
font-size: 12px;
color: var(--muted2);
}
.dotsBtn{
width: 34px;
height: 34px;
border-radius: 12px;
border: 1px solid transparent;
background: transparent;
color: var(--muted);
display:flex;
align-items:center;
justify-content:center;
cursor:pointer;
}
.chatItem:hover .dotsBtn{
border-color: rgba(255,255,255,.06);
background: rgba(255,255,255,.03);
color: var(--text);
}
.menu{
position:absolute;
right: 10px;
top: 46px;
width: 170px;
background: rgba(15,17,21,.95);
border: 1px solid rgba(255,255,255,.12);
border-radius: 14px;
box-shadow: var(--shadow);
padding: 6px;
z-index: 50;
backdrop-filter: blur(10px);
}
.menuItem{
width:100%;
height: 38px;
border-radius: 12px;
border: none;
background: transparent;
color: var(--text);
display:flex;
align-items:center;
gap:10px;
padding: 0 10px;
cursor:pointer;
font-size: 14px;
}
.menuItem:hover{
background: rgba(255,255,255,.06);
}
.menuItem.danger{
color: #ffd2d2;
}
.menuItem.danger:hover{
background: rgba(255,92,92,.15);
}
.sbBottom{
border-top: 1px solid var(--stroke);
padding: 12px 12px 14px;
}
.pill{
display:inline-flex;
align-items:center;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,.10);
background: rgba(255,255,255,.03);
font-size: 12px;
color: var(--muted);
}
.muted{ color: var(--muted); font-size: 12px; margin-top: 8px; }
/* Main */
.main{
flex:1;
display:flex;
flex-direction:column;
min-width: 0;
}
.topbar{
height: 56px;
display:flex;
align-items:center;
justify-content:space-between;
padding: 0 16px;
border-bottom: 1px solid var(--stroke);
background: rgba(18,21,28,.65);
backdrop-filter: blur(10px);
}
.topbarLeft{
display:flex;
align-items:center;
gap:12px;
min-width:0;
}
.topTitle{
font-size: 15px;
font-weight: 600;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
max-width: 60vw;
}
.topbarRight{
display:flex;
align-items:center;
gap:8px;
color: var(--muted);
font-size: 13px;
}
.statusDot{
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255,255,255,.55);
}
.statusText{ color: var(--muted); }
.thread{
flex:1;
overflow:auto;
padding: 18px 0;
}
.thread::-webkit-scrollbar{ width:12px; }
.thread::-webkit-scrollbar-thumb{
background: rgba(255,255,255,.08);
border-radius: 10px;
border: 3px solid transparent;
background-clip: content-box;
}
.threadInner{
max-width: 900px;
margin: 0 auto;
padding: 0 16px;
display:flex;
flex-direction:column;
gap: 14px;
}
/* Messages */
.msgRow{
display:flex;
gap: 12px;
align-items:flex-start;
}
.msgRowUser{
flex-direction: row-reverse;
}
.avatar{
width: 34px;
height: 34px;
border-radius: 12px;
display:flex;
align-items:center;
justify-content:center;
font-size: 12px;
font-weight: 700;
border: 1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.05);
color: var(--text);
flex: 0 0 auto;
}
.avatarUser{
background: rgba(255,255,255,.07);
}
.avatarAsst{
background: rgba(255,255,255,.04);
}
.bubble{
max-width: 720px;
border-radius: 18px;
border: 1px solid rgba(255,255,255,.10);
background: rgba(255,255,255,.04);
padding: 12px 14px;
line-height: 1.55;
font-size: 14px;
}
.bubbleUser{
background: rgba(255,255,255,.08);
border-color: rgba(255,255,255,.14);
}
.bubble p{
margin: 0;
}
.bubble code{
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
background: rgba(0,0,0,.25);
padding: 2px 6px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,.10);
}
/* Composer */
.composer{
border-top: 1px solid var(--stroke);
background: rgba(18,21,28,.65);
backdrop-filter: blur(10px);
padding: 12px 16px 14px;
}
.composerInner{
max-width: 900px;
margin: 0 auto;
display:flex;
gap: 10px;
align-items:flex-end;
padding: 10px;
border-radius: 18px;
border: 1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.04);
}
.composerInner.disabled{
opacity: .75;
}
.input{
flex:1;
border: none;
outline: none;
resize: none;
background: transparent;
color: var(--text);
font-size: 14px;
line-height: 1.5;
min-height: 22px;
max-height: 160px;
}
.sendBtn{
width: 42px;
height: 42px;
border-radius: 14px;
border: 1px solid rgba(255,255,255,.14);
background: rgba(255,255,255,.06);
color: var(--text);
display:flex;
align-items:center;
justify-content:center;
cursor:pointer;
transition: transform .12s ease, background .12s ease, border-color .12s ease;
}
.sendBtn:hover{
background: rgba(255,255,255,.09);
border-color: rgba(255,255,255,.18);
}
.sendBtn:active{ transform: scale(.98); }
.sendBtn:disabled{
opacity: .45;
cursor:not-allowed;
}
.composerHint{
max-width: 900px;
margin: 8px auto 0;
padding: 0 4px;
}
.composerHint code{
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
}
/* Responsive */
@media (max-width: 860px){
.sidebar{
position:absolute;
left:0; top:0; bottom:0;
z-index: 60;
transform: translateX(0);
}
.sidebar.closed{
transform: translateX(-100%);
width: 320px;
}
.main{
margin-left: 0;
}
}
`;