feat: markdown 추가

This commit is contained in:
juheelee
2026-07-30 17:59:06 +09:00
parent b5ff930556
commit 39f68ccf46

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AX HUB - ChatClient</title>
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
<script src="/vendor/marked.umd.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; background-color: #0f1115; color: #e2e8f0; }
@@ -25,6 +26,20 @@
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
.markdown-content > :first-child { margin-top: 0; }
.markdown-content > :last-child { margin-bottom: 0; }
.markdown-content p { margin: 0.5rem 0; }
.markdown-content ul, .markdown-content ol { margin: 0.5rem 0; padding-left: 1.5rem; }
.markdown-content ul { list-style: disc; }
.markdown-content ol { list-style: decimal; }
.markdown-content a { color: #6ee7b7; text-decoration: underline; }
.markdown-content code { padding: 0.125rem 0.3rem; border-radius: 0.25rem; background: #0f1115; color: #f1f5f9; }
.markdown-content pre { margin: 0.75rem 0; padding: 0.75rem; overflow-x: auto; border-radius: 0.5rem; background: #0f1115; }
.markdown-content pre code { padding: 0; background: transparent; }
.markdown-content blockquote { margin: 0.5rem 0; padding-left: 0.75rem; border-left: 3px solid #34d399; color: #cbd5e1; }
.markdown-content table { width: 100%; margin: 0.5rem 0; border-collapse: collapse; }
.markdown-content th, .markdown-content td { padding: 0.4rem; border: 1px solid #475569; text-align: left; }
</style>
</head>
<body class="h-screen flex flex-col items-center justify-center p-4">
@@ -150,7 +165,7 @@
</div>
<div class="flex flex-col gap-1 max-w-[80%]">
<span class="text-xs text-slate-500 ml-1 font-medium">Assistant</span>
<div class="bg-[#1e2128] px-5 py-3.5 rounded-2xl rounded-tl-sm text-sm text-slate-200 leading-relaxed border border-white/5 shadow-sm whitespace-pre-wrap bot-text"></div>
<div class="bg-[#1e2128] px-5 py-3.5 rounded-2xl rounded-tl-sm text-sm text-slate-200 leading-relaxed border border-white/5 shadow-sm markdown-content bot-text"></div>
</div>
`;
chatBox.appendChild(div);
@@ -184,6 +199,61 @@
.replace(/'/g, "&#039;");
}
function sanitizeRenderedHtml(html) {
const template = document.createElement('template');
template.innerHTML = html;
template.content.querySelectorAll('script, iframe, object, embed, style, link, meta').forEach(element => element.remove());
template.content.querySelectorAll('*').forEach(element => {
[...element.attributes].forEach(attribute => {
const name = attribute.name.toLowerCase();
const value = attribute.value.trim();
if (name.startsWith('on') || name === 'style' ||
(['href', 'src', 'xlink:href'].includes(name) && /^(javascript|data|vbscript):/i.test(value))) {
element.removeAttribute(attribute.name);
}
});
});
return template.innerHTML;
}
function normalizeMarkdown(markdown) {
// 일부 모델은 문단 뒤의 제목 표기(예: "설명입니다.## 제목") 앞 줄바꿈을 생략한다.
// 또한 "###제목", "###📝 제목"처럼 해시 뒤 공백을 생략하는 출력도 제목으로 보정한다.
return markdown
.replace(/\r\n?/g, '\n')
.replace(/([^\n])((?:#{1,6})\s+)/g, '$1\n$2')
.replace(/(^|\n)(#{1,6})([^\s#])/g, '$1$2 $3');
}
function renderMarkdown(container, markdown) {
// 프로젝트에 포함한 표준 GFM 렌더러가 제목, 강조, 표, 목록, 코드, 링크 등을 일괄 처리한다.
const normalizedMarkdown = normalizeMarkdown(markdown);
if (typeof marked !== 'undefined') {
const html = marked.parse(normalizedMarkdown, { breaks: true, gfm: true });
container.innerHTML = sanitizeRenderedHtml(html);
return;
}
// 로컬 라이브러리 로딩에 실패한 경우에는 안전한 일반 텍스트로 표시한다.
container.textContent = normalizedMarkdown;
}
function consumeSseEvents(buffer, onData) {
// Spring SSE는 응답 본문의 줄바꿈을 여러 data: 줄로 전송한다.
// data 줄을 단순 연결하면 Markdown 표의 행 구분이 사라지므로, 이벤트 단위로 복원한다.
let eventEnd;
while ((eventEnd = buffer.indexOf('\n\n')) !== -1) {
const event = buffer.substring(0, eventEnd);
buffer = buffer.substring(eventEnd + 2);
const dataLines = event.split('\n')
.filter(line => line.startsWith('data:'))
.map(line => line.substring(5).replace(/^ /, ''));
if (dataLines.length > 0) onData(dataLines.join('\n'));
}
return buffer;
}
async function sendMessage() {
const text = chatInput.value.trim();
if (!text || isLoading) return;
@@ -214,24 +284,24 @@
// 4. 로딩 숨기고 봇 응답 컨테이너 생성
hideLoading();
const textContainer = appendBotMessageContainer();
let responseText = '';
// 5. 스트림 읽기
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let sseBuffer = '';
const appendResponse = (data) => {
responseText += data;
renderMarkdown(textContainer, responseText);
scrollToBottom();
};
while (true) {
const { done, value } = await reader.read();
sseBuffer += decoder.decode(value || new Uint8Array(), { stream: !done }).replace(/\r\n/g, '\n');
sseBuffer = consumeSseEvents(sseBuffer, appendResponse);
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data:')) {
const data = line.substring(5);
textContainer.innerHTML += escapeHtml(data);
scrollToBottom();
}
}
}
} catch (error) {