Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<rich-content
v-if="shouldShowRichContent"
:content="answer.content"
:diagrams="answer.diagrams"
:message-uuid="messageUuid"
:answer-uuid="answer._uuid"
:should-stream="shouldStream"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
:string="content"
:should-stream="shouldStream"
:rich-content="true"
:diagrams="diagrams"
@streaming-complete="onStreamComplete"
/>
</template>
Expand All @@ -25,6 +26,11 @@ export default {
required: true,
type: String
},
diagrams: {
type: Array,
required: false,
default: () => []
},
messageUuid: {
required: true,
type: String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,21 @@
import { mapState } from 'pinia'

import { CODE_BLOCK_ICONS, useMarkdownHelper } from '@/composables/strings/Markdown.js'
import { THEME_VARIABLES, sanitizeDiagram, serializeSvg, svgToPngBlob } from '@/composables/strings/Mermaid.js'
import useStreamingWords from '@/composables/strings/StreamingWords.js'
import { sanitize } from '@/composables/strings/String.js'

import { useProductAssistantStore } from '@/stores/product-assistant.js'

// Sources are compared ignoring whitespace, so a diagram still matches its block if the spacing differs
function normalizeSource (source) {
return source.replace(/\s+/g, ' ').trim()
}

function diagramButton (label, className, attributes) {
return `<button type="button" class="ff-btn ff-btn--tertiary ff-btn-small ${className}" ${attributes}>${label}</button>`
}

export default {
name: 'StreamableContent',
props: {
Expand Down Expand Up @@ -64,12 +74,19 @@ export default {
type: Boolean,
required: false,
default: false
},
// SVGs rendered by the Expert, matched to mermaid blocks by their source
diagrams: {
type: Array,
required: false,
default: () => []
}
},
emits: ['streaming-complete', 'update:modelValue'],
setup () {
const { stream, text, isStreaming } = useStreamingWords({
delayMs: 30
delayMs: 10,
wordsPerTick: 4
})
const { markedInstance } = useMarkdownHelper()

Expand All @@ -86,6 +103,13 @@ export default {

return rawText
},
// Streaming re-renders the content on every tick, so each diagram is sanitized once up front
safeDiagrams () {
return this.diagrams.map(diagram => ({
source: normalizeSource(diagram.source || ''),
svg: sanitizeDiagram(diagram.svg || '')
}))
},
text () {
const text = this.shouldStream ? this.streamedText : this.rawText

Expand All @@ -104,6 +128,7 @@ export default {
isStreaming (isStreaming) {
if (!isStreaming) {
this.$emit('streaming-complete')
this.renderDiagrams()
if (this.modelValue) {
this.$emit('update:modelValue', {
...this.modelValue,
Expand All @@ -117,9 +142,124 @@ export default {
if (this.shouldStream) {
this.stream(this.rawText)
}
this.renderDiagrams()
},
updated () {
this.renderDiagrams()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So while streaming, :key="text" rebuilds the whole content every tick and updated() re-injects the diagrams each time, so the diagram buttons don't work until streaming ends. None of my clicks on Code / export registered.

Could each diagram be its own Vue component instead of going through v-html? Then it wouldn't rebuild every tick, and we could use ff-button / ff-kebab-menu, which would also fix the export buttons wrapping in the drawer on smaller screens.

},
methods: {
// Mermaid blocks stay as plain code until their closing fence has streamed in, then
// are swapped for the SVG the Expert rendered, so earlier diagrams show while later text streams.
renderDiagrams () {
if (!this.richContent) return
const blocks = [...this.$el.querySelectorAll('.ff-code-block--mermaid')]
const ready = this.isStreaming ? this.completedMermaidBlocks() : blocks.length
for (const block of blocks.slice(0, ready)) {
if (block.hasAttribute('data-mermaid-state')) continue
const source = block.querySelector('pre code')?.textContent || ''
const svg = this.findDiagramSvg(source)
if (svg) {
this.showDiagram(block, svg)
} else {
block.setAttribute('data-mermaid-state', 'failed')
}
}
},
findDiagramSvg (source) {
const key = normalizeSource(source)
return this.safeDiagrams.find(diagram => diagram.source === key)?.svg
},
// The page theme, so exports look like the diagram on screen
diagramTheme (block) {
const style = getComputedStyle(block.querySelector('.ff-code-block--diagram'))
const theme = {}
for (const name of THEME_VARIABLES) {
const value = style.getPropertyValue(name).trim()
if (value) {
theme[name] = value
}
}
return theme
},
// The streamed text is cut mid-block, so count the mermaid blocks whose end has arrived.
completedMermaidBlocks () {
const parts = this.streamedText.split('ff-code-block--mermaid').slice(1)
return parts.filter(part => part.includes('</pre>')).length
},
showDiagram (block, svg) {
const diagram = document.createElement('div')
diagram.className = 'ff-code-block--diagram'
diagram.innerHTML = svg
block.appendChild(diagram)
block.querySelector('pre').hidden = true
const actions = document.createElement('span')
actions.className = 'ff-code-block--diagram-actions'
actions.innerHTML = [
diagramButton('Code', 'ff-code-block--toggle', 'aria-pressed="false"'),
diagramButton('Copy SVG', 'ff-code-block--export', 'data-export="svg"'),
diagramButton('Copy image', 'ff-code-block--export', 'data-export="image"'),
diagramButton('Download PNG', 'ff-code-block--export', 'data-export="png"')
].join('')
block.querySelector('.ff-code-block--copy').after(actions)
block.setAttribute('data-mermaid-state', 'rendered')
},
async exportDiagram (btn) {
const block = btn.closest('.ff-code-block')
const source = block.querySelector('pre code')?.textContent || ''
const svg = this.findDiagramSvg(source)
if (!svg) return
const theme = this.diagramTheme(block)
const label = btn.textContent

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So clicking twice within 2s saves "Copied" as the label, and the button stays that way until the page is reloaded.

Image

Could do CODE_BLOCK_ICONS.ICON_CHECK swap that the code-block copy button already uses next to each label. That's consistent with it and avoids this bug.

const flash = text => {
btn.textContent = text
setTimeout(() => { btn.textContent = label }, 2000)
}
try {
switch (btn.dataset.export) {
case 'svg':
await navigator.clipboard.writeText(serializeSvg(svg, theme))
flash('Copied')
break
case 'image': {
const blob = svgToPngBlob(svg, { theme })
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
flash('Copied')
break
}
case 'png': {
const url = URL.createObjectURL(await svgToPngBlob(svg, { theme }))
const a = document.createElement('a')
a.href = url
a.download = 'diagram.png'
a.click()
// Revoking straight after the click can cancel the download in Safari and Firefox
setTimeout(() => URL.revokeObjectURL(url), 40 * 1000)
break
}
}
} catch (_) {
flash('Failed')
}
},
handleClick (event) {
const toggleBtn = event.target.closest('.ff-code-block--toggle')
if (toggleBtn) {
const block = toggleBtn.closest('.ff-code-block')
const showCode = toggleBtn.getAttribute('aria-pressed') !== 'true'
block.querySelector('pre').hidden = !showCode
block.querySelector('.ff-code-block--diagram').hidden = showCode
block.querySelectorAll('.ff-code-block--export').forEach(btn => { btn.hidden = showCode })
toggleBtn.setAttribute('aria-pressed', String(showCode))
toggleBtn.classList.toggle('active', showCode)
toggleBtn.blur()
return
}
const exportBtn = event.target.closest('.ff-code-block--export')
if (exportBtn) {
this.exportDiagram(exportBtn)
exportBtn.blur()
return
}
const copyBtn = event.target.closest('.ff-code-block--copy') || event.target.closest('.ff-table-block--copy')
if (!copyBtn) return

Expand Down Expand Up @@ -265,6 +405,40 @@ export default {
}
}

// The diagram takes its colours from these variables, so it follows the theme without being re-rendered
:deep(.ff-code-block--diagram) {
--pele-bg: var(--ff-color-bg-surface);
--pele-fg: var(--ff-color-text);
--pele-muted: var(--ff-color-text-subtle);
--pele-line: var(--ff-color-text-subtle);
--pele-surface: var(--ff-color-bg-surface-raised);
--pele-surface-alt: var(--ff-color-bg-surface-raised);
--pele-border: var(--ff-color-accent);
--pele-accent: var(--ff-color-accent);

padding: 1rem;
overflow-x: auto;
background: var(--ff-color-bg-surface);

svg {
display: block;
max-width: 100%;
height: auto;
margin: 0 auto;
}
}

:deep(.ff-code-block--diagram-actions) {
display: inline-flex;
gap: 0.5rem;
margin: 0 auto 0 0.5rem;

// .ff-btn sets display, which would override the hidden attribute
[hidden] {
display: none;
}
}

:deep(.ff-code-block--header) {
display: flex;
align-items: center;
Expand Down
46 changes: 45 additions & 1 deletion frontend/src/composables/strings/Markdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,48 @@ hljs.registerLanguage('typescript', typescript)
hljs.registerLanguage('ts', typescript)
hljs.registerLanguage('xml', xml)
hljs.registerLanguage('html', xml)
// highlight.js ships no mermaid grammar
hljs.registerLanguage('mermaid', () => ({
name: 'Mermaid',
keywords: {
keyword: [
// diagram types, the ones with a dash are matched by their own mode below
'graph', 'flowchart', 'sequenceDiagram', 'classDiagram', 'stateDiagram', 'erDiagram', 'journey', 'gantt',
'pie', 'quadrantChart', 'requirementDiagram', 'gitGraph', 'mindmap', 'timeline', 'sankey', 'xychart',
'block', 'C4Context', 'C4Container', 'C4Component', 'C4Dynamic', 'C4Deployment', 'packet', 'kanban',
'treemap', 'ishikawa', 'info',
// statements
'subgraph', 'end', 'direction', 'participant', 'actor', 'autonumber', 'activate', 'deactivate', 'create',
'destroy', 'box', 'loop', 'alt', 'else', 'opt', 'par', 'critical', 'option', 'break', 'rect', 'note',
'links', 'link', 'class', 'classDef', 'cssClass', 'style', 'linkStyle',
'click', 'call', 'href', 'callback', 'namespace', 'state', 'section', 'title', 'accTitle', 'accDescr',
'dateFormat', 'axisFormat', 'tickInterval', 'excludes', 'includes', 'todayMarker', 'weekday', 'showData',
'commit', 'branch', 'checkout', 'merge', 'requirement', 'functionalRequirement', 'interfaceRequirement', 'performanceRequirement',
'physicalRequirement', 'designConstraint', 'verifymethod', 'satisfies', 'traces', 'copies', 'derives',
'refines', 'verifies', 'columns', 'service', 'junction',
'Person', 'Person_Ext', 'System', 'System_Ext', 'SystemDb', 'SystemQueue', 'Container', 'ContainerDb',
'ContainerQueue', 'Component', 'ComponentDb', 'Boundary', 'System_Boundary', 'Container_Boundary',
'Enterprise_Boundary', 'Deployment_Node', 'Rel', 'BiRel', 'Rel_U', 'Rel_D', 'Rel_L', 'Rel_R',
'Rel_Back', 'UpdateLayoutConfig', 'UpdateRelStyle', 'UpdateElementStyle'
],
literal: ['TB', 'TD', 'BT', 'RL', 'LR', 'true', 'false']
},
contains: [
hljs.COMMENT('%%', '$'),
// keywords with a dash, which would otherwise read as a name followed by an arrow
{ className: 'keyword', begin: /\b(?:[A-Za-z]+-(?:beta|v2)|[xy]-axis|cherry-pick)\b/ },
hljs.QUOTE_STRING_MODE,
hljs.NUMBER_MODE,
// entity relationship cardinalities such as ||--o{ and }|..|{, before the |label| mode takes the pipes
{ className: 'operator', begin: /[|}][|o](?:--|\.\.)[|o][|{]/ },
// flowchart, sequence, class and state arrows such as -->, -.->, ==>, --o, <-->, ->>, -x, -), <|--, *--, ..|>
{ className: 'operator', begin: /(?:<\||[<*ox])?(?:[-=.~]{2,}(?:\|>|[>ox)]{1,2})?|-(?:>>?|x|\)))(?=[\s\w"[({|:]|$)/ },
{ className: 'meta', begin: /:::\w+|\[\*\]/ },
// edge labels and node text
{ className: 'string', begin: /\|/, end: /\|/ },
{ className: 'string', begin: /\[/, end: /\]/ }
]
}))

// Inline SVGs are intentional here — the copy button lives inside v-html rendered
// content where Vue components cannot be used.
Expand All @@ -33,14 +75,16 @@ export function useMarkdownHelper () {
renderer: {
code ({ text, lang }) {
const language = lang ? lang.split(/\s/)[0].toLowerCase() : ''
const isMermaid = language === 'mermaid'
let highlighted
if (language && hljs.getLanguage(language)) {
highlighted = hljs.highlight(text, { language }).value
} else {
highlighted = hljs.highlightAuto(text).value
}
const langLabel = language ? `<span class="ff-code-block--lang">${language}</span>` : ''
return `<div class="ff-code-block"><div class="ff-code-block--header"><button class="ff-code-block--copy">${ICON_DUPLICATE}</button>${langLabel}</div><pre><code class="hljs">${highlighted.replace(/\n/g, '&#10;')}</code></pre></div>`
const blockClass = isMermaid ? 'ff-code-block ff-code-block--mermaid' : 'ff-code-block'
return `<div class="${blockClass}"><div class="ff-code-block--header"><button class="ff-code-block--copy">${ICON_DUPLICATE}</button>${langLabel}</div><pre><code class="hljs">${highlighted.replace(/\n/g, '&#10;')}</code></pre></div>`
},
checkbox ({ checked }) {
return `<span class="ff-checkbox${checked ? ' ff-checkbox--checked' : ''}">${checked ? '&#x2611;' : '&#x2610;'}</span>`
Expand Down
64 changes: 64 additions & 0 deletions frontend/src/composables/strings/Mermaid.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import DOMPurify from 'dompurify'

// The CSS variables the diagram takes its colours from
export const THEME_VARIABLES = [
'--pele-bg',
'--pele-fg',
'--pele-muted',
'--pele-line',
'--pele-surface',
'--pele-surface-alt',
'--pele-border',
'--pele-accent'
]

// The SVG is rendered by the Expert and arrives with the message, so it is untrusted markup.
// Ids are dropped because nothing in the SVG refers to them and they repeat across the diagrams on a page.
export function sanitizeDiagram (svg) {
return DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true }, FORBID_ATTR: ['id'] })
}

function parseSvg (svg) {
const doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
return doc.documentElement
}

// Returns the diagram as standalone SVG markup with explicit pixel dimensions. The theme
// variables are written onto the SVG, as outside the page it no longer inherits them.
export function serializeSvg (svg, theme = {}) {
const el = parseSvg(svg)
const [, , width, height] = (el.getAttribute('viewBox') || '').split(/\s+/).map(Number)
if (width && height) {
el.setAttribute('width', width)
el.setAttribute('height', height)
}
el.style.maxWidth = ''
for (const [name, value] of Object.entries(theme)) {
el.style.setProperty(name, value)
}
el.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
return new XMLSerializer().serializeToString(el)
}

export async function svgToPngBlob (svg, { scale = 2, theme = {} } = {}) {
const standalone = serializeSvg(svg, theme)
const el = parseSvg(standalone)
const width = Number(el.getAttribute('width'))
const height = Number(el.getAttribute('height'))
const image = new Image()
await new Promise((resolve, reject) => {
image.onload = resolve
image.onerror = reject
image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(standalone)
})
const canvas = document.createElement('canvas')
canvas.width = Math.ceil(width * scale)
canvas.height = Math.ceil(height * scale)
const ctx = canvas.getContext('2d')
ctx.fillStyle = theme['--pele-bg'] || '#ffffff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(image, 0, 0, canvas.width, canvas.height)
return new Promise((resolve, reject) => {
canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('PNG export failed')), 'image/png')
})
}
Loading
Loading