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) {