170 lines
7.1 KiB
TypeScript
170 lines
7.1 KiB
TypeScript
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 = `<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 5h16a5 5 0 0 1 5 5v10a5 5 0 0 1-5 5H13l-7 4v-5a5 5 0 0 1-3-4V10a5 5 0 0 1 5-5Z"/><path d="M11 19c0-5 4-8 10-8 0 6-3 10-8 10"/><path d="M11 22c2-3 5-5 9-7"/></svg>`;
|
||
|
||
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<void> {
|
||
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 : "원문을 불러오지 못했습니다.";
|
||
});
|
||
},
|
||
});
|
||
}
|