feat(chat): render <details>/<summary> blocks as collapsible sections
markdown-it runs with html: false, so raw HTML in a message body is escaped and shown as literal text. Agents legitimately use <details>/<summary> to hide the long tail of a reply, and it rendered as a wall of visible tags. MarkdownRenderer now splits the message into plain segments and <details> blocks, renders the plain segments through markdown-it as before, and builds real <details> elements from the blocks. Nested blocks are handled by recursion; an unclosed tag does not swallow the rest of the message. The html option stays false on purpose: enabling it would let model output inject arbitrary markup into v-html. Every fragment still goes through markdown-it, so nothing unsanitised reaches the DOM. Group chat shares this component, so it picks the change up too.
This commit is contained in:
@@ -8,6 +8,7 @@ import katex from 'katex'
|
|||||||
import markdownItKatex from '@vscode/markdown-it-katex'
|
import markdownItKatex from '@vscode/markdown-it-katex'
|
||||||
import { handleCodeBlockCopyClick, renderHighlightedCodeBlock } from './highlight'
|
import { handleCodeBlockCopyClick, renderHighlightedCodeBlock } from './highlight'
|
||||||
import { repairNestedMarkdownFences } from './markdownFenceRepair'
|
import { repairNestedMarkdownFences } from './markdownFenceRepair'
|
||||||
|
import { splitDetails } from './markdownDetails'
|
||||||
import {
|
import {
|
||||||
MERMAID_MAX_DIAGRAMS_PER_MESSAGE,
|
MERMAID_MAX_DIAGRAMS_PER_MESSAGE,
|
||||||
MERMAID_MAX_SOURCE_LENGTH,
|
MERMAID_MAX_SOURCE_LENGTH,
|
||||||
@@ -143,8 +144,28 @@ function hasExtension(path: string, extensions: Set<string>): boolean {
|
|||||||
return !!ext && extensions.has(ext)
|
return !!ext && extensions.has(ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render markdown, but turn <details>/<summary> 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 `<details class="markdown-details"><summary class="markdown-details-summary">${summaryHtml}</summary><div class="markdown-details-body">${bodyHtml}</div></details>`
|
||||||
|
}).join('')
|
||||||
|
}
|
||||||
|
|
||||||
const renderedHtml = computed(() => {
|
const renderedHtml = computed(() => {
|
||||||
let html = md.render(repairNestedMarkdownFences(props.content))
|
let html = renderWithDetails(repairNestedMarkdownFences(props.content))
|
||||||
|
|
||||||
// Add IDs to headings for anchor links
|
// Add IDs to headings for anchor links
|
||||||
const prefix = props.headingIdPrefix ? `${props.headingIdPrefix}-` : ''
|
const prefix = props.headingIdPrefix ? `${props.headingIdPrefix}-` : ''
|
||||||
@@ -585,6 +606,46 @@ async function previewTextFile(path: string, fileName: string): Promise<void> {
|
|||||||
margin: 8px 0;
|
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 {
|
.markdown-video-container {
|
||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
border-radius: $radius-sm;
|
border-radius: $radius-sm;
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Collapsible <details>/<summary> 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
|
||||||
|
// <details> blocks. The caller renders the text segments through
|
||||||
|
// markdown-it as usual and substitutes the details blocks with real
|
||||||
|
// <details> 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 = /<details\b[^>]*>/i
|
||||||
|
const SUMMARY_TAG = /<summary\b[^>]*>([\s\S]*?)<\/summary\s*>/i
|
||||||
|
const TAG_SCAN = /<details\b[^>]*>|<\/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 <details> segments.
|
||||||
|
*
|
||||||
|
* Nested <details> 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 + '</details>'.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index of the `</details>` 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user