mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-18 13:49:09 -04:00
feat(ui): per-message code blocks get a language header + copy button
Chat code blocks now render inside a framed block with a header showing the language and a copy button (delegated handler, copies the block and flips to a check briefly). Decoration + highlighting run from a MutationObserver scoped to the messages container, which fires reliably for streamed responses AND for chats loaded/switched from storage - the prior render-keyed effect missed the load path (code was left unhighlighted on reload). The observer disconnects while mutating so it does not retrigger on its own edits. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
@@ -3185,6 +3185,49 @@ select.input {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* Code block with a header (language + copy), injected by enhanceCodeBlocks. */
|
||||
.code-block {
|
||||
margin: var(--spacing-sm) 0;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
.code-block__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px var(--spacing-sm);
|
||||
background: var(--color-surface-sunken);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.code-block__lang {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.code-block .code-copy-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
transition: color var(--duration-fast) var(--ease-default),
|
||||
background var(--duration-fast) var(--ease-default);
|
||||
}
|
||||
.code-block .code-copy-btn:hover { color: var(--color-text-primary); background: var(--color-bg-hover); }
|
||||
.code-block .code-copy-btn--ok { color: var(--color-success); }
|
||||
/* Inner pre sits flush inside the framed block. */
|
||||
.code-block pre {
|
||||
margin: 0 !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.chat-message-user .chat-message-content pre {
|
||||
background: var(--color-surface-sunken);
|
||||
border-color: var(--color-border-subtle);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { fromState } from '../utils/editorNav'
|
||||
import { useChat } from '../hooks/useChat'
|
||||
import ModelSelector from '../components/ModelSelector'
|
||||
import { renderMarkdown, highlightAll } from '../utils/markdown'
|
||||
import { renderMarkdown, highlightAll, enhanceCodeBlocks } from '../utils/markdown'
|
||||
import { extractCodeArtifacts, renderMarkdownWithArtifacts } from '../utils/artifacts'
|
||||
import CanvasPanel from '../components/CanvasPanel'
|
||||
import Toggle from '../components/Toggle'
|
||||
@@ -658,12 +658,25 @@ export default function Chat() {
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [focusActive])
|
||||
|
||||
// Highlight code blocks
|
||||
// Highlight code blocks + add per-block copy buttons. A MutationObserver on
|
||||
// the messages container is more reliable than render-keyed effects: it fires
|
||||
// for loaded/switched chats AND for streaming token updates, regardless of
|
||||
// render timing. The observer is disconnected while we mutate so our own
|
||||
// highlight/enhance edits don't retrigger it.
|
||||
useEffect(() => {
|
||||
if (messagesRef.current) {
|
||||
highlightAll(messagesRef.current)
|
||||
const el = messagesRef.current
|
||||
if (!el) return
|
||||
let obs
|
||||
const run = () => {
|
||||
obs?.disconnect()
|
||||
highlightAll(el)
|
||||
enhanceCodeBlocks(el)
|
||||
obs?.observe(el, { childList: true, subtree: true })
|
||||
}
|
||||
}, [activeChat?.history, streamingContent])
|
||||
obs = new MutationObserver(run)
|
||||
run()
|
||||
return () => obs.disconnect()
|
||||
}, [activeChat?.id])
|
||||
|
||||
// Auto-grow textarea
|
||||
const autoGrowTextarea = useCallback(() => {
|
||||
|
||||
51
core/http/react-ui/src/utils/markdown.js
vendored
51
core/http/react-ui/src/utils/markdown.js
vendored
@@ -1,6 +1,7 @@
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import hljs from './hljs'
|
||||
import { copyToClipboard } from './clipboard'
|
||||
|
||||
marked.setOptions({
|
||||
highlight(code, lang) {
|
||||
@@ -25,3 +26,53 @@ export function highlightAll(element) {
|
||||
hljs.highlightElement(block)
|
||||
})
|
||||
}
|
||||
|
||||
// Decorate each (not-yet-enhanced) <pre> code block in `element` with a header
|
||||
// bar carrying the language label and a copy button. Idempotent: re-running on
|
||||
// the same DOM (e.g. while streaming) only touches new blocks. Copy clicks are
|
||||
// handled by a single delegated document listener (registered below).
|
||||
export function enhanceCodeBlocks(element) {
|
||||
if (!element) return
|
||||
element.querySelectorAll('pre:not([data-enhanced])').forEach((pre) => {
|
||||
pre.setAttribute('data-enhanced', '1')
|
||||
const code = pre.querySelector('code')
|
||||
const langMatch = code && code.className.match(/language-(\w+)/)
|
||||
const lang = langMatch ? langMatch[1] : 'text'
|
||||
const wrap = document.createElement('div')
|
||||
wrap.className = 'code-block'
|
||||
const head = document.createElement('div')
|
||||
head.className = 'code-block__head'
|
||||
const label = document.createElement('span')
|
||||
label.className = 'code-block__lang'
|
||||
label.textContent = lang
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.className = 'code-copy-btn'
|
||||
btn.setAttribute('aria-label', 'Copy code')
|
||||
btn.innerHTML = '<i class="fas fa-copy" aria-hidden="true"></i>'
|
||||
head.appendChild(label)
|
||||
head.appendChild(btn)
|
||||
pre.parentNode.insertBefore(wrap, pre)
|
||||
wrap.appendChild(head)
|
||||
wrap.appendChild(pre)
|
||||
})
|
||||
}
|
||||
|
||||
// One delegated handler for every code-copy button, anywhere in the app.
|
||||
if (typeof document !== 'undefined' && !window.__codeCopyDelegate) {
|
||||
window.__codeCopyDelegate = true
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest?.('.code-copy-btn')
|
||||
if (!btn) return
|
||||
const code = btn.closest('.code-block')?.querySelector('pre code')
|
||||
if (!code) return
|
||||
const ok = await copyToClipboard(code.innerText)
|
||||
if (!ok) return
|
||||
btn.innerHTML = '<i class="fas fa-check" aria-hidden="true"></i>'
|
||||
btn.classList.add('code-copy-btn--ok')
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = '<i class="fas fa-copy" aria-hidden="true"></i>'
|
||||
btn.classList.remove('code-copy-btn--ok')
|
||||
}, 2000)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user