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
@@ -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',