diff --git a/dap-gateway/src/main/resources/static/chat.html b/dap-gateway/src/main/resources/static/chat.html index 9346d2e3..3c18712e 100644 --- a/dap-gateway/src/main/resources/static/chat.html +++ b/dap-gateway/src/main/resources/static/chat.html @@ -5,6 +5,7 @@ AX HUB - ChatClient + @@ -150,7 +165,7 @@
Assistant -
+
`; 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) {