진행 상태와 출처 칩이 있는 AI 문서 대화 패널
기타 블록오른쪽 말풍선 질문, '생각 중/문서 찾는 중' 상태 표시, 답변 문단과 출처 칩, 하단 질문 입력창으로 구성된 AI 문서 대화 패널입니다. 질문을 보내면 돌아가는 상태 표시 뒤 답변이 추가됩니다.
Claude Code · 모름 · React (TSX)
결과 코드펼치기
import { useEffect, useRef, useState } from "react";
const FONT_URL = "https://fonts.googleapis.com/css2?family=Gothic+A1:wght@400;500;600;700;800&display=swap";
type Msg = { id: number; role: "user" | "ai"; text: string; sources?: string[]; trail?: boolean };
const FIRST: Msg[] = [
{ id: 1, role: "user", text: "기후 변화가 해양 생태계에 미치는 영향은?", trail: true },
{
id: 2,
role: "ai",
text: "기후 변화는 해양 생태계에 다양한 방식으로 영향을 미칩니다.\n해수 온도 상승으로 인해 산호 백화 현상이 가속화되고 있으며,\n해양 산성화는 껍데기를 가진\n생물들의 생존을 위협하고 있습니다.",
},
{
id: 3,
role: "ai",
text: "특히 북극 지방의 빙하 소실은 해류 순환에 변화를 일으켜 전 세계 기상 패턴에도 영향을 줍니다. 해양 생물 다양성 감소는 어업 산업과 인류의 식량 안보에도 직접적인 위협이 됩니다.",
sources: ["출처"],
},
];
function Spinner({ color }: { color: string }) {
return (
<svg className="animate-spin" width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<circle cx="6" cy="6" r="4.5" stroke={color} strokeOpacity="0.25" strokeWidth="1.5" />
<path d="M6 1.5A4.5 4.5 0 0 1 10.5 6" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
function StatusTrail({ searching, live }: { searching: boolean; live?: boolean }) {
return (
<div className="flex flex-col gap-1.5" role={live ? "status" : undefined}>
<div className="flex items-center gap-2 py-2">
{live ? <Spinner color="#6b7280" /> : <Ring color="#6b7280" />}
<span className="text-[12px] leading-normal text-[#6b7280]">생각 중...</span>
</div>
{searching && (
<div className="flex items-center gap-2 py-2">
{live ? <Spinner color="#3b82f6" /> : <Ring color="#3b82f6" />}
<span className="text-[12px] leading-normal text-[#6b7280]">문서 찾는 중...</span>
</div>
)}
</div>
);
}
function Ring({ color }: { color: string }) {
return (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<circle cx="6" cy="6" r="4.5" stroke={color} strokeWidth="1.5" />
</svg>
);
}
export default function AiDocumentChat() {
const [messages, setMessages] = useState<Msg[]>(FIRST);
const [phase, setPhase] = useState<"idle" | "thinking" | "searching">("idle");
const [draft, setDraft] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
const timers = useRef<number[]>([]);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
}, [messages, phase]);
useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), []);
const ask = (text: string) => {
const q = text.trim();
if (!q || phase !== "idle") return;
const id = Date.now();
setMessages((m) => [...m, { id, role: "user", text: q }]);
setDraft("");
setPhase("thinking");
timers.current.push(
window.setTimeout(() => setPhase("searching"), 900),
window.setTimeout(() => {
setPhase("idle");
setMessages((m) => [
...m,
{
id: id + 1,
role: "ai",
text: "이 문서는 해수 온도 상승, 해양 산성화, 빙하 소실이라는 세 가지 변화가 해양 생태계와 식량 안보에 미치는 영향을 정리하고 있습니다.",
sources: ["출처"],
},
]);
}, 2000),
);
};
return (
<section className="w-full bg-[#f5f5f5] px-4 py-10 font-['Gothic_A1',sans-serif] sm:px-6">
<link rel="stylesheet" href={FONT_URL} precedence="default" />
<div className="mx-auto flex h-[720px] w-full max-w-[700px] flex-col gap-5 rounded-2xl border border-[#e5e7eb] bg-[#fefeff] p-3 drop-shadow-[0px_4px_6px_rgba(0,0,0,0.03)] sm:h-[960px]">
<div ref={scrollRef} className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto bg-white p-4" aria-live="polite">
{messages.map((m, i) =>
m.role === "user" ? (
<div key={m.id} className="flex w-full flex-col gap-2.5">
<div className="flex w-full justify-end">
<p className="max-w-[85%] rounded-xl bg-[#f7f7f7] p-3 text-[14px] leading-[1.5] text-[#111827]">{m.text}</p>
</div>
{m.trail && <StatusTrail searching />}
</div>
) : (
<div key={m.id} className={`flex w-full flex-col gap-2.5 ${i === 1 ? "py-10" : ""}`}>
<p className="whitespace-pre-line text-[14px] leading-[1.5] text-[#111827]">{m.text}</p>
{m.sources && (
<div className="flex flex-wrap gap-2">
{m.sources.map((s) => (
<button
key={s}
type="button"
className="rounded-3xl border border-[#c7d2fe] bg-[#eef2ff] px-2.5 py-1 text-[11px] leading-normal text-[#6366f1] transition-colors hover:bg-[#e0e7ff] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#6366f1]"
>
{s}
</button>
))}
</div>
)}
</div>
),
)}
{phase !== "idle" && <StatusTrail searching={phase === "searching"} live />}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
ask(draft);
}}
className="flex items-center gap-2.5 rounded-lg border border-[#e5e7eb] bg-white p-2.5 focus-within:border-[#6366f1]"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" strokeWidth="2.5" strokeLinecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.35-4.35" />
</svg>
<label htmlFor="ai-ask" className="sr-only">
문서에 대해 질문하기
</label>
<input
id="ai-ask"
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="이 문서를 요약해주세요."
className="min-w-0 flex-1 bg-transparent text-[14px] leading-normal text-[#111827] outline-none placeholder:text-[#94a3b8]"
/>
<button
type="submit"
disabled={!draft.trim() || phase !== "idle"}
aria-label="보내기"
className="flex size-7 shrink-0 items-center justify-center rounded-md bg-[#6366f1] text-white transition-opacity hover:bg-[#4f46e5] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#6366f1] disabled:cursor-not-allowed disabled:opacity-30"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
</button>
</form>
</div>
</section>
);
}
적용 사이트 0
이 블록이 실제로 쓰인 사이트입니다.
아직 등록된 사이트가 없습니다. 이 블록을 쓴 사이트가 있다면 알려 주세요.
재현 후기
v1 · 0개아직 이 버전의 프롬프트로 직접 만들어 본 사람이 없습니다. 첫 번째로 재현해 보세요.