Files
Aislo/B01_Dashboard/B01_Dashboard_UI_Knowledge_Chat.ts
T
eomsangdonandClaude Opus 5.5 192d4feebe fix(b01): 챗봇 창 블랙 · 화이트 테마 색 맞춤 · Enter 보내기 · Shift+Enter 줄바꿈 (275-1·2)
- 없는 색 변수(--color-text-primary · --color-background) 걷고 테마 변수로 — 어두운 테마 제목 · 말풍선 글자 안 보임 해소
- 단추 · 내 말풍선 = primary 색 짝 · 답 말풍선 = 떠 있는 표면 + 테두리 · 입력칸 바탕 · 글자
- Enter = 보내기 · Shift+Enter = 줄바꿈 · 한글 조합 중 Enter 무시
- 화면 시험 ui_checks/275 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjUPRxjDxULvh5VA7kX5py
2026-10-11 21:28:54 +09:00

176 lines
7.3 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 = "메시지를 입력하세요 (Shift+Enter 줄바꿈)";
input.setAttribute("aria-label", "질문");
const submit = document.createElement("button");
submit.type = "submit";
submit.textContent = "보내기";
input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
submit.click();
}
});
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 : "원문을 불러오지 못했습니다.";
});
},
});
}