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
@@ -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' })