diff --git a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue index c13eceb..b775ac8 100644 --- a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue +++ b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue @@ -8,6 +8,7 @@ import katex from 'katex' import markdownItKatex from '@vscode/markdown-it-katex' import { handleCodeBlockCopyClick, renderHighlightedCodeBlock } from './highlight' import { repairNestedMarkdownFences } from './markdownFenceRepair' +import { splitDetails } from './markdownDetails' import { MERMAID_MAX_DIAGRAMS_PER_MESSAGE, MERMAID_MAX_SOURCE_LENGTH, @@ -143,8 +144,28 @@ function hasExtension(path: string, extensions: Set): boolean { return !!ext && extensions.has(ext) } +// Render markdown, but turn
/ blocks into real collapsible +// elements. markdown-it runs with `html: false`, so raw HTML in the message +// body would otherwise be escaped and shown as literal text. Every fragment +// still goes through markdown-it, so nothing unsanitised reaches v-html. +function renderWithDetails(content: string): string { + const segments = splitDetails(content) + if (segments.length === 1 && segments[0].kind === 'text') { + return md.render(content) + } + + return segments.map((segment) => { + if (segment.kind === 'text') { + return md.render(segment.value) + } + const summaryHtml = md.renderInline(segment.block.summary.trim()) + const bodyHtml = renderWithDetails(segment.block.body) + return `
${summaryHtml}
${bodyHtml}
` + }).join('') +} + const renderedHtml = computed(() => { - let html = md.render(repairNestedMarkdownFences(props.content)) + let html = renderWithDetails(repairNestedMarkdownFences(props.content)) // Add IDs to headings for anchor links const prefix = props.headingIdPrefix ? `${props.headingIdPrefix}-` : '' @@ -585,6 +606,46 @@ async function previewTextFile(path: string, fileName: string): Promise { margin: 8px 0; } + .markdown-details { + margin: 8px 0; + border: 1px solid $border-color; + border-radius: $radius-sm; + background: $code-bg; + overflow: hidden; + } + + .markdown-details-summary { + cursor: pointer; + padding: 8px 12px; + font-weight: 600; + color: $text-primary; + user-select: none; + list-style: revert; + display: list-item; + + &::marker { + color: $text-secondary; + } + + &:hover { + background: rgba(var(--bg-card-rgb), 0.6); + } + } + + .markdown-details-body { + padding: 8px 12px 4px; + border-top: 1px solid $border-color; + color: $text-primary; + + > :first-child { + margin-top: 0; + } + + > :last-child { + margin-bottom: 0; + } + } + .markdown-video-container { margin: 12px 0; border-radius: $radius-sm; diff --git a/packages/client/src/components/hermes/chat/markdownDetails.ts b/packages/client/src/components/hermes/chat/markdownDetails.ts new file mode 100644 index 0000000..b20642c --- /dev/null +++ b/packages/client/src/components/hermes/chat/markdownDetails.ts @@ -0,0 +1,92 @@ +// Collapsible
/ blocks inside markdown messages. +// +// markdown-it runs with `html: false`, so raw HTML in a message body is +// escaped and shown as literal text. That is the right default for LLM +// output (no script injection), but it also kills the one HTML construct +// agents legitimately use to hide the long tail of a reply. +// +// This module splits the raw message into plain text segments and +//
blocks. The caller renders the text segments through +// markdown-it as usual and substitutes the details blocks with real +//
HTML built from markdown-rendered inner content. Everything +// that ends up in the DOM still went through markdown-it, so no unsanitised +// model output reaches v-html. + +export interface DetailsBlock { + summary: string + body: string +} + +export type DetailsSegment = + | { kind: 'text'; value: string } + | { kind: 'details'; block: DetailsBlock } + +const OPEN_TAG = /]*>/i +const SUMMARY_TAG = /]*>([\s\S]*?)<\/summary\s*>/i +const TAG_SCAN = /]*>|<\/details\s*>/gi + +/** Marker that survives markdown rendering untouched, one per details block. */ +export function detailsToken(index: number): string { + return `hermes-details-block-${index}` +} + +/** + * Split `src` into plain text and
segments. + * + * Nested
blocks are supported: the inner blocks end up inside the + * outer block body and are split again when that body is rendered. + */ +export function splitDetails(src: string): DetailsSegment[] { + const segments: DetailsSegment[] = [] + let cursor = 0 + + while (cursor < src.length) { + const rest = src.slice(cursor) + const open = OPEN_TAG.exec(rest) + if (!open) { + segments.push({ kind: 'text', value: src.slice(cursor) }) + break + } + + const openIndex = cursor + open.index + if (openIndex > cursor) { + segments.push({ kind: 'text', value: src.slice(cursor, openIndex) }) + } + + const innerStart = openIndex + open[0].length + const innerEnd = findMatchingClose(src, innerStart) + + const inner = src.slice(innerStart, innerEnd) + const summaryMatch = SUMMARY_TAG.exec(inner) + const summary = summaryMatch ? summaryMatch[1] : '' + const body = summaryMatch + ? inner.slice(0, summaryMatch.index) + inner.slice(summaryMatch.index + summaryMatch[0].length) + : inner + + segments.push({ kind: 'details', block: { summary, body } }) + + cursor = innerEnd + '
'.length + } + + return segments +} + +/** + * Index of the `
` that closes the block opened before `from`. + * Returns src.length when the tag is missing, so an unclosed block still + * renders instead of swallowing the rest of the message. + */ +function findMatchingClose(src: string, from: number): number { + let depth = 1 + TAG_SCAN.lastIndex = from + let match: RegExpExecArray | null + while ((match = TAG_SCAN.exec(src)) !== null) { + if (match[0][1] === '/') { + depth -= 1 + if (depth === 0) return match.index + } else { + depth += 1 + } + } + return src.length +}