Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions agent/history/inley/feat-ai-markdown.md
Original file line number Diff line number Diff line change
@@ -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**: <details><summary>4개 파일</summary>

- **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` — 의존성 잠금 파일 업데이트

</details>
30 changes: 30 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
189 changes: 189 additions & 0 deletions frontend/src/ui/AiMarkdownRenderer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'
import DOMPurify from 'dompurify'

const props = defineProps<{
content: string
}>()

marked.setOptions({
gfm: true,
breaks: true
})

const sanitizedHtml = computed(() => {
const raw = marked.parse(props.content, { async: false }) as string
return DOMPurify.sanitize(raw, {
ALLOWED_TAGS: [
'p', 'br', 'hr',
'strong', 'b', 'em', 'i', 'u', 's', 'del',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li',
'code', 'pre',
'blockquote',
'a',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'span', 'div'
],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class']
})
})
</script>

<template>
<div
class="ai-markdown"
v-html="sanitizedHtml"
/>
</template>

<style scoped>
.ai-markdown {
min-width: 0;
word-break: break-word;
font-size: 0.8125rem;
line-height: 1.625;
color: inherit;
}

.ai-markdown p {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}

.ai-markdown p:first-child {
margin-top: 0;
}

.ai-markdown p:last-child {
margin-bottom: 0;
}

.ai-markdown strong {
font-weight: 700;
}

.ai-markdown ul {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
list-style-type: disc;
padding-left: 1.25rem;
}

.ai-markdown ol {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
list-style-type: decimal;
padding-left: 1.25rem;
}

.ai-markdown li {
margin-top: 0.125rem;
margin-bottom: 0.125rem;
}

.ai-markdown li > ul,
.ai-markdown li > ol {
margin-top: 0;
margin-bottom: 0;
}

.ai-markdown code {
padding: 0.125rem 0.375rem;
border-radius: 0.375rem;
font-size: 0.8125rem;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
background-color: rgba(128, 128, 128, 0.1);
border: 1px solid rgba(128, 128, 128, 0.15);
}

.ai-markdown pre {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
overflow-x: auto;
border-radius: 0.5rem;
padding: 0.75rem;
background-color: rgba(0, 0, 0, 0.06);
border: 1px solid rgba(128, 128, 128, 0.15);
}

:global(.dark) .ai-markdown pre {
background-color: rgba(255, 255, 255, 0.06);
}

.ai-markdown pre code {
padding: 0;
border: none;
background: none;
font-size: 0.75rem;
line-height: 1.5;
color: inherit;
}

.ai-markdown h1,
.ai-markdown h2,
.ai-markdown h3,
.ai-markdown h4 {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
font-weight: 600;
}

.ai-markdown h1 { font-size: 1rem; }
.ai-markdown h2 { font-size: 0.875rem; }
.ai-markdown h3 { font-size: 0.875rem; }

.ai-markdown blockquote {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
border-left: 2px solid;
padding-left: 0.75rem;
font-style: italic;
opacity: 0.8;
border-color: var(--color-border);
}

.ai-markdown hr {
margin-top: 0.75rem;
margin-bottom: 0.75rem;
border: 0;
border-top: 1px solid var(--color-border-muted);
}

.ai-markdown a {
text-decoration: underline;
color: var(--color-primary);
}

.ai-markdown a:hover {
opacity: 0.8;
}

.ai-markdown table {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
width: 100%;
border-collapse: collapse;
font-size: 0.75rem;
}

.ai-markdown th {
border: 1px solid;
border-color: var(--color-border);
padding: 0.375rem 0.5rem;
text-align: left;
font-weight: 600;
background-color: var(--color-surface-muted);
}

.ai-markdown td {
border: 1px solid;
border-color: var(--color-border);
padding: 0.375rem 0.5rem;
}

.ai-markdown tr:nth-child(even) td {
background-color: var(--color-surface-muted);
}
</style>
4 changes: 3 additions & 1 deletion frontend/src/views/AiDataChatbotView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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);'"
>
<p class="whitespace-pre-line app-type-sm leading-10">{{ message.content }}</p>
<p v-if="message.role === 'user'" class="whitespace-pre-line app-type-sm leading-10">{{ message.content }}</p>
<AiMarkdownRenderer v-else :content="message.content" />
<div class="mt-2 flex items-center gap-2 app-type-11 opacity-70">
<span>{{ formatTime(message.createdAt) }}</span>
<span v-if="message.response" class="app-status app-status-neutral">
Expand Down
Loading