import { API_BASE_URL } from "@config/config_frontend"; import { renderHelpMarkdown } from "@ui/ui_template_help"; import { openModal } from "@ui/ui_template_modal"; type Source = { path: string; excerpt: string }; type ChatAnswer = { answer: string; sources: Source[]; detail?: string }; type ChatTurn = { role: "user" | "assistant"; content: string }; const chatIcon = ``; export function buildKnowledgeChat(): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b01-knowledge-chat"; const toggle = document.createElement("button"); toggle.type = "button"; toggle.className = "b01-knowledge-chat__toggle"; toggle.innerHTML = chatIcon; toggle.setAttribute("aria-label", "챗봇 열기"); toggle.title = "챗봇"; toggle.setAttribute("aria-expanded", "false"); const panel = document.createElement("section"); panel.className = "b01-knowledge-chat__panel"; panel.hidden = true; panel.id = "b01-knowledge-chat-panel"; panel.setAttribute("aria-label", "챗봇"); panel.addEventListener( "wheel", (event) => { if (!messages.contains(event.target as Node)) event.preventDefault(); }, { passive: false }, ); toggle.setAttribute("aria-controls", panel.id); const header = document.createElement("header"); const avatar = document.createElement("span"); avatar.className = "b01-knowledge-chat__avatar"; avatar.innerHTML = chatIcon; const title = document.createElement("strong"); title.textContent = "챗봇"; const close = document.createElement("button"); close.type = "button"; close.className = "b01-knowledge-chat__close"; close.textContent = "×"; close.setAttribute("aria-label", "챗봇 닫기"); header.append(avatar, title, close); const messages = document.createElement("div"); let history: ChatTurn[] = []; messages.className = "b01-knowledge-chat__messages"; messages.setAttribute("role", "log"); messages.setAttribute("aria-live", "polite"); const welcome = document.createElement("p"); welcome.className = "b01-knowledge-chat__reply"; welcome.textContent = "안녕하세요! 법령이나 지침에 대해 질문해 주세요. 근거 문서도 함께 보여드릴게요."; messages.append(welcome); const form = document.createElement("form"); form.className = "b01-knowledge-chat__form"; const input = document.createElement("textarea"); input.rows = 2; input.required = true; input.maxLength = 500; input.placeholder = "메시지를 입력하세요"; input.setAttribute("aria-label", "질문"); const submit = document.createElement("button"); submit.type = "submit"; submit.textContent = "보내기"; form.append(input, submit); panel.append(header, messages, form); wrap.append(toggle, panel); const setOpen = (open: boolean): void => { panel.hidden = !open; toggle.setAttribute("aria-expanded", String(!panel.hidden)); toggle.setAttribute("aria-label", open ? "챗봇 닫기" : "챗봇 열기"); if (open) input.focus(); else toggle.focus(); }; toggle.addEventListener("click", () => setOpen(Boolean(panel.hidden))); close.addEventListener("click", () => setOpen(false)); form.addEventListener("submit", async (event) => { event.preventDefault(); const question = input.value.trim(); if (question.length < 2) return; const userLine = document.createElement("p"); userLine.className = "b01-knowledge-chat__user"; userLine.textContent = question; messages.append(userLine); input.value = ""; input.disabled = submit.disabled = true; const reply = document.createElement("div"); reply.className = "b01-knowledge-chat__reply"; reply.textContent = "원문을 찾는 중입니다…"; messages.append(reply); messages.scrollTop = messages.scrollHeight; try { const response = await fetch(`${API_BASE_URL}/dashboard/knowledge-chat`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ question, history }), }); const result = (await response.json()) as ChatAnswer; if (!response.ok) throw new Error(result.detail || "답변을 받지 못했습니다."); reply.textContent = result.answer; history = [ ...history, { role: "user", content: question }, { role: "assistant", content: result.answer }, ].slice(-6) as ChatTurn[]; if (result.sources.length) { const sources = document.createElement("details"); sources.className = "b01-knowledge-chat__sources"; const summary = document.createElement("summary"); summary.textContent = `근거 원문 ${result.sources.length}건`; sources.append(summary); result.sources.forEach((source, index) => { const item = document.createElement("button"); item.type = "button"; item.className = "b01-knowledge-chat__source"; const filename = source.path.split("/").pop()?.replace(/\.md$/i, "") ?? source.path; const name = document.createElement("strong"); name.textContent = `[${index + 1}] ${filename}`; const location = document.createElement("span"); location.textContent = `원문 · ${source.path.replace(/^original\//, "")}`; item.append(name, location); item.addEventListener("click", () => void openSource(source.path, filename)); sources.append(item); }); reply.append(sources); } } catch (error) { reply.textContent = error instanceof Error ? error.message : "답변을 받지 못했습니다."; } finally { input.disabled = submit.disabled = false; input.focus(); messages.scrollTop = messages.scrollHeight; } }); return wrap; } async function openSource(path: string, title: string): Promise { openModal({ title, closeLabel: "닫기", dialogClass: "b01-knowledge-source-modal", mount: (body) => { const article = document.createElement("article"); article.className = "b01-knowledge-source-modal__content"; article.textContent = "원문을 불러오는 중입니다…"; body.append(article); void fetch(`${API_BASE_URL}/dashboard/knowledge-chat/source`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path }), }) .then(async (response) => { const result = (await response.json()) as { content?: string; detail?: string }; if (!response.ok) throw new Error(result.detail || "원문을 불러오지 못했습니다."); article.innerHTML = renderHelpMarkdown(result.content ?? ""); }) .catch((error: unknown) => { article.textContent = error instanceof Error ? error.message : "원문을 불러오지 못했습니다."; }); }, }); }