🎨 优化前端渲染

Signed-off-by: Echo <1711788888@qq.com>
This commit is contained in:
2026-03-02 00:51:11 +08:00
parent fd660c8804
commit 23396caeeb

View File

@@ -68,21 +68,40 @@ function parseChoices(content: string): { choices: Choice[]; cleanContent: strin
const match = content.match(choiceRegex) const match = content.match(choiceRegex)
if (!match) { if (!match) {
// 如果没有标准格式尝试从HTML中提取选择项 // 尝试匹配纯文本格式的选项列表
// 匹配类似 "1.xxx""A.xxx" 的列表项 // 匹配格式: A: xxx\nB: xxx\nC: xxx 或 A. xxx\nB. xxx
const htmlChoiceRegex = /<p>\s*(\d+|[A-Z])\s*[.、:]\s*([^<]+)/gi const textChoiceRegex = /^([A-E])[.、:]\s*(.+?)(?=\n[A-E][.、:]|\n*$)/gm
const choices: Choice[] = [] 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 let htmlMatch
while ((htmlMatch = htmlChoiceRegex.exec(content)) !== null) { while ((htmlMatch = htmlChoiceRegex.exec(content)) !== null) {
choices.push({ htmlChoices.push({
label: htmlMatch[1], label: htmlMatch[1],
text: htmlMatch[2].trim() text: htmlMatch[2].trim()
}) })
} }
if (choices.length > 0) { if (htmlChoices.length > 0) {
return { choices, cleanContent: content } return { choices: htmlChoices, cleanContent: content }
} }
return { choices: [], cleanContent: content } return { choices: [], cleanContent: content }
@@ -145,7 +164,7 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
const [showRaw, setShowRaw] = useState(false) const [showRaw, setShowRaw] = useState(false)
const [hasHtml, setHasHtml] = useState(false) const [hasHtml, setHasHtml] = useState(false)
const [hasScript, setHasScript] = useState(false) const [hasScript, setHasScript] = useState(false)
const [allowScript, setAllowScript] = useState(true) // 默认用脚本 const [allowScript, setAllowScript] = useState(false) // 默认用脚本
const [choices, setChoices] = useState<Choice[]>([]) const [choices, setChoices] = useState<Choice[]>([])
const [displayContent, setDisplayContent] = useState(content) const [displayContent, setDisplayContent] = useState(content)
const [statusPanel, setStatusPanel] = useState<any>(null) const [statusPanel, setStatusPanel] = useState<any>(null)
@@ -154,32 +173,106 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
useEffect(() => { useEffect(() => {
console.log('[MessageContent] 原始内容:', content) 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(content) const { status, cleanContent: contentAfterStatus } = parseStatusPanel(processedContent)
console.log('[MessageContent] 状态面板:', status) console.log('[MessageContent] 状态面板:', status)
setStatusPanel(status) setStatusPanel(status)
// 解析选择项 // 解析选择项(从剩余内容中解析,而不是从 HTML 中解析)
const { choices: parsedChoices, cleanContent } = parseChoices(contentAfterStatus) let parsedChoices: Choice[] = []
console.log('[MessageContent] 选择项:', parsedChoices) 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) setChoices(parsedChoices)
// 清理脚本输出 // 清理脚本输出(只在非 HTML 代码块时清理)
const finalContent = cleanScriptOutput(cleanContent) // 如果是HTML代码块直接使用提取的HTML内容processedContent而不是contentAfterStatus
const finalContent = isHtmlCodeBlock ? processedContent : cleanScriptOutput(cleanContent)
console.log('[MessageContent] 清理后内容:', finalContent) console.log('[MessageContent] 清理后内容:', finalContent)
setDisplayContent(finalContent)
// 检测内容类型 // 检测内容类型(需要在转换代码块之前检测,以便使用原始 finalContent
const htmlRegex = /<[^>]+>/g const htmlRegex = /<[^>]+>/g
const scriptRegex = /<script[\s\S]*?<\/script>/gi const scriptRegex = /<script[\s\S]*?<\/script>/gi
const hasHtmlContent = htmlRegex.test(finalContent) const hasHtmlContent = htmlRegex.test(finalContent)
const hasScriptContent = scriptRegex.test(finalContent) const hasScriptContent = scriptRegex.test(finalContent)
console.log('[MessageContent] hasHtml:', hasHtmlContent, 'hasScript:', hasScriptContent) 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) setHasHtml(hasHtmlContent)
setHasScript(hasScriptContent) setHasScript(hasScriptContent)
// 如果有 HTML 内容或脚本,自动启用
if (hasHtmlContent || hasScriptContent) {
setAllowScript(true)
console.log('[MessageContent] 自动启用脚本')
}
}, [content]) }, [content])
const renderInIframe = () => { const renderInIframe = () => {
@@ -197,15 +290,36 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<style> <style>
body { html, body {
margin: 0; margin: 0;
padding: 0;
width: 100% !important;
height: auto;
overflow-x: hidden;
}
body {
padding: 16px; padding: 16px;
font-family: system-ui, -apple-system, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: transparent; background: transparent;
color: #fff; color: rgba(255, 255, 255, 0.9);
font-size: 14px;
line-height: 1.6;
} }
* { * {
box-sizing: border-box; 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> </style>
</head> </head>
@@ -220,17 +334,20 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
setTimeout(() => { setTimeout(() => {
if (doc.body) { if (doc.body) {
const height = doc.body.scrollHeight const height = doc.body.scrollHeight
iframe.style.height = `${Math.max(height, 100)}px` iframe.style.height = `${Math.max(height + 32, 100)}px`
} }
}, 100) }, 150)
} }
} }
useEffect(() => { useEffect(() => {
if (allowScript && hasScript) { if (allowScript && hasScript && iframeRef.current) {
renderInIframe() // 延迟渲染确保 iframe 已挂载
setTimeout(() => {
renderInIframe()
}, 50)
} }
}, [allowScript, displayContent]) }, [allowScript, hasScript, displayContent])
// 如果是用户消息,直接显示纯文本 // 如果是用户消息,直接显示纯文本
if (role === 'user') { if (role === 'user') {
@@ -254,14 +371,16 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
{hasScript && ( {hasScript && (
<button <button
onClick={() => setAllowScript(!allowScript)} onClick={() => {
setAllowScript(!allowScript)
}}
className={`flex items-center gap-1 px-2 py-1 text-xs rounded cursor-pointer ${ className={`flex items-center gap-1 px-2 py-1 text-xs rounded cursor-pointer ${
allowScript ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400' allowScript ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
}`} }`}
title={allowScript ? '禁用脚本' : '允许脚本'} title={allowScript ? '禁用脚本' : '启用脚本'}
> >
<Play className="w-3 h-3" /> <Play className="w-3 h-3" />
{allowScript ? '脚本已启用' : '启用脚本'} {allowScript ? '禁用脚本' : '启用脚本'}
</button> </button>
)} )}
</div> </div>
@@ -273,17 +392,23 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
<code>{content}</code> <code>{content}</code>
</pre> </pre>
) : hasScript && allowScript ? ( ) : hasScript && allowScript ? (
<iframe // 有脚本时使用 iframe 渲染
ref={iframeRef} <div className="w-full border border-white/10 rounded-lg bg-black/20 overflow-hidden">
className="w-full border border-white/10 rounded-lg bg-black/20" <iframe
sandbox="allow-scripts allow-same-origin" ref={iframeRef}
style={{ minHeight: '100px' }} className="w-full"
/> sandbox="allow-scripts allow-same-origin"
style={{ minHeight: '100px', border: 'none' }}
/>
</div>
) : hasHtml ? ( ) : hasHtml ? (
<div // 有 HTML 但无脚本或脚本未启用时,直接渲染 HTML
className="prose prose-invert max-w-none text-sm leading-relaxed break-words overflow-wrap-anywhere" <div className="w-full border border-white/10 rounded-lg bg-black/20 overflow-hidden">
dangerouslySetInnerHTML={{ __html: displayContent }} <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"> <div className="text-sm leading-relaxed whitespace-pre-wrap break-words overflow-wrap-anywhere">
<DialogueText text={displayContent} /> <DialogueText text={displayContent} />