Files
openclaw/ui/src/components/markdown-assistant-transcript.ts
T
snowzlmbot e8ad0466ff fix: render assistant transcript headers safely (#99404)
* fix: render assistant transcript headers safely

Co-authored-by: snowzlmbot <293528334+snowzlmbot@users.noreply.github.com>

* fix(markdown): nest crossing annotation spans

* fix(markdown): protect final transport projections

* refactor(markdown): split transcript render ownership

* test(ui): cover assistant transcript render flag

* refactor(markdown): keep transcript helpers private

* docs(changelog): note assistant transcript headers

* chore(plugin-sdk): refresh transcript annotation baseline

* fix(markdown): harden final transcript projections

* docs(changelog): defer transcript note to release

* refactor(markdown): centralize HTML tokenization

* fix(markdown): satisfy lint gates

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: snowzlmbot <293528334+snowzlmbot@users.noreply.github.com>
2026-07-14 02:25:37 -07:00

96 lines
3.4 KiB
TypeScript

import type MarkdownIt from "markdown-it";
import {
ASSISTANT_TRANSCRIPT_ROLE_NODE_TYPE,
markdownItAssistantTranscriptRoles,
type AssistantTranscriptRoleImageMeta,
} from "../../../packages/markdown-core/src/assistant-transcript.js";
const ROLE_MARKER_OPEN = '<code class="assistant-transcript-role">';
const ROLE_MARKER_CLOSE = "</code>";
function renderAssistantTranscriptRoleMarker(
text: string,
escapeHtml: (value: string) => string,
): string {
return `${ROLE_MARKER_OPEN}${escapeHtml(text)}${ROLE_MARKER_CLOSE}`;
}
function renderAssistantTranscriptRoleImageLabel(
text: string,
spans: ReadonlyArray<{ start: number; end: number }>,
escapeHtml: (value: string) => string,
): string {
let rendered = "";
let cursor = 0;
for (const span of spans) {
const start = Math.max(cursor, Math.min(span.start, text.length));
const end = Math.max(start, Math.min(span.end, text.length));
rendered += escapeHtml(text.slice(cursor, start));
if (end > start) {
rendered += renderAssistantTranscriptRoleMarker(text.slice(start, end), escapeHtml);
}
cursor = end;
}
return rendered + escapeHtml(text.slice(cursor));
}
export function installAssistantTranscriptRoleMarkdown(
md: MarkdownIt,
escapeHtml: (value: string) => string,
): void {
md.use(markdownItAssistantTranscriptRoles, {
// The task-list plugin injects a trusted checkbox HTML token. It is visible
// UI structure, not text before the list item's semantic first character.
isStructuralHtmlInline: (token) => token.meta?.taskListPlugin === true,
});
md.renderer.rules[ASSISTANT_TRANSCRIPT_ROLE_NODE_TYPE] = (tokens, index) => {
const token = tokens[index];
return token ? renderAssistantTranscriptRoleMarker(token.content, escapeHtml) : "";
};
}
export function installAssistantTranscriptRoleImageRenderer(
md: MarkdownIt,
options: {
escapeHtml: (value: string) => string;
isInlineDataImage: (src: string) => boolean;
normalizeLabel: (value: string) => string;
assistantLabel: () => string;
},
): void {
md.renderer.rules.image = (tokens, index) => {
const token = tokens[index];
if (!token) {
return "";
}
const src = token.attrGet("src")?.trim() ?? "";
// token.content preserves raw Markdown formatting in image labels.
const alt = options.normalizeLabel(token.content);
const roleMeta = (token.meta as AssistantTranscriptRoleImageMeta | undefined)
?.assistantTranscriptRoleImage;
if (!options.isInlineDataImage(src)) {
return roleMeta
? renderAssistantTranscriptRoleImageLabel(roleMeta.text, roleMeta.spans, options.escapeHtml)
: options.escapeHtml(alt);
}
const image = `<img class="markdown-inline-image" src="${options.escapeHtml(src)}" alt="${options.escapeHtml(alt)}">`;
return roleMeta
? `${renderAssistantTranscriptRoleMarker(`${options.assistantLabel()}:`, options.escapeHtml)} ${image}`
: image;
};
}
export function renderAssistantTranscriptPlainTextFallback(
text: string,
enabled: boolean,
assistantLabel: () => string,
escapeHtml: (value: string) => string,
): string {
const escaped = escapeHtml(text);
if (!enabled) {
return `<div class="markdown-plain-text-fallback">${escaped}</div>`;
}
const marker = renderAssistantTranscriptRoleMarker(`${assistantLabel()}:`, escapeHtml);
return `<div class="markdown-plain-text-fallback">${marker}\n<span class="markdown-plain-text-source">${escaped}</span></div>`;
}