feat: markdown 추가
This commit is contained in:
@@ -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, "'");
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user