Spaces:
Running
Running
| // 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() === "" ? <div key={i} style={{ height: 10 }} /> : <p key={i}>{p}</p> | |
| ); | |
| } | |
| function Icon({ name, size = 18 }) { | |
| const common = { width: size, height: size, viewBox: "0 0 24 24", fill: "none" }; | |
| switch (name) { | |
| case "plus": | |
| return ( | |
| <svg {...common}> | |
| <path | |
| d="M12 5v14M5 12h14" | |
| stroke="currentColor" | |
| strokeWidth="2" | |
| strokeLinecap="round" | |
| /> | |
| </svg> | |
| ); | |
| case "search": | |
| return ( | |
| <svg {...common}> | |
| <path | |
| d="M11 19a8 8 0 1 1 0-16 8 8 0 0 1 0 16Z" | |
| stroke="currentColor" | |
| strokeWidth="2" | |
| /> | |
| <path | |
| d="M21 21l-4.3-4.3" | |
| stroke="currentColor" | |
| strokeWidth="2" | |
| strokeLinecap="round" | |
| /> | |
| </svg> | |
| ); | |
| case "dots": | |
| return ( | |
| <svg {...common}> | |
| <path | |
| d="M5 12a1.5 1.5 0 1 0 3 0 1.5 1.5 0 0 0-3 0Zm5.5 0a1.5 1.5 0 1 0 3 0 1.5 1.5 0 0 0-3 0ZM16 12a1.5 1.5 0 1 0 3 0 1.5 1.5 0 0 0-3 0Z" | |
| fill="currentColor" | |
| /> | |
| </svg> | |
| ); | |
| case "trash": | |
| return ( | |
| <svg {...common}> | |
| <path | |
| d="M9 3h6m-8 4h10m-1 0-.7 13a2 2 0 0 1-2 2H9.7a2 2 0 0 1-2-2L7 7" | |
| stroke="currentColor" | |
| strokeWidth="2" | |
| strokeLinecap="round" | |
| /> | |
| <path | |
| d="M10 11v7M14 11v7" | |
| stroke="currentColor" | |
| strokeWidth="2" | |
| strokeLinecap="round" | |
| /> | |
| </svg> | |
| ); | |
| case "send": | |
| return ( | |
| <svg {...common}> | |
| <path | |
| d="M22 2 11 13" | |
| stroke="currentColor" | |
| strokeWidth="2" | |
| strokeLinecap="round" | |
| /> | |
| <path | |
| d="M22 2 15 22l-4-9-9-4 20-7Z" | |
| stroke="currentColor" | |
| strokeWidth="2" | |
| strokeLinejoin="round" | |
| /> | |
| </svg> | |
| ); | |
| case "chevron": | |
| return ( | |
| <svg {...common}> | |
| <path | |
| d="M15 18 9 12l6-6" | |
| stroke="currentColor" | |
| strokeWidth="2" | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| /> | |
| </svg> | |
| ); | |
| 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 ( | |
| <div className={`msgRow ${isUser ? "msgRowUser" : "msgRowAsst"}`}> | |
| <div className={`avatar ${isUser ? "avatarUser" : "avatarAsst"}`}> | |
| {isUser ? "나" : "AI"} | |
| </div> | |
| <div className={`bubble ${isUser ? "bubbleUser" : "bubbleAsst"}`}>{toParagraphs(content)}</div> | |
| </div> | |
| ); | |
| }; | |
| return ( | |
| <div className="app"> | |
| <style>{css}</style> | |
| {/* Sidebar */} | |
| <aside className={`sidebar ${sidebarOpen ? "open" : "closed"}`}> | |
| <div className="sbTop"> | |
| <button | |
| className="iconBtn" | |
| onClick={() => setSidebarOpen((v) => !v)} | |
| aria-label="사이드바 접기/펼치기" | |
| title="사이드바 접기/펼치기" | |
| > | |
| <Icon name="chevron" size={18} /> | |
| </button> | |
| <button className="primaryBtn" onClick={newChat}> | |
| <Icon name="plus" size={18} /> | |
| <span>새 대화</span> | |
| </button> | |
| </div> | |
| <div className="sbSearch"> | |
| <div className="searchBox"> | |
| <Icon name="search" size={18} /> | |
| <input | |
| value={chatSearch} | |
| onChange={(e) => setChatSearch(e.target.value)} | |
| placeholder="대화 검색" | |
| aria-label="대화 검색" | |
| /> | |
| </div> | |
| </div> | |
| <div className="sbList"> | |
| {filteredChats.map((c) => ( | |
| <div | |
| key={c.id} | |
| className={`chatItem ${c.id === activeChatId ? "active" : ""}`} | |
| onClick={() => { | |
| setActiveChatId(c.id); | |
| setMenuOpenFor(null); | |
| }} | |
| role="button" | |
| tabIndex={0} | |
| > | |
| <div className="chatMeta"> | |
| <div className="chatTitle" title={c.title}> | |
| {c.title} | |
| </div> | |
| <div className="chatTime">{formatTime(c.updatedAt)}</div> | |
| </div> | |
| <button | |
| className="dotsBtn" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| setMenuOpenFor((prev) => (prev === c.id ? null : c.id)); | |
| }} | |
| aria-label="대화 메뉴" | |
| title="대화 메뉴" | |
| > | |
| <Icon name="dots" size={18} /> | |
| </button> | |
| {menuOpenFor === c.id && ( | |
| <div className="menu" ref={menuRef} onClick={(e) => e.stopPropagation()}> | |
| <button className="menuItem danger" onClick={() => deleteChat(c.id)}> | |
| <Icon name="trash" size={16} /> | |
| <span>삭제</span> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| ))} | |
| </div> | |
| <div className="sbBottom"> | |
| <div className="pill">로컬 UI 템플릿</div> | |
| <div className="muted">Next.js/React 연동 시 그대로 이식 가능합니다.</div> | |
| </div> | |
| </aside> | |
| {/* Main */} | |
| <main className="main"> | |
| <header className="topbar"> | |
| <div className="topbarLeft"> | |
| {!sidebarOpen && ( | |
| <button className="iconBtn" onClick={() => setSidebarOpen(true)} aria-label="사이드바 열기"> | |
| <Icon name="chevron" size={18} /> | |
| </button> | |
| )} | |
| <div className="topTitle">{activeChat?.title || "대화"}</div> | |
| </div> | |
| <div className="topbarRight"> | |
| <span className="statusDot" /> | |
| <span className="statusText">{isGenerating ? "응답 생성 중" : "대기"}</span> | |
| </div> | |
| </header> | |
| <section className="thread" ref={scrollRef}> | |
| <div className="threadInner"> | |
| {(activeChat?.messages || []).map((m) => ( | |
| <MessageBubble key={m.id} role={m.role} content={m.content} /> | |
| ))} | |
| <div ref={endRef} /> | |
| </div> | |
| </section> | |
| <footer className="composer"> | |
| <div className={`composerInner ${isGenerating ? "disabled" : ""}`}> | |
| <textarea | |
| ref={textareaRef} | |
| className="input" | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={onKeyDown} | |
| placeholder="메시지를 입력하세요… (Enter 전송, Shift+Enter 줄바꿈)" | |
| disabled={isGenerating} | |
| /> | |
| <button className="sendBtn" onClick={send} disabled={isGenerating || !input.trim()}> | |
| <Icon name="send" size={18} /> | |
| </button> | |
| </div> | |
| <div className="composerHint"> | |
| <span className="muted"> | |
| 본 화면은 UI 템플릿입니다. 실제 모델 호출은 <code>send()</code> 내부 API 구간을 교체하여 적용하십시오. | |
| </span> | |
| </div> | |
| </footer> | |
| </main> | |
| </div> | |
| ); | |
| } | |
| 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; | |
| } | |
| } | |
| `; |