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:
2026-05-26 21:57:09 +08:00
parent 9f3b0f386d
commit 24f827fe02
10 changed files with 473 additions and 87 deletions
@@ -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(/^&gt; (.+)$/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 ![alt](url) — 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
@@ -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} />
+5 -3
View File
@@ -312,17 +312,19 @@ function handleServerMessage(msg: WSServerMessage) {
case 'review':
// 审查子会话消息 — 后端返回带类型的 review_messages 列表
// 支持类型: action, chat, markdown, code, search_result
if (msg.review_messages && msg.review_messages.length > 0) {
// 逐条显示审查消息,action 类型使用动作消息样式,chat 类型使用普通聊天样式
for (const rm of msg.review_messages) {
const msgType = (rm.type as MessageDisplayType) || 'chat';
addMessage({
id: `review_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
role: rm.type === 'action' ? 'action' : 'assistant',
content: rm.content,
timestamp: Date.now(),
isStreaming: false,
msgType: rm.type === 'action' ? 'action' : 'chat',
});
msgType,
metadata: rm.metadata,
} as Message);
}
}
setTyping(false);
+52
View File
@@ -197,3 +197,55 @@
.tts-playing {
animation: tts-wave 1s ease-in-out infinite;
}
/* ===== Markdown 渲染样式 ===== */
.markdown-body h1 { font-size: 1.25rem; font-weight: 700; margin: 0.75rem 0 0.5rem; }
.markdown-body h2 { font-size: 1.1rem; font-weight: 700; margin: 0.75rem 0 0.4rem; }
.markdown-body h3 { font-size: 1rem; font-weight: 700; margin: 0.6rem 0 0.3rem; }
.markdown-body h4, .markdown-body h5, .markdown-body h6 { font-size: 0.95rem; font-weight: 600; margin: 0.5rem 0 0.25rem; }
.markdown-body p { margin: 0.3rem 0; }
.markdown-body ul, .markdown-body ol { margin: 0.3rem 0; padding-left: 1.5rem; }
.markdown-body li { margin: 0.15rem 0; }
.markdown-body ul li { list-style-type: disc; }
.markdown-body ol li { list-style-type: decimal; }
.markdown-body strong { font-weight: 700; color: inherit; }
.markdown-body em { font-style: italic; }
.markdown-body a { color: #ec4899; text-decoration: underline; word-break: break-all; }
.markdown-body a:hover { color: #be185d; }
.markdown-body blockquote {
border-left: 3px solid #f9a8d4;
padding-left: 0.75rem;
margin: 0.4rem 0;
color: #9ca3af;
font-style: italic;
}
.markdown-body hr { border: none; border-top: 1px solid #fce7f3; margin: 0.75rem 0; }
.markdown-body table { width: 100%; border-collapse: collapse; margin: 0.5rem 0; font-size: 0.85rem; }
.markdown-body th, .markdown-body td { border: 1px solid #fce7f3; padding: 0.35rem 0.6rem; text-align: left; }
.markdown-body th { background: #fdf2f8; font-weight: 600; }
.markdown-body img { max-width: 100%; height: auto; border-radius: 0.5rem; margin: 0.3rem 0; }
.markdown-body pre { background: #1e293b; border-radius: 0.5rem; padding: 0; margin: 0.5rem 0; overflow-x: auto; }
.markdown-body pre code { display: block; padding: 0.75rem 1rem; color: #e2e8f0; font-size: 0.8rem; line-height: 1.6; white-space: pre; }
.markdown-body code.inline-code {
background: #fce7f3;
color: #be185d;
padding: 0.1rem 0.35rem;
border-radius: 0.25rem;
font-size: 0.85em;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
}
.markdown-body > *:first-child { margin-top: 0; }
.markdown-body > *:last-child { margin-bottom: 0; }
/* 深色模式 Markdown */
@media (prefers-color-scheme: dark) {
.markdown-body a { color: #f472b6; }
.markdown-body a:hover { color: #fbcfe8; }
.markdown-body blockquote { border-left-color: #9d174d; color: #9ca3af; }
.markdown-body th, .markdown-body td { border-color: #374151; }
.markdown-body th { background: #1f2937; }
.markdown-body hr { border-top-color: #374151; }
.markdown-body code.inline-code { background: #4a1942; color: #fbcfe8; }
.markdown-body pre { background: #0f172a; }
.markdown-body pre code { color: #e2e8f0; }
}
+5 -2
View File
@@ -4,7 +4,7 @@
export type MessageRole = 'user' | 'assistant' | 'system' | 'action';
/** 消息显示类型 (区分聊天消息与动作消息) */
export type MessageDisplayType = 'chat' | 'action' | 'system' | 'thinking' | 'tool_progress' | 'system_info';
export type MessageDisplayType = 'chat' | 'action' | 'system' | 'thinking' | 'tool_progress' | 'system_info' | 'markdown' | 'code';
/** 对话模式 */
export type ChatMode = 'text' | 'voice_msg' | 'voice_assistant';
@@ -34,8 +34,9 @@ export interface MessageAttachment {
/** 审查消息 (后端审查子会话输出的带类型消息) */
export interface ReviewMessage {
type: 'action' | 'chat';
type: 'action' | 'chat' | 'markdown' | 'code' | 'search_result';
content: string;
metadata?: Record<string, unknown>;
}
/** 客户端信息 (多端区分) */
@@ -59,6 +60,8 @@ export interface Message {
msgType?: MessageDisplayType;
/** 消息来源客户端信息 (多端区分) */
client_info?: ClientInfo;
/** 类型特定元数据 (如 code 语言、search_result URL 等) */
metadata?: Record<string, unknown>;
}
/** IoT 设备类型定义 */