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:
co-authored by
Claude Opus 4.6
parent
cb3a50a920
commit
81ccd69aae
@@ -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' })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user