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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
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>`);
|
||||
}
|
||||
|
||||
@@ -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,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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user