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: