@@ -68,21 +68,40 @@ function parseChoices(content: string): { choices: Choice[]; cleanContent: strin
|
||||
const match = content.match(choiceRegex)
|
||||
|
||||
if (!match) {
|
||||
// 如果没有标准格式,尝试从HTML中提取选择项
|
||||
// 匹配类似 "1.xxx" 或 "A.xxx" 的列表项
|
||||
const htmlChoiceRegex = /<p>\s*(\d+|[A-Z])\s*[.、::]\s*([^<]+)/gi
|
||||
// 尝试匹配纯文本格式的选项列表
|
||||
// 匹配格式: 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) {
|
||||
choices.push({
|
||||
htmlChoices.push({
|
||||
label: htmlMatch[1],
|
||||
text: htmlMatch[2].trim()
|
||||
})
|
||||
}
|
||||
|
||||
if (choices.length > 0) {
|
||||
return { choices, cleanContent: content }
|
||||
if (htmlChoices.length > 0) {
|
||||
return { choices: htmlChoices, cleanContent: content }
|
||||
}
|
||||
|
||||
return { choices: [], cleanContent: content }
|
||||
@@ -145,7 +164,7 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
|
||||
const [showRaw, setShowRaw] = useState(false)
|
||||
const [hasHtml, setHasHtml] = useState(false)
|
||||
const [hasScript, setHasScript] = useState(false)
|
||||
const [allowScript, setAllowScript] = useState(true) // 默认启用脚本
|
||||
const [allowScript, setAllowScript] = useState(false) // 默认禁用脚本
|
||||
const [choices, setChoices] = useState<Choice[]>([])
|
||||
const [displayContent, setDisplayContent] = useState(content)
|
||||
const [statusPanel, setStatusPanel] = useState<any>(null)
|
||||
@@ -154,32 +173,106 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
|
||||
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(content)
|
||||
const { status, cleanContent: contentAfterStatus } = parseStatusPanel(processedContent)
|
||||
console.log('[MessageContent] 状态面板:', status)
|
||||
setStatusPanel(status)
|
||||
|
||||
// 解析选择项
|
||||
const { choices: parsedChoices, cleanContent } = parseChoices(contentAfterStatus)
|
||||
// 解析选择项(从剩余内容中解析,而不是从 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)
|
||||
|
||||
// 清理脚本输出
|
||||
const finalContent = cleanScriptOutput(cleanContent)
|
||||
// 清理脚本输出(只在非 HTML 代码块时清理)
|
||||
// 如果是HTML代码块,直接使用提取的HTML内容(processedContent),而不是contentAfterStatus
|
||||
const finalContent = isHtmlCodeBlock ? processedContent : cleanScriptOutput(cleanContent)
|
||||
console.log('[MessageContent] 清理后内容:', finalContent)
|
||||
setDisplayContent(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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
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 = () => {
|
||||
@@ -197,15 +290,36 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
height: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
body {
|
||||
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;
|
||||
color: #fff;
|
||||
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>
|
||||
@@ -220,17 +334,20 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
|
||||
setTimeout(() => {
|
||||
if (doc.body) {
|
||||
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(() => {
|
||||
if (allowScript && hasScript) {
|
||||
if (allowScript && hasScript && iframeRef.current) {
|
||||
// 延迟渲染确保 iframe 已挂载
|
||||
setTimeout(() => {
|
||||
renderInIframe()
|
||||
}, 50)
|
||||
}
|
||||
}, [allowScript, displayContent])
|
||||
}, [allowScript, hasScript, displayContent])
|
||||
|
||||
// 如果是用户消息,直接显示纯文本
|
||||
if (role === 'user') {
|
||||
@@ -254,14 +371,16 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
|
||||
|
||||
{hasScript && (
|
||||
<button
|
||||
onClick={() => setAllowScript(!allowScript)}
|
||||
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-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" />
|
||||
{allowScript ? '脚本已启用' : '启用脚本'}
|
||||
{allowScript ? '禁用脚本' : '启用脚本'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -273,17 +392,23 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
|
||||
<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 border border-white/10 rounded-lg bg-black/20"
|
||||
className="w-full"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
style={{ minHeight: '100px' }}
|
||||
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"
|
||||
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} />
|
||||
|
||||
Reference in New Issue
Block a user