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 @@
`;
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) {