Files
Aislo/B01_Dashboard/B01_Dashboard_UI_Knowledge_Chat.ts
T

170 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 : "원문을 불러오지 못했습니다.";
});
},
});
}