Skip to content

Commit 5bade36

Browse files
authored
feat(jmanus): the chat component has added markdown preview and code block highlighting (#2124)
* feat(jmanus): the chat component has added markdown preview and code block highlighting. * fix(jmanus): translate
1 parent fb6a933 commit 5bade36

File tree

2 files changed

+198
-9
lines changed

2 files changed

+198
-9
lines changed

spring-ai-alibaba-jmanus/ui-vue3/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@
3232
"ant-design-vue": "^4.0.8",
3333
"axios": "^1.6.2",
3434
"dayjs": "^1.11.10",
35+
"dompurify": "^3.2.6",
3536
"lodash": "^4.17.21",
37+
"marked": "^16.1.2",
38+
"highlight.js": "^11.11.1",
3639
"monaco-editor": "^0.45.0",
3740
"nprogress": "^0.2.0",
3841
"pinia": "^2.3.1",

spring-ai-alibaba-jmanus/ui-vue3/src/components/chat/index.vue

Lines changed: 195 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
-->
1616
<template>
1717
<div class="chat-container">
18-
<div class="messages" ref="messagesRef">
18+
<div class="messages" ref="messagesRef" @click="handleMessageContainerClick">
1919
<div
2020
v-for="message in messages"
2121
:key="message.id"
@@ -476,6 +476,10 @@ import type { PlanExecutionRecord, AgentExecutionRecord } from '@/types/plan-exe
476476
import type { InputMessage } from "@/stores/memory"
477477
import {memoryStore} from "@/stores/memory";
478478
import {MemoryApiService} from "@/api/memory-api-service";
479+
import { marked } from 'marked'
480+
import DOMPurify from 'dompurify'
481+
import hljs from 'highlight.js'
482+
import 'highlight.js/styles/github-dark.css'
479483
480484
/**
481485
* Chat message interface that includes PlanExecutionRecord for plan-based messages
@@ -541,6 +545,38 @@ const { t } = useI18n()
541545
// Use the plan execution manager
542546
const planExecution = usePlanExecution()
543547
548+
// Configure marked once with GFM and line breaks
549+
marked.setOptions({ gfm: true, breaks: true })
550+
551+
// Custom renderer: highlight code blocks, add copy button (markdown fenced code treated same as code)
552+
const mdRenderer = new marked.Renderer()
553+
mdRenderer.code = ({ text, lang }: { text: string; lang?: string; escaped?: boolean }): string => {
554+
const langRaw = (lang || '').trim()
555+
const langLower = langRaw.toLowerCase()
556+
557+
let highlighted = ''
558+
try {
559+
if (langLower && hljs.getLanguage(langLower)) {
560+
highlighted = hljs.highlight(text, { language: langLower }).value
561+
} else {
562+
highlighted = hljs.highlightAuto(text).value
563+
}
564+
} catch (e) {
565+
highlighted = text
566+
}
567+
568+
const rawEncoded = encodeURIComponent(text)
569+
const label = langLower || 'text'
570+
return `
571+
<div class="md-code-block" data-lang="${label}">
572+
<div class="md-code-header">
573+
<span class="md-lang">${label}</span>
574+
<button class="md-copy-btn" data-raw="${rawEncoded}" title="copy">copy</button>
575+
</div>
576+
<pre><code class="hljs language-${label}">${highlighted}</code></pre>
577+
</div>`
578+
}
579+
544580
const messagesRef = ref<HTMLElement>()
545581
const isLoading = ref(false)
546582
const messages = ref<Message[]>([])
@@ -1333,18 +1369,59 @@ const handlePlanError = (message: string) => {
13331369
const formatResponseText = (text: string): string => {
13341370
if (!text) return ''
13351371
1336-
// Convert line breaks to HTML line breaks
1337-
let formatted = text.replace(/\n\n/g, '<br><br>').replace(/\n/g, '<br>')
1372+
try {
1373+
const rawHtml = marked.parse(text, { renderer: mdRenderer })
1374+
// Sanitize to avoid XSS
1375+
return DOMPurify.sanitize(rawHtml as string)
1376+
} catch (e) {
1377+
console.error('Markdown render error:', e)
1378+
// Fallback: preserve original simple formatting
1379+
let fallback = text.replace(/\n\n/g, '<br><br>').replace(/\n/g, '<br>')
1380+
fallback = fallback.replace(/(<br><br>)/g, '</p><p>')
1381+
if (fallback.includes('</p><p>')) fallback = `<p>${fallback}</p>`
1382+
return fallback
1383+
}
1384+
}
1385+
1386+
// Copy button handler (event delegation)
1387+
const handleMessageContainerClick = (event: Event) => {
1388+
const target = event.target as HTMLElement
1389+
if (!target) return
1390+
const btn = target.closest('.md-copy-btn') as HTMLElement | null
1391+
if (!btn) return
13381392
1339-
// Add appropriate paragraph spacing and formatting
1340-
formatted = formatted.replace(/(<br><br>)/g, '</p><p>')
1393+
const raw = btn.getAttribute('data-raw') || ''
1394+
let textToCopy = ''
1395+
try {
1396+
textToCopy = decodeURIComponent(raw)
1397+
} catch {
1398+
textToCopy = raw
1399+
}
13411400
1342-
// Wrap with p tags if there are multiple paragraphs
1343-
if (formatted.includes('</p><p>')) {
1344-
formatted = `<p>${formatted}</p>`
1401+
const doCopy = async () => {
1402+
try {
1403+
if (navigator.clipboard && navigator.clipboard.writeText) {
1404+
await navigator.clipboard.writeText(textToCopy)
1405+
} else {
1406+
const ta = document.createElement('textarea')
1407+
ta.value = textToCopy
1408+
ta.style.position = 'fixed'
1409+
ta.style.left = '-9999px'
1410+
document.body.appendChild(ta)
1411+
ta.select()
1412+
document.execCommand('copy')
1413+
document.body.removeChild(ta)
1414+
}
1415+
btn.textContent = 'copy'
1416+
setTimeout(() => (btn.textContent = 'copy'), 1500)
1417+
} catch (err) {
1418+
console.error('Copy failed:', err)
1419+
btn.textContent = 'copy failed'
1420+
setTimeout(() => (btn.textContent = 'copy'), 1500)
1421+
}
13451422
}
13461423
1347-
return formatted
1424+
doCopy()
13481425
}
13491426
13501427
// Handle user input form submission
@@ -1767,6 +1844,115 @@ defineExpose({
17671844
color: #e2e8f0;
17681845
font-style: italic;
17691846
}
1847+
1848+
/* Headings */
1849+
h1, h2, h3, h4, h5, h6 {
1850+
margin: 12px 0 8px;
1851+
font-weight: 700;
1852+
line-height: 1.4;
1853+
}
1854+
h1 { font-size: 22px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 6px; }
1855+
h2 { font-size: 20px; margin-top: 16px; }
1856+
h3 { font-size: 18px; }
1857+
1858+
/* Lists */
1859+
ul, ol {
1860+
margin: 6px 0 12px 22px;
1861+
padding-left: 18px;
1862+
}
1863+
li { margin: 4px 0; }
1864+
1865+
/* Blockquote */
1866+
blockquote {
1867+
margin: 10px 0;
1868+
padding: 8px 12px;
1869+
border-left: 3px solid #667eea;
1870+
background: rgba(102, 126, 234, 0.08);
1871+
color: #e5e7eb;
1872+
}
1873+
1874+
/* Inline code */
1875+
code {
1876+
background: rgba(0,0,0,0.35);
1877+
padding: 2px 6px;
1878+
border-radius: 4px;
1879+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
1880+
'Courier New', monospace;
1881+
font-size: 13px;
1882+
}
1883+
1884+
/* Code blocks */
1885+
pre {
1886+
background: rgba(0,0,0,0.5);
1887+
border: 1px solid rgba(255, 255, 255, 0.08);
1888+
border-radius: 8px;
1889+
padding: 12px 14px;
1890+
overflow: auto;
1891+
margin: 10px 0 14px;
1892+
}
1893+
pre code {
1894+
background: transparent;
1895+
padding: 0;
1896+
font-size: 13px;
1897+
line-height: 1.6;
1898+
color: #e5e7eb;
1899+
white-space: pre;
1900+
}
1901+
1902+
/* Enhanced code block container with toolbar */
1903+
:deep(.md-code-block) {
1904+
position: relative;
1905+
margin: 12px 0 16px;
1906+
border: 1px solid #30363d; /* GitHub dark border */
1907+
border-radius: 8px;
1908+
background: #0d1117; /* GitHub dark bg */
1909+
box-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
1910+
}
1911+
:deep(.md-code-block .md-code-header) {
1912+
display: flex;
1913+
align-items: center;
1914+
justify-content: space-between;
1915+
padding: 8px 10px;
1916+
border-bottom: 1px solid #30363d;
1917+
background: #161b22; /* GitHub dark header */
1918+
border-top-left-radius: 8px;
1919+
border-top-right-radius: 8px;
1920+
}
1921+
:deep(.md-code-block .md-code-header .md-lang) {
1922+
margin-right: auto;
1923+
}
1924+
:deep(.md-code-block .md-code-header .md-copy-btn) {
1925+
margin-left: auto; /* ensure right aligned */
1926+
}
1927+
:deep(.md-code-block .md-lang) {
1928+
font-size: 12px;
1929+
color: #8b949e;
1930+
text-transform: lowercase;
1931+
}
1932+
:deep(.md-code-block .md-copy-btn) {
1933+
height: 22px;
1934+
padding: 0 8px;
1935+
background: #21262d; /* GitHub dark button bg */
1936+
color: #c9d1d9; /* GitHub dark text */
1937+
border: 1px solid #30363d;
1938+
border-radius: 6px;
1939+
font-size: 12px;
1940+
cursor: pointer;
1941+
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.1s ease;
1942+
}
1943+
:deep(.md-code-block .md-copy-btn:hover) {
1944+
background: #30363d; /* GitHub dark hover bg */
1945+
color: #f0f6fc;
1946+
border-color: #8b949e;
1947+
transform: translateY(-1px);
1948+
}
1949+
:deep(.md-code-block pre) {
1950+
margin: 0;
1951+
border: none;
1952+
border-bottom-left-radius: 8px;
1953+
border-bottom-right-radius: 8px;
1954+
background: #0d1117; /* match container */
1955+
}
17701956
}
17711957
}
17721958

0 commit comments

Comments
 (0)