feat(app): add markdown rendering in chat messages

Install markdown-it and render assistant messages through it with safe
defaults (html: false, linkify, breaks). Add CSS for code blocks,
lists, links, blockquotes, headings. Links open in new tab. User
messages remain plain text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 20:20:13 +00:00
co-authored by Claude Opus 4.6
parent cb3a50a920
commit 81ccd69aae
4 changed files with 199 additions and 2 deletions
+2
View File
@@ -20,6 +20,7 @@
},
"dependencies": {
"@aiui/core": "workspace:*",
"markdown-it": "^14.1.1",
"pinia": "^3.0.4",
"plyr": "^3.8.4",
"vue": "^3.5.29",
@@ -29,6 +30,7 @@
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.49.0",
"@tailwindcss/vite": "^4.2.1",
"@types/markdown-it": "^14.1.2",
"@vitejs/plugin-vue": "^6.0.4",
"duck-duck-scrape": "^2.2.7",
"eslint": "^10.0.2",
@@ -9,8 +9,17 @@
:class="[bubbleClasses, { 'cursor-pointer': hasContext }]"
@click="handleBubbleClick"
>
<p class="text-sm leading-relaxed whitespace-pre-wrap break-words"
:class="isDark ? 'text-white/90' : 'text-gray-800'">{{ displayText }}</p>
<div
v-if="!isUser"
class="chat-markdown text-sm leading-relaxed break-words"
:class="isDark ? 'text-white/90' : 'text-gray-800'"
v-html="renderedMarkdown"
/>
<p
v-else
class="text-sm leading-relaxed whitespace-pre-wrap break-words"
:class="isDark ? 'text-white/90' : 'text-gray-800'"
>{{ displayText }}</p>
<div v-if="inlineFilms.length > 0" class="mt-3 space-y-1" @click.stop>
<FilmCard
@@ -164,6 +173,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import MarkdownIt from 'markdown-it'
import type { Message, WebSearchResult } from '@aiui/core/types/message'
import type { Film, Song, Podcast, Book, TVSeries, ImageItem, Place } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
@@ -231,6 +241,22 @@ const hasContext = computed(() => !isUser.value && (
inlineMagazineSections.value.length > 0
))
const md = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
})
// Open links in new tab
const defaultRender = md.renderer.rules.link_open || function (tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
tokens[idx].attrSet('target', '_blank')
tokens[idx].attrSet('rel', 'noopener noreferrer')
return defaultRender(tokens, idx, options, env, self)
}
const displayText = computed(() => {
if (isUser.value) return props.message.content
let text = stripContentTags(props.message.content)
@@ -238,6 +264,10 @@ const displayText = computed(() => {
return text
})
const renderedMarkdown = computed(() => {
return md.render(displayText.value)
})
const formattedTime = computed(() => {
const d = new Date(props.message.timestamp)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+94
View File
@@ -713,6 +713,100 @@ input:focus-visible {
animation: pulseGlow 2s ease-in-out infinite;
}
/* ===== CHAT MARKDOWN ===== */
.chat-markdown p {
margin-bottom: 0.5em;
}
.chat-markdown p:last-child {
margin-bottom: 0;
}
.chat-markdown strong {
font-weight: 600;
}
.chat-markdown em {
font-style: italic;
}
.chat-markdown a {
color: var(--color-accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.chat-markdown a:hover {
opacity: 0.8;
}
.chat-markdown code {
font-family: var(--font-mono);
font-size: 0.85em;
padding: 0.15em 0.35em;
border-radius: 0.25rem;
background: rgba(255, 255, 255, 0.08);
}
.chat-markdown pre {
margin: 0.75em 0;
padding: 0.75em 1em;
border-radius: 0.5rem;
background: rgba(0, 0, 0, 0.3);
overflow-x: auto;
}
.chat-markdown pre code {
padding: 0;
background: transparent;
font-size: 0.8em;
line-height: 1.5;
}
.chat-markdown ul,
.chat-markdown ol {
margin: 0.5em 0;
padding-left: 1.5em;
}
.chat-markdown ul {
list-style-type: disc;
}
.chat-markdown ol {
list-style-type: decimal;
}
.chat-markdown li {
margin-bottom: 0.25em;
}
.chat-markdown blockquote {
margin: 0.5em 0;
padding-left: 0.75em;
border-left: 3px solid rgba(255, 255, 255, 0.2);
opacity: 0.8;
}
.chat-markdown h1,
.chat-markdown h2,
.chat-markdown h3,
.chat-markdown h4 {
font-weight: 600;
margin: 0.75em 0 0.25em;
}
.chat-markdown h1 { font-size: 1.25em; }
.chat-markdown h2 { font-size: 1.15em; }
.chat-markdown h3 { font-size: 1.05em; }
.chat-markdown hr {
margin: 0.75em 0;
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
/* Light mode overrides */
@media (prefers-color-scheme: light) {
.chat-markdown code {
background: rgba(0, 0, 0, 0.06);
}
.chat-markdown pre {
background: rgba(0, 0, 0, 0.04);
}
.chat-markdown blockquote {
border-left-color: rgba(0, 0, 0, 0.15);
}
.chat-markdown hr {
border-top-color: rgba(0, 0, 0, 0.08);
}
}
/* ===== REDUCED MOTION ===== */
@media (prefers-reduced-motion: reduce) {
+71
View File
@@ -20,6 +20,9 @@ importers:
'@aiui/core':
specifier: workspace:*
version: link:../core
markdown-it:
specifier: ^14.1.1
version: 14.1.1
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@5.8.3)(vue@3.5.29(typescript@5.8.3))
@@ -42,6 +45,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.2.1
version: 4.2.1(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
'@types/markdown-it':
specifier: ^14.1.2
version: 14.1.2
'@vitejs/plugin-vue':
specifier: ^6.0.4
version: 6.0.4(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.8.3))
@@ -1171,6 +1177,15 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
'@types/node@25.3.3':
resolution: {integrity: sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==}
@@ -1368,6 +1383,9 @@ packages:
alien-signals@3.1.2:
resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
array-buffer-byte-length@1.0.2:
resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
engines: {node: '>= 0.4'}
@@ -1581,6 +1599,10 @@ packages:
entities@2.2.0:
resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
@@ -2129,6 +2151,9 @@ packages:
resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==}
engines: {node: '>= 12.0.0'}
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
loadjs@4.3.0:
resolution: {integrity: sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==}
@@ -2166,10 +2191,17 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
markdown-it@14.1.1:
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
minimatch@10.2.4:
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
engines: {node: 18 || 20 || >=22}
@@ -2337,6 +2369,10 @@ packages:
resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==}
engines: {node: ^14.13.1 || >=16.0.0}
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -2677,6 +2713,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
ufo@1.6.3:
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
@@ -4023,6 +4062,15 @@ snapshots:
'@types/json-schema@7.0.15': {}
'@types/linkify-it@5.0.0': {}
'@types/markdown-it@14.1.2':
dependencies:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
'@types/mdurl@2.0.0': {}
'@types/node@25.3.3':
dependencies:
undici-types: 7.18.2
@@ -4312,6 +4360,8 @@ snapshots:
alien-signals@3.1.2: {}
argparse@2.0.1: {}
array-buffer-byte-length@1.0.2:
dependencies:
call-bound: 1.0.4
@@ -4524,6 +4574,8 @@ snapshots:
entities@2.2.0: {}
entities@4.5.0: {}
entities@7.0.1: {}
es-abstract@1.24.1:
@@ -5125,6 +5177,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.31.1
lightningcss-win32-x64-msvc: 1.31.1
linkify-it@5.0.0:
dependencies:
uc.micro: 2.1.0
loadjs@4.3.0: {}
local-pkg@1.1.2:
@@ -5161,8 +5217,19 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
markdown-it@14.1.1:
dependencies:
argparse: 2.0.1
entities: 4.5.0
linkify-it: 5.0.0
mdurl: 2.0.0
punycode.js: 2.3.1
uc.micro: 2.1.0
math-intrinsics@1.1.0: {}
mdurl@2.0.0: {}
minimatch@10.2.4:
dependencies:
brace-expansion: 5.0.4
@@ -5320,6 +5387,8 @@ snapshots:
pretty-bytes@6.1.1: {}
punycode.js@2.3.1: {}
punycode@2.3.1: {}
quansync@0.2.11: {}
@@ -5726,6 +5795,8 @@ snapshots:
typescript@5.8.3: {}
uc.micro@2.1.0: {}
ufo@1.6.3: {}
unbox-primitive@1.1.0: