diff --git a/packages/app/package.json b/packages/app/package.json
index 2108cebe..a7da454a 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -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",
diff --git a/packages/app/src/components/chat/ChatMessage.vue b/packages/app/src/components/chat/ChatMessage.vue
index 4fd4da9f..97e719fe 100644
--- a/packages/app/src/components/chat/ChatMessage.vue
+++ b/packages/app/src/components/chat/ChatMessage.vue
@@ -9,8 +9,17 @@
:class="[bubbleClasses, { 'cursor-pointer': hasContext }]"
@click="handleBubbleClick"
>
-
{{ displayText }}
+
+ {{ displayText }}
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' })
diff --git a/packages/app/src/styles/main.css b/packages/app/src/styles/main.css
index f90e5262..b02d9a55 100644
--- a/packages/app/src/styles/main.css
+++ b/packages/app/src/styles/main.css
@@ -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) {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1dea79cc..33dfadb4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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: