|
15 | 15 | -->
|
16 | 16 | <template>
|
17 | 17 | <div class="chat-container">
|
18 |
| - <div class="messages" ref="messagesRef"> |
| 18 | + <div class="messages" ref="messagesRef" @click="handleMessageContainerClick"> |
19 | 19 | <div
|
20 | 20 | v-for="message in messages"
|
21 | 21 | :key="message.id"
|
@@ -476,6 +476,10 @@ import type { PlanExecutionRecord, AgentExecutionRecord } from '@/types/plan-exe
|
476 | 476 | import type { InputMessage } from "@/stores/memory"
|
477 | 477 | import {memoryStore} from "@/stores/memory";
|
478 | 478 | 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' |
479 | 483 |
|
480 | 484 | /**
|
481 | 485 | * Chat message interface that includes PlanExecutionRecord for plan-based messages
|
@@ -541,6 +545,38 @@ const { t } = useI18n()
|
541 | 545 | // Use the plan execution manager
|
542 | 546 | const planExecution = usePlanExecution()
|
543 | 547 |
|
| 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 | +
|
544 | 580 | const messagesRef = ref<HTMLElement>()
|
545 | 581 | const isLoading = ref(false)
|
546 | 582 | const messages = ref<Message[]>([])
|
@@ -1333,18 +1369,59 @@ const handlePlanError = (message: string) => {
|
1333 | 1369 | const formatResponseText = (text: string): string => {
|
1334 | 1370 | if (!text) return ''
|
1335 | 1371 |
|
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 |
1338 | 1392 |
|
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 | + } |
1341 | 1400 |
|
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 | + } |
1345 | 1422 | }
|
1346 | 1423 |
|
1347 |
| - return formatted |
| 1424 | + doCopy() |
1348 | 1425 | }
|
1349 | 1426 |
|
1350 | 1427 | // Handle user input form submission
|
@@ -1767,6 +1844,115 @@ defineExpose({
|
1767 | 1844 | color: #e2e8f0;
|
1768 | 1845 | font-style: italic;
|
1769 | 1846 | }
|
| 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 | + } |
1770 | 1956 | }
|
1771 | 1957 | }
|
1772 | 1958 |
|
|
0 commit comments