feat: 富文本消息类型支持 — Markdown/代码块安全渲染 + 审查解析器
添加 review_parser.go 从 LLM 输出中提取 Markdown 和代码块,创建独立 ReviewMessage 类型 (markdown/code/search_result)。前端新增安全 Markdown 渲染器 (HTML 转义优先),代码块以深色背景+语言标签展示。Markdown/代码 类型禁止断句拆分,避免格式损坏。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight, secure Markdown renderer.
|
||||
*
|
||||
* 1. HTML-escapes all content first (prevents XSS).
|
||||
* 2. Converts common Markdown syntax to HTML.
|
||||
*
|
||||
* Supported syntax:
|
||||
* - Headings (# ## ### … ######)
|
||||
* - Bold (**text**), Italic (*text*)
|
||||
* - Inline code (`code`)
|
||||
* - Fenced code blocks (```lang … ```)
|
||||
* - Links [text](url) → target=_blank rel=noopener
|
||||
* - Unordered lists (- or *)
|
||||
* - Ordered lists (1. 2. …)
|
||||
* - Blockquotes (> text)
|
||||
* - Horizontal rules (--- or ***)
|
||||
* - Tables (| col | col |)
|
||||
* - Line breaks (two trailing spaces or blank line)
|
||||
*/
|
||||
export function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
|
||||
const html = useMemo(() => renderMarkdown(content), [content]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`markdown-body ${className}`}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- converter ----
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
// 1. HTML-escape
|
||||
let html = escapeHTML(text);
|
||||
|
||||
// 2. Fenced code blocks (before other transformations — they contain raw markdown chars)
|
||||
html = html.replace(
|
||||
/```(\w*)\n([\s\S]*?)```/g,
|
||||
(_: string, lang: string, code: string) =>
|
||||
`<pre><code class="language-${lang || 'text'}">${code.trim()}</code></pre>`
|
||||
);
|
||||
|
||||
// 3. Headings (after code blocks to avoid matching # comments in code)
|
||||
html = html.replace(/^###### (.+)$/gm, '<h6>$1</h6>');
|
||||
html = html.replace(/^##### (.+)$/gm, '<h5>$1</h5>');
|
||||
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
|
||||
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||||
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
||||
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
||||
|
||||
// 4. Horizontal rules
|
||||
html = html.replace(/^(---|\*\*\*)$/gm, '<hr />');
|
||||
|
||||
// 5. Blockquotes
|
||||
html = html.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>');
|
||||
|
||||
// 6. Bold and italic
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/(?<!\*)\*(.+?)\*(?!\*)/g, '<em>$1</em>');
|
||||
|
||||
// 7. Inline code (already escaped, just wrap)
|
||||
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
|
||||
|
||||
// 8. Links [text](url) — open in new tab
|
||||
html = html.replace(
|
||||
/\[([^\]]+)\]\(([^)]+)\)/g,
|
||||
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>'
|
||||
);
|
||||
|
||||
// 9. Images  — after links to avoid false match
|
||||
html = html.replace(
|
||||
/!\[([^\]]*)\]\(([^)]+)\)/g,
|
||||
'<img src="$2" alt="$1" loading="lazy" />'
|
||||
);
|
||||
|
||||
// 10. Tables — detect lines with at least two | separators
|
||||
const lines = html.split('\n');
|
||||
const result: string[] = [];
|
||||
let inTable = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const isTableLine = /^\|.*\|.*\|/.test(line);
|
||||
const isSepLine = /^\|[\s\-:|]+\|/.test(line);
|
||||
|
||||
if (isTableLine) {
|
||||
if (!inTable) {
|
||||
result.push('<table>');
|
||||
inTable = true;
|
||||
}
|
||||
|
||||
if (isSepLine) {
|
||||
// Alignment separator row — skip rendering but use for alignment info
|
||||
continue;
|
||||
}
|
||||
|
||||
const cells = line
|
||||
.replace(/^\|/, '')
|
||||
.replace(/\|$/, '')
|
||||
.split('|')
|
||||
.map((c) => c.trim());
|
||||
|
||||
const isFirstRow = inTable && result[result.length - 1] === '<table>';
|
||||
const tag = isFirstRow ? 'th' : 'td';
|
||||
result.push(
|
||||
'<tr>' + cells.map((c) => `<${tag}>${c}</${tag}>`).join('') + '</tr>'
|
||||
);
|
||||
} else {
|
||||
if (inTable) {
|
||||
result.push('</table>');
|
||||
inTable = false;
|
||||
}
|
||||
result.push(line);
|
||||
}
|
||||
}
|
||||
if (inTable) {
|
||||
result.push('</table>');
|
||||
}
|
||||
html = result.join('\n');
|
||||
|
||||
// 11. Unordered lists
|
||||
html = html.replace(/^[\-\*] (.+)$/gm, '<li>$1</li>');
|
||||
html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
|
||||
|
||||
// 12. Ordered lists
|
||||
html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
|
||||
// Wrap consecutive <li> blocks that aren't already inside a <ul>
|
||||
// (This is a simplification — real markdown would need a proper parser)
|
||||
|
||||
// 13. Paragraphs: wrap remaining text lines in <p>, skip already-tagged lines
|
||||
const finalLines = html.split('\n');
|
||||
const out: string[] = [];
|
||||
let para: string[] = [];
|
||||
|
||||
function flushPara() {
|
||||
if (para.length > 0) {
|
||||
out.push('<p>' + para.join('\n') + '</p>');
|
||||
para = [];
|
||||
}
|
||||
}
|
||||
|
||||
for (const l of finalLines) {
|
||||
const isBlock =
|
||||
/^<(h[1-6]|ul|ol|li|table|pre|blockquote|hr|div|p|tr|th|td|thead|tbody|img)/.test(l) ||
|
||||
/^<\/(ul|ol|table|blockquote|pre|div)>/.test(l) ||
|
||||
l === '';
|
||||
|
||||
if (isBlock) {
|
||||
flushPara();
|
||||
if (l !== '') out.push(l);
|
||||
} else {
|
||||
para.push(l);
|
||||
}
|
||||
}
|
||||
flushPara();
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
/** Escape HTML special characters. */
|
||||
function escapeHTML(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useChatStore } from '@/store/chatStore';
|
||||
import { useSpeechSynthesis } from '@/hooks/useSpeechSynthesis';
|
||||
import type { MessageAttachment, MultiMessageItem, StreamSegment, MessageDisplayType } from '@/types/chat';
|
||||
import { ImageLightbox } from './ImageLightbox';
|
||||
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
id: string;
|
||||
@@ -16,6 +17,7 @@ interface MessageBubbleProps {
|
||||
multiMessages?: MultiMessageItem[];
|
||||
streamSegments?: StreamSegment[];
|
||||
msgType?: MessageDisplayType;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,12 +154,15 @@ export function MessageBubble({
|
||||
multiMessages,
|
||||
streamSegments,
|
||||
msgType,
|
||||
metadata,
|
||||
}: MessageBubbleProps) {
|
||||
const isUser = role === 'user';
|
||||
const isAction = role === 'action' || msgType === 'action';
|
||||
const isThinking = msgType === 'thinking';
|
||||
const isToolProgress = msgType === 'tool_progress';
|
||||
const isSystemInfo = msgType === 'system_info';
|
||||
const isMarkdown = msgType === 'markdown';
|
||||
const isCode = msgType === 'code';
|
||||
|
||||
// 动作消息使用独立的渲染方式
|
||||
if (isAction) {
|
||||
@@ -195,6 +200,45 @@ export function MessageBubble({
|
||||
);
|
||||
}
|
||||
|
||||
// 代码块 — 独立渲染,深色背景 + 语言标签
|
||||
if (isCode) {
|
||||
const lang = (metadata?.language as string) || '';
|
||||
return (
|
||||
<div className="flex px-4 py-0.5 gap-2 items-start group animate-fadeIn">
|
||||
<div className="w-8 flex-shrink-0" />
|
||||
<div className="max-w-[85%] w-full my-1 rounded-xl overflow-hidden border border-gray-200 dark:border-gray-700 bg-gray-900 dark:bg-gray-950 shadow-sm">
|
||||
{lang && (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-gray-800 dark:bg-gray-900 border-b border-gray-700">
|
||||
<span className="text-[10px] uppercase tracking-wider text-gray-400 font-mono">{lang}</span>
|
||||
</div>
|
||||
)}
|
||||
<pre className="px-4 py-3 overflow-x-auto">
|
||||
<code className="text-xs md:text-sm text-gray-100 font-mono leading-relaxed whitespace-pre">{content}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 flex-shrink-0 hidden md:block md:opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{new Date(timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Markdown — 在昔涟气泡内使用 MarkdownRenderer
|
||||
if (isMarkdown) {
|
||||
const time = new Date(timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
return (
|
||||
<div className="flex px-4 py-2 gap-2 items-end group">
|
||||
<CyreneAvatar size="sm" className="flex-shrink-0 mb-0.5" />
|
||||
<div className="max-w-[75%] px-4 py-2.5 rounded-2xl text-sm leading-relaxed shadow-sm bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 rounded-bl-md border border-pink-100 dark:border-pink-900">
|
||||
<MarkdownRenderer content={content} />
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 flex-shrink-0 mb-1 hidden md:block md:opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
{time}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
|
||||
@@ -73,6 +73,7 @@ export function MessageList({
|
||||
isStreaming={msg.isStreaming}
|
||||
attachments={msg.attachments}
|
||||
msgType={msg.msgType}
|
||||
metadata={msg.metadata}
|
||||
/>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
|
||||
Reference in New Issue
Block a user