feat(agent): render assistant messages as markdown with code highlighting

提取共享 markdown 渲染模块 (src/web/lib/markdown.ts),基于 marked + highlight.js:
- 围栏代码块按语言高亮 (github-dark 主题,按需注册常用语言)
- 沿用既有的标签白名单 sanitizer,扩展允许 hljs token span
- Agent Workbench 助手消息从纯 <pre> 改为 markdown 渲染
- ComfyUIAgent 去重,复用同一渲染模块(顺带获得代码高亮)
This commit is contained in:
boos
2026-06-23 20:39:05 +08:00
parent f9118583ff
commit 99f9d5995d
5 changed files with 269 additions and 75 deletions
+1
View File
@@ -52,6 +52,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@visactor/react-vchart": "^2.0.21",
"gsap": "^3.15.0",
"highlight.js": "^11.11.1",
"marked": "^17.0.5",
"zod": "^4.3.6"
},
+86
View File
@@ -23052,3 +23052,89 @@ textarea:focus {
min-width: 0;
}
}
/* Agent Workbench — markdown-rendered assistant messages. */
.agent-markdown {
word-break: break-word;
}
.agent-markdown > :first-child {
margin-top: 0;
}
.agent-markdown > :last-child {
margin-bottom: 0;
}
.agent-markdown p {
margin: 0 0 8px;
}
.agent-markdown h1,
.agent-markdown h2,
.agent-markdown h3,
.agent-markdown h4 {
margin: 14px 0 8px;
line-height: 1.3;
}
.agent-markdown ul,
.agent-markdown ol {
margin: 0 0 8px;
padding-left: 22px;
}
.agent-markdown li {
margin: 2px 0;
}
.agent-markdown a {
color: var(--color-primary, #4a9eff);
text-decoration: underline;
text-underline-offset: 2px;
}
.agent-markdown blockquote {
margin: 0 0 8px;
padding: 2px 12px;
border-left: 3px solid var(--color-border, #2a2a2a);
color: var(--text-secondary, #aaa);
}
/* Inline code. */
.agent-markdown code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.88em;
padding: 1px 5px;
border-radius: 5px;
background: var(--color-surface-subtle, rgba(255, 255, 255, 0.06));
}
/* Fenced code blocks (highlight.js github-dark theme paints the tokens). */
.agent-markdown pre {
margin: 0 0 10px;
padding: 12px 14px;
border-radius: 10px;
overflow-x: auto;
border: 1px solid var(--color-border, #2a2a2a);
}
.agent-markdown pre code {
display: block;
padding: 0;
background: transparent;
font-size: 12.5px;
line-height: 1.55;
}
.agent-markdown table {
border-collapse: collapse;
margin: 0 0 8px;
}
.agent-markdown th,
.agent-markdown td {
border: 1px solid var(--color-border, #2a2a2a);
padding: 4px 8px;
text-align: left;
}
+171
View File
@@ -0,0 +1,171 @@
// Shared markdown renderer for chat-style surfaces (ComfyUI Agent, Agent
// Workbench). Renders GitHub-flavored markdown via `marked`, highlights fenced
// code blocks with highlight.js, then runs the output through a strict
// allowlist sanitizer before it is handed to `dangerouslySetInnerHTML`.
import { Marked } from 'marked';
import hljs from 'highlight.js/lib/core';
// A focused set of languages — enough to cover what an engineering agent
// emits, without pulling in highlight.js's full ~200-language bundle.
import bash from 'highlight.js/lib/languages/bash';
import c from 'highlight.js/lib/languages/c';
import cpp from 'highlight.js/lib/languages/cpp';
import css from 'highlight.js/lib/languages/css';
import diff from 'highlight.js/lib/languages/diff';
import dockerfile from 'highlight.js/lib/languages/dockerfile';
import go from 'highlight.js/lib/languages/go';
import ini from 'highlight.js/lib/languages/ini';
import java from 'highlight.js/lib/languages/java';
import javascript from 'highlight.js/lib/languages/javascript';
import json from 'highlight.js/lib/languages/json';
import markdown from 'highlight.js/lib/languages/markdown';
import python from 'highlight.js/lib/languages/python';
import rust from 'highlight.js/lib/languages/rust';
import sql from 'highlight.js/lib/languages/sql';
import typescript from 'highlight.js/lib/languages/typescript';
import xml from 'highlight.js/lib/languages/xml';
import yaml from 'highlight.js/lib/languages/yaml';
import 'highlight.js/styles/github-dark.css';
hljs.registerLanguage('bash', bash);
hljs.registerLanguage('c', c);
hljs.registerLanguage('cpp', cpp);
hljs.registerLanguage('css', css);
hljs.registerLanguage('diff', diff);
hljs.registerLanguage('dockerfile', dockerfile);
hljs.registerLanguage('go', go);
hljs.registerLanguage('ini', ini);
hljs.registerLanguage('java', java);
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('json', json);
hljs.registerLanguage('markdown', markdown);
hljs.registerLanguage('python', python);
hljs.registerLanguage('rust', rust);
hljs.registerLanguage('sql', sql);
hljs.registerLanguage('typescript', typescript);
hljs.registerLanguage('xml', xml);
hljs.registerLanguage('yaml', yaml);
// Common aliases so a fenced ```sh / ```yml / ```html block still highlights.
hljs.registerAliases(['sh', 'shell', 'zsh'], { languageName: 'bash' });
hljs.registerAliases(['ts', 'tsx'], { languageName: 'typescript' });
hljs.registerAliases(['js', 'jsx', 'node'], { languageName: 'javascript' });
hljs.registerAliases(['py'], { languageName: 'python' });
hljs.registerAliases(['rs'], { languageName: 'rust' });
hljs.registerAliases(['yml'], { languageName: 'yaml' });
hljs.registerAliases(['html', 'xhtml', 'svg'], { languageName: 'xml' });
hljs.registerAliases(['toml'], { languageName: 'ini' });
hljs.registerAliases(['dockerfile'], { languageName: 'dockerfile' });
export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function sanitizeUrl(raw: string | null | undefined, allowDataImage = false): string | null {
const value = String(raw || '').trim();
if (!value) return null;
if (value.startsWith('#') || value.startsWith('/')) return value;
if (allowDataImage && /^data:image\/[a-z0-9.+-]+;base64,/i.test(value)) return value;
if (/^https?:\/\//i.test(value)) return value;
if (/^mailto:/i.test(value) || /^tel:/i.test(value)) return value;
return null;
}
// Highlight a fenced code block, returning ready-to-insert inner HTML with
// `hljs-*` token spans. Falls back to plain escaped text on an unknown/missing
// language so output is never raw, unescaped code.
function highlightCode(code: string, lang: string): string {
const language = lang.trim().toLowerCase();
if (language && hljs.getLanguage(language)) {
try {
return hljs.highlight(code, { language, ignoreIllegals: true }).value;
} catch {
/* fall through to escaped plain text */
}
}
return escapeHtml(code);
}
// Strip everything not on the allowlist. Tags outside the set are unwrapped
// (children kept), a few dangerous ones are dropped entirely, and attributes
// are whitelisted per-tag. `span` with `hljs-*` classes is permitted so the
// syntax-highlight token markup survives sanitization.
function sanitizeMarkdownHtml(html: string): string {
if (typeof DOMParser !== 'function' || typeof Node === 'undefined') {
return `<p>${escapeHtml(html)}</p>`;
}
const allowedTags = new Set([
'a', 'blockquote', 'br', 'code', 'del', 'em', 'h1', 'h2', 'h3', 'h4',
'h5', 'h6', 'hr', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong',
'table', 'tbody', 'td', 'th', 'thead', 'tr', 'ul',
]);
const droppedTags = new Set(['script', 'style', 'iframe', 'object', 'embed', 'link', 'meta']);
const voidTags = new Set(['br', 'hr', 'img']);
const isHljsClass = (cls: string) => /^(hljs(\s|$)|language-[a-z0-9_-]+$)/i.test(cls);
const parser = new DOMParser();
const doc = parser.parseFromString(`<body>${html}</body>`, 'text/html');
const cleanNode = (node: Node): string => {
if (node.nodeType === Node.TEXT_NODE) return escapeHtml(node.textContent || '');
if (node.nodeType !== Node.ELEMENT_NODE) return '';
const element = node as Element;
const tag = element.tagName.toLowerCase();
if (droppedTags.has(tag)) return '';
const children = Array.from(element.childNodes).map(cleanNode).join('');
if (!allowedTags.has(tag)) return children;
const attrs: string[] = [];
if (tag === 'a') {
const href = sanitizeUrl(element.getAttribute('href'));
if (href) {
attrs.push(` href="${escapeHtml(href)}"`);
attrs.push(' target="_blank" rel="noopener noreferrer"');
}
} else if (tag === 'img') {
const src = sanitizeUrl(element.getAttribute('src'), true);
if (!src) return '';
attrs.push(` src="${escapeHtml(src)}"`);
const alt = element.getAttribute('alt');
if (alt) attrs.push(` alt="${escapeHtml(alt)}"`);
} else if (tag === 'code' || tag === 'span') {
// Keep highlight.js / language-* classes only; drop anything else.
const className = String(element.getAttribute('class') || '')
.split(/\s+/)
.filter((c) => isHljsClass(c))
.join(' ')
.trim();
if (className) attrs.push(` class="${escapeHtml(className)}"`);
else if (tag === 'span') return children; // a bare span carries no meaning
}
return voidTags.has(tag)
? `<${tag}${attrs.join('')}>`
: `<${tag}${attrs.join('')}>${children}</${tag}>`;
};
return Array.from(doc.body.childNodes).map(cleanNode).join('');
}
// A single shared Marked instance configured for GFM + highlighted code blocks.
const md = new Marked({ gfm: true, breaks: true });
md.use({
renderer: {
code({ text, lang }: { text: string; lang?: string }): string {
const language = (lang || '').trim().toLowerCase();
const highlighted = highlightCode(text, language);
const langClass = language ? ` language-${language}` : '';
return `<pre><code class="hljs${langClass}">${highlighted}</code></pre>`;
},
},
});
/** Render markdown to sanitized, syntax-highlighted HTML. Safe for innerHTML. */
export function renderMarkdown(content: string): string {
const raw = String(content || '').trim();
if (!raw) return '';
const rendered = String(md.parse(raw));
return sanitizeMarkdownHtml(rendered || `<p>${escapeHtml(raw)}</p>`);
}
+10 -1
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { getSessionToken } from '../authSession.js';
import { useToast } from '../components/Toast.js';
import { renderMarkdown } from '../lib/markdown.js';
// Agent Workbench — a minimal chat UI over POST /api/agent/chat (SSE).
// Streams assistant text, renders tool-call cards, surfaces interactive
@@ -380,7 +381,15 @@ export default function AgentWorkbench() {
{m.role === 'user' ? '你' : 'Agent'}
</div>
{m.text && (
<div style={{ whiteSpace: 'pre-wrap', fontSize: 14, lineHeight: 1.6 }}>{m.text}</div>
m.role === 'assistant' ? (
<div
className="agent-markdown"
style={{ fontSize: 14, lineHeight: 1.6 }}
dangerouslySetInnerHTML={{ __html: renderMarkdown(m.text) }}
/>
) : (
<div style={{ whiteSpace: 'pre-wrap', fontSize: 14, lineHeight: 1.6 }}>{m.text}</div>
)
)}
{m.tools.map((t) => (
<ToolCard key={t.id} tool={t} />
+1 -74
View File
@@ -1,10 +1,10 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { useLocation } from 'react-router-dom';
import { marked } from 'marked';
import { getSessionToken } from '../authSession.js';
import { useToast } from '../components/Toast.js';
import { useIsMobile } from '../components/useIsMobile.js';
import { filterModelTesterModelNames } from './helpers/modelTesterSession.js';
import { renderMarkdown } from '../lib/markdown.js';
type AgentRole = 'user' | 'assistant' | 'system';
@@ -591,79 +591,6 @@ function appendLine(current: string, line: string) {
return current ? `${current}\n${line}` : line;
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function sanitizeUrl(raw: string | null | undefined, allowDataImage = false): string | null {
const value = String(raw || '').trim();
if (!value) return null;
if (value.startsWith('#') || value.startsWith('/')) return value;
if (allowDataImage && /^data:image\/[a-z0-9.+-]+;base64,/i.test(value)) return value;
if (/^https?:\/\//i.test(value)) return value;
if (/^mailto:/i.test(value) || /^tel:/i.test(value)) return value;
return null;
}
function sanitizeMarkdownHtml(html: string): string {
if (typeof DOMParser !== 'function' || typeof Node === 'undefined') {
return `<p>${escapeHtml(html)}</p>`;
}
const allowedTags = new Set([
'a', 'blockquote', 'br', 'code', 'del', 'em', 'h1', 'h2', 'h3', 'h4',
'h5', 'h6', 'hr', 'img', 'li', 'ol', 'p', 'pre', 'strong', 'table',
'tbody', 'td', 'th', 'thead', 'tr', 'ul',
]);
const droppedTags = new Set(['script', 'style', 'iframe', 'object', 'embed', 'link', 'meta']);
const voidTags = new Set(['br', 'hr', 'img']);
const parser = new DOMParser();
const doc = parser.parseFromString(`<body>${html}</body>`, 'text/html');
const cleanNode = (node: Node): string => {
if (node.nodeType === Node.TEXT_NODE) return escapeHtml(node.textContent || '');
if (node.nodeType !== Node.ELEMENT_NODE) return '';
const element = node as Element;
const tag = element.tagName.toLowerCase();
if (droppedTags.has(tag)) return '';
const children = Array.from(element.childNodes).map(cleanNode).join('');
if (!allowedTags.has(tag)) return children;
const attrs: string[] = [];
if (tag === 'a') {
const href = sanitizeUrl(element.getAttribute('href'));
if (href) {
attrs.push(` href="${escapeHtml(href)}"`);
attrs.push(' target="_blank" rel="noopener noreferrer"');
}
} else if (tag === 'img') {
const src = sanitizeUrl(element.getAttribute('src'), true);
if (!src) return '';
attrs.push(` src="${escapeHtml(src)}"`);
const alt = element.getAttribute('alt');
if (alt) attrs.push(` alt="${escapeHtml(alt)}"`);
} else if (tag === 'code') {
const className = String(element.getAttribute('class') || '').trim();
if (/^language-[a-z0-9_-]+$/i.test(className)) {
attrs.push(` class="${escapeHtml(className)}"`);
}
}
return voidTags.has(tag)
? `<${tag}${attrs.join('')}>`
: `<${tag}${attrs.join('')}>${children}</${tag}>`;
};
return Array.from(doc.body.childNodes).map(cleanNode).join('');
}
function renderMarkdown(content: string): string {
const raw = String(content || '').trim();
if (!raw) return '';
const rendered = String(marked.parse(raw, { gfm: true, breaks: true }));
return sanitizeMarkdownHtml(rendered || `<p>${escapeHtml(raw)}</p>`);
}
function processThinkTags(content: string, reasoningContent = '') {
if (!content || !/<think>/i.test(content)) {
return { content, reasoningContent };