467 lines
17 KiB
TypeScript
467 lines
17 KiB
TypeScript
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, '&')
|
||
.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 = () => {
|
||
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>
|
||
)
|
||
}
|