Files
st-react/web-app/src/components/MessageContent.tsx
Echo 23396caeeb 🎨 优化前端渲染
Signed-off-by: Echo <1711788888@qq.com>
2026-03-02 00:51:11 +08:00

467 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {useEffect, useRef, useState} from 'react'
import {Code, Eye, Play} from 'lucide-react'
interface MessageContentProps {
content: string
role: 'user' | 'assistant'
onChoiceSelect?: (choice: string) => void
}
interface Choice {
label: string
text: string
}
// 解析并高亮对白的组件
function DialogueText({ text }: { text: string }) {
// 匹配各种引号格式的对白
const dialogueRegex = /([""「『])(.*?)([""」』])|(")(.*?)(")|(')(.*?)(')/g
const parts: JSX.Element[] = []
let lastIndex = 0
let match
let key = 0
while ((match = dialogueRegex.exec(text)) !== null) {
// 添加对白之前的文本
if (match.index > lastIndex) {
parts.push(
<span key={`text-${key++}`} className="text-white/80">
{text.substring(lastIndex, match.index)}
</span>
)
}
// 提取对白内容(处理不同的引号组)
const dialogue = match[2] || match[5] || match[8]
const openQuote = match[1] || match[4] || match[7]
const closeQuote = match[3] || match[6] || match[9]
// 添加高亮的对白
parts.push(
<span key={`dialogue-${key++}`} className="inline-block">
<span className="text-primary/60">{openQuote}</span>
<span className="text-primary font-medium px-0.5">{dialogue}</span>
<span className="text-primary/60">{closeQuote}</span>
</span>
)
lastIndex = match.index + match[0].length
}
// 添加剩余的文本
if (lastIndex < text.length) {
parts.push(
<span key={`text-${key++}`} className="text-white/80">
{text.substring(lastIndex)}
</span>
)
}
return <>{parts.length > 0 ? parts : <span className="text-white/80">{text}</span>}</>
}
// 解析选择项的函数
function parseChoices(content: string): { choices: Choice[]; cleanContent: string } {
// 匹配 <choice>...</choice> 或 [choice]...[/choice] 格式
const choiceRegex = /(?:<choice>|\\[choice\\])([\s\S]*?)(?:<\/choice>|\\[\/choice\\])/i
const match = content.match(choiceRegex)
if (!match) {
// 尝试匹配纯文本格式的选项列表
// 匹配格式: A: xxx\nB: xxx\nC: xxx 或 A. xxx\nB. xxx
const textChoiceRegex = /^([A-E])[.、:]\s*(.+?)(?=\n[A-E][.、:]|\n*$)/gm
const choices: Choice[] = []
let textMatch
while ((textMatch = textChoiceRegex.exec(content)) !== null) {
choices.push({
label: textMatch[1],
text: textMatch[2].trim()
})
}
// 如果找到了至少2个选项认为是有效的选择列表
if (choices.length >= 2) {
// 移除选项列表,保留其他内容
const cleanContent = content.replace(textChoiceRegex, '').trim()
return { choices, cleanContent }
}
// 如果没有纯文本格式尝试从HTML中提取选择项
const htmlChoiceRegex = /<p>\s*(\d+|[A-Z])\s*[.、:]\s*([^<]+)/gi
const htmlChoices: Choice[] = []
let htmlMatch
while ((htmlMatch = htmlChoiceRegex.exec(content)) !== null) {
htmlChoices.push({
label: htmlMatch[1],
text: htmlMatch[2].trim()
})
}
if (htmlChoices.length > 0) {
return { choices: htmlChoices, cleanContent: content }
}
return { choices: [], cleanContent: content }
}
const choiceBlock = match[1]
const choices: Choice[] = []
// 匹配 A. text, B. text 等格式
const optionRegex = /^([A-Z])[.、:]\s*(.+)$/gm
let optionMatch
while ((optionMatch = optionRegex.exec(choiceBlock)) !== null) {
choices.push({
label: optionMatch[1],
text: optionMatch[2].trim()
})
}
// 移除选择块,返回清理后的内容
const cleanContent = content.replace(choiceRegex, '').trim()
return { choices, cleanContent }
}
// 清理脚本输出内容
function cleanScriptOutput(content: string): string {
// 移除 <UpdateVariable>...</UpdateVariable> 块
let cleaned = content.replace(/<UpdateVariable>[\s\S]*?<\/UpdateVariable>/gi, '')
// 移除 <Analysis>...</Analysis> 块
cleaned = cleaned.replace(/<Analysis>[\s\S]*?<\/Analysis>/gi, '')
// 移除 _.set() 调用
cleaned = cleaned.replace(/^\s*_.set\([^)]+\);\s*$/gm, '')
return cleaned.trim()
}
// 解析状态面板数据
function parseStatusPanel(content: string): { status: any; cleanContent: string } {
const statusRegex = /<status_current_variable>([\s\S]*?)<\/status_current_variable>/i
const match = content.match(statusRegex)
if (!match) {
return { status: null, cleanContent: content }
}
try {
const statusData = JSON.parse(match[1].trim())
const cleanContent = content.replace(statusRegex, '').trim()
return { status: statusData, cleanContent }
} catch (e) {
console.error('解析状态面板失败:', e)
return { status: null, cleanContent: content }
}
}
export default function MessageContent({ content, role, onChoiceSelect }: MessageContentProps) {
const [showRaw, setShowRaw] = useState(false)
const [hasHtml, setHasHtml] = useState(false)
const [hasScript, setHasScript] = useState(false)
const [allowScript, setAllowScript] = useState(false) // 默认禁用脚本
const [choices, setChoices] = useState<Choice[]>([])
const [displayContent, setDisplayContent] = useState(content)
const [statusPanel, setStatusPanel] = useState<any>(null)
const iframeRef = useRef<HTMLIFrameElement>(null)
useEffect(() => {
console.log('[MessageContent] 原始内容:', content)
// 提取 markdown 代码块中的 HTML并从原始内容中移除代码块标记
// 支持 ```html 和不带标识符的 ``` 包裹(后者在内容含 HTML 标签时识别为 HTML 块)
let processedContent = content
let remainingContent = content
let isHtmlCodeBlock = false
const htmlTagRegex = /<[a-zA-Z][^>]*>/
// 先尝试匹配 ```html 代码块
const explicitHtmlRegex = /```html\s*([\s\S]*?)```/gi
const htmlCodeBlocks: string[] = []
let htmlMatch
while ((htmlMatch = explicitHtmlRegex.exec(content)) !== null) {
htmlCodeBlocks.push(htmlMatch[1].trim())
}
if (htmlCodeBlocks.length > 0) {
// 有明确的 ```html 标识
processedContent = htmlCodeBlocks.join('\n')
remainingContent = content.replace(/```html\s*[\s\S]*?```/gi, '').trim()
isHtmlCodeBlock = true
console.log('[MessageContent] 提取到 ```html 代码块:', processedContent)
console.log('[MessageContent] 剩余内容:', remainingContent)
} else {
// 尝试匹配无语言标识的 ``` 代码块,内容含 HTML 标签时也视为 HTML 块
const genericCodeRegex = /```\s*\n?([\s\S]*?)```/g
const genericBlocks: string[] = []
let genericMatch
while ((genericMatch = genericCodeRegex.exec(content)) !== null) {
const blockContent = genericMatch[1].trim()
if (htmlTagRegex.test(blockContent)) {
genericBlocks.push(blockContent)
}
}
if (genericBlocks.length > 0) {
processedContent = genericBlocks.join('\n')
remainingContent = content.replace(/```\s*\n?[\s\S]*?```/g, '').trim()
isHtmlCodeBlock = true
console.log('[MessageContent] 提取到通用 HTML 代码块:', processedContent)
console.log('[MessageContent] 剩余内容:', remainingContent)
}
}
// 解析状态面板
const { status, cleanContent: contentAfterStatus } = parseStatusPanel(processedContent)
console.log('[MessageContent] 状态面板:', status)
setStatusPanel(status)
// 解析选择项(从剩余内容中解析,而不是从 HTML 中解析)
let parsedChoices: Choice[] = []
let cleanContent = contentAfterStatus
if (isHtmlCodeBlock && remainingContent) {
// 如果有 HTML 代码块,从剩余内容中解析选项
const choiceResult = parseChoices(remainingContent)
parsedChoices = choiceResult.choices
console.log('[MessageContent] 从剩余内容解析选择项:', parsedChoices)
} else if (!isHtmlCodeBlock) {
// 如果没有 HTML 代码块,正常解析
const choiceResult = parseChoices(contentAfterStatus)
parsedChoices = choiceResult.choices
cleanContent = choiceResult.cleanContent
console.log('[MessageContent] 选择项:', parsedChoices)
}
setChoices(parsedChoices)
// 清理脚本输出(只在非 HTML 代码块时清理)
// 如果是HTML代码块直接使用提取的HTML内容processedContent而不是contentAfterStatus
const finalContent = isHtmlCodeBlock ? processedContent : cleanScriptOutput(cleanContent)
console.log('[MessageContent] 清理后内容:', finalContent)
// 先检测内容类型(需要在转换代码块之前检测,以便使用原始 finalContent
const htmlRegex = /<[^>]+>/g
const scriptRegex = /<script[\s\S]*?<\/script>/gi
const hasHtmlContent = htmlRegex.test(finalContent)
const hasScriptContent = scriptRegex.test(finalContent)
console.log('[MessageContent] hasHtml:', hasHtmlContent, 'hasScript:', hasScriptContent)
// 对于含 HTML 的内容,将剩余的 markdown 代码块(``` ... ```)转换为 <pre><code>
// 避免反引号作为纯文本出现在 dangerouslySetInnerHTML 的渲染结果中
let renderedContent = finalContent
if (hasHtmlContent) {
renderedContent = finalContent.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, lang, code) => {
const escaped = code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
const langLabel = lang ? `<span style="font-size:11px;color:rgba(255,255,255,0.4);display:block;margin-bottom:4px;">${lang}</span>` : ''
return `<pre style="background:rgba(0,0,0,0.35);padding:10px 12px;border-radius:8px;overflow-x:auto;font-size:13px;line-height:1.5;margin:8px 0;">${langLabel}<code>${escaped}</code></pre>`
})
}
setDisplayContent(renderedContent)
setHasHtml(hasHtmlContent)
setHasScript(hasScriptContent)
// 如果有 HTML 内容或脚本,自动启用
if (hasHtmlContent || hasScriptContent) {
setAllowScript(true)
console.log('[MessageContent] 自动启用脚本')
}
}, [content])
const renderInIframe = () => {
if (!iframeRef.current) return
const iframe = iframeRef.current
const doc = iframe.contentDocument || iframe.contentWindow?.document
if (doc) {
doc.open()
doc.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
html, body {
margin: 0;
padding: 0;
width: 100% !important;
height: auto;
overflow-x: hidden;
}
body {
padding: 16px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: transparent;
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
line-height: 1.6;
}
* {
box-sizing: border-box;
max-width: 100% !important;
}
img {
height: auto;
width: auto !important;
}
/* 强制覆盖任何固定宽度 */
div, p, span, section, article {
max-width: 100% !important;
}
[style*="width"] {
width: auto !important;
max-width: 100% !important;
}
</style>
</head>
<body>
${displayContent}
</body>
</html>
`)
doc.close()
// 自动调整iframe高度
setTimeout(() => {
if (doc.body) {
const height = doc.body.scrollHeight
iframe.style.height = `${Math.max(height + 32, 100)}px`
}
}, 150)
}
}
useEffect(() => {
if (allowScript && hasScript && iframeRef.current) {
// 延迟渲染确保 iframe 已挂载
setTimeout(() => {
renderInIframe()
}, 50)
}
}, [allowScript, hasScript, displayContent])
// 如果是用户消息,直接显示纯文本
if (role === 'user') {
return <p className="text-sm leading-relaxed whitespace-pre-wrap break-words overflow-wrap-anywhere">{content}</p>
}
// AI消息 - 支持多种渲染模式
return (
<div className="space-y-2">
{/* 控制按钮 */}
{(hasHtml || hasScript) && (
<div className="flex items-center gap-2 mb-2">
<button
onClick={() => setShowRaw(!showRaw)}
className="flex items-center gap-1 px-2 py-1 text-xs glass-hover rounded cursor-pointer"
title={showRaw ? '显示渲染' : '显示源码'}
>
{showRaw ? <Eye className="w-3 h-3" /> : <Code className="w-3 h-3" />}
{showRaw ? '渲染' : '源码'}
</button>
{hasScript && (
<button
onClick={() => {
setAllowScript(!allowScript)
}}
className={`flex items-center gap-1 px-2 py-1 text-xs rounded cursor-pointer ${
allowScript ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
}`}
title={allowScript ? '禁用脚本' : '启用脚本'}
>
<Play className="w-3 h-3" />
{allowScript ? '禁用脚本' : '启用脚本'}
</button>
)}
</div>
)}
{/* 内容渲染 */}
{showRaw ? (
<pre className="text-xs bg-black/30 p-3 rounded-lg overflow-x-auto">
<code>{content}</code>
</pre>
) : hasScript && allowScript ? (
// 有脚本时使用 iframe 渲染
<div className="w-full border border-white/10 rounded-lg bg-black/20 overflow-hidden">
<iframe
ref={iframeRef}
className="w-full"
sandbox="allow-scripts allow-same-origin"
style={{ minHeight: '100px', border: 'none' }}
/>
</div>
) : hasHtml ? (
// 有 HTML 但无脚本或脚本未启用时,直接渲染 HTML
<div className="w-full border border-white/10 rounded-lg bg-black/20 overflow-hidden">
<div
className="prose prose-invert max-w-none text-sm leading-relaxed break-words overflow-wrap-anywhere p-4"
dangerouslySetInnerHTML={{ __html: displayContent }}
/>
</div>
) : (
<div className="text-sm leading-relaxed whitespace-pre-wrap break-words overflow-wrap-anywhere">
<DialogueText text={displayContent} />
</div>
)}
{/* Sudachi 选择按钮 */}
{choices.length > 0 && onChoiceSelect && (
<div className="mt-4 p-4 glass rounded-lg border border-primary/20">
<div className="text-sm font-medium text-primary mb-3"></div>
<div className="space-y-2">
{choices.map((choice) => (
<button
key={choice.label}
onClick={() => onChoiceSelect(choice.text)}
className="w-full text-left px-4 py-3 glass-hover rounded-lg border border-white/10 hover:border-primary/50 transition-all group"
>
<span className="text-primary font-bold mr-2">{choice.label}.</span>
<span className="text-white/80 group-hover:text-white">{choice.text}</span>
</button>
))}
</div>
</div>
)}
{/* 状态面板 */}
{statusPanel && (
<div className="mt-4 p-4 glass rounded-lg border border-secondary/20">
<div className="text-sm font-medium text-secondary mb-3"></div>
<div className="space-y-3">
{Object.entries(statusPanel).map(([category, data]: [string, any]) => (
<div key={category} className="space-y-1">
<div className="text-xs font-semibold text-secondary/80">{category}</div>
<div className="pl-3 space-y-1">
{typeof data === 'object' && data !== null ? (
Object.entries(data).map(([key, value]: [string, any]) => (
<div key={key} className="flex justify-between text-xs">
<span className="text-white/60">{key}:</span>
<span className="text-white/90 font-medium">
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
</span>
</div>
))
) : (
<div className="text-xs text-white/90">{String(data)}</div>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}