nnuer
강희태@gmlxo9917

진행 상태와 출처 칩이 있는 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개

아직 이 버전의 프롬프트로 직접 만들어 본 사람이 없습니다. 첫 번째로 재현해 보세요.