From 776ffd526db536fc41ca8c88a191a45e6b185ff7 Mon Sep 17 00:00:00 2001 From: inley Date: Wed, 24 Jun 2026 17:44:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20NL2SQL=20=EC=B1=97=EB=B4=87=20=EB=A7=88?= =?UTF-8?q?=ED=81=AC=EB=8B=A4=EC=9A=B4=20=EB=A0=8C=EB=8D=94=EB=9F=AC=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - marked + dompurify 의존성 추가 - AiMarkdownRenderer.vue 생성 — GFM 스펙 지원, DOMPurify로 XSS 차단 - AiDataChatbotView.vue 어시스턴트 메시지에 렌더러 적용 (사용자 메시지는 plain text 유지) Co-Authored-By: Claude Opus 4.8 (1M context) --- agent/history/inley/feat-ai-markdown.md | 18 +++ frontend/package-lock.json | 30 ++++ frontend/package.json | 2 + frontend/src/ui/AiMarkdownRenderer.vue | 189 +++++++++++++++++++++++ frontend/src/views/AiDataChatbotView.vue | 4 +- 5 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 agent/history/inley/feat-ai-markdown.md create mode 100644 frontend/src/ui/AiMarkdownRenderer.vue diff --git a/agent/history/inley/feat-ai-markdown.md b/agent/history/inley/feat-ai-markdown.md new file mode 100644 index 0000000..af54268 --- /dev/null +++ b/agent/history/inley/feat-ai-markdown.md @@ -0,0 +1,18 @@ +### NL2SQL 챗봇 응답 마크다운 렌더러 도입 (Claude Code) +- **User Intent**: LLM이 생성하는 응답 텍스트에 마크다운 형식(볼드, 리스트, 코드 블록 등)이 포함되어 있으나 현재 일반 텍스트로 출력 중이어서 가독성이 떨어짐. 안전하고 완성도 높은 UI 표현을 위해 마크다운 렌더러 도입 요청. +- **Agent Context**: 프로젝트가 Vue 3 + TypeScript + Vite 환경임을 확인. `marked` + `dompurify` 조합을 선택하여 GFM 스펙 지원과 XSS 방지를 동시에 달성. 기존 메시지 버블은 `whitespace-pre-line`으로 일반 텍스트 렌더링 중이었으며, 어시스턴트 메시지에만 마크다운 렌더러를 적용하여 변경 범위 최소화. +- **Key Decisions**: + - `marked` + `dompurify` 조합 선택 — 프로젝트의 Vue 환경에 적합하며(Option B), `marked.parse()` 후 `DOMPurify.sanitize()`로 XSS 차단. tailwind typography 플러그인 대신 CSS 변수 기반 직접 스타일링으로 프로젝트 디자인 시스템과 일치 + - GFM(gfm: true) + 줄바꿈(breaks: true) 활성화 — SQL 코드 블록, 테이블 렌더링, 리스트 등 LLM이 자주 생성하는 포맷을 모두 지원 + - 어시스턴트 메시지만 마크다운 렌더링, 사용자 메시지는 기존 plain text 유지 — 사용자 입력은 의도적으로 마크다운 파싱할 필요가 없으므로 변경 범위 최소화 + - `v-html` 사용 시 DOMPurify로 허용 태그/속성만 명시적 화이트리스트 처리하여 보안 위험 제로화 +- **Affected Files**:
4개 파일 + + - **Created**: + - `frontend/src/ui/AiMarkdownRenderer.vue` — 마크다운 파싱(marked) + 새니타이징(DOMPurify) + 프로젝트 CSS 변수 기반 스타일링을 포함한 렌더러 컴포넌트 + - **Modified**: + - `frontend/src/views/AiDataChatbotView.vue` (+2/-1) — 어시스턴트 메시지 영역에 AiMarkdownRenderer 적용 + - `frontend/package.json` (+2/-0) — marked, dompurify 의존성 추가 + - `frontend/package-lock.json` — 의존성 잠금 파일 업데이트 + +
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4d37073..e37c310 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@lucide/vue": "^1.17.0", "axios": "^1.16.1", + "dompurify": "^3.4.11", + "marked": "^18.0.5", "pinia": "^3.0.4", "vue": "^3.5.32", "vue-router": "^5.0.4" @@ -1180,6 +1182,13 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@vitejs/plugin-vue": { "version": "6.0.7", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", @@ -1903,6 +1912,15 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2671,6 +2689,18 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index b9fd9b1..dc49fca 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,8 @@ "dependencies": { "@lucide/vue": "^1.17.0", "axios": "^1.16.1", + "dompurify": "^3.4.11", + "marked": "^18.0.5", "pinia": "^3.0.4", "vue": "^3.5.32", "vue-router": "^5.0.4" diff --git a/frontend/src/ui/AiMarkdownRenderer.vue b/frontend/src/ui/AiMarkdownRenderer.vue new file mode 100644 index 0000000..ccd145d --- /dev/null +++ b/frontend/src/ui/AiMarkdownRenderer.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/frontend/src/views/AiDataChatbotView.vue b/frontend/src/views/AiDataChatbotView.vue index a639be4..a44e15e 100644 --- a/frontend/src/views/AiDataChatbotView.vue +++ b/frontend/src/views/AiDataChatbotView.vue @@ -13,6 +13,7 @@ import { import { useAiStore } from '@/state/aiStore' import AiAnswerSummary from '@/ui/AiAnswerSummary.vue' import AiChartPanel from '@/ui/AiChartPanel.vue' +import AiMarkdownRenderer from '@/ui/AiMarkdownRenderer.vue' import AiResultTable from '@/ui/AiResultTable.vue' import AiSqlPanel from '@/ui/AiSqlPanel.vue' @@ -173,7 +174,8 @@ function formatTime(value: string) { ? 'background-color: var(--color-primary); border-color: var(--color-primary); color: var(--color-text-inverse);' : 'background-color: var(--color-surface); border-color: var(--color-border); color: var(--color-text);'" > -

{{ message.content }}

+

{{ message.content }}

+
{{ formatTime(message.createdAt) }}