341
web-app/src/components/MessageContent.tsx
Normal file
341
web-app/src/components/MessageContent.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
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) {
|
||||
// 如果没有标准格式,尝试从HTML中提取选择项
|
||||
// 匹配类似 "1.xxx" 或 "A.xxx" 的列表项
|
||||
const htmlChoiceRegex = /<p>\s*(\d+|[A-Z])\s*[.、::]\s*([^<]+)/gi
|
||||
const choices: Choice[] = []
|
||||
let htmlMatch
|
||||
|
||||
while ((htmlMatch = htmlChoiceRegex.exec(content)) !== null) {
|
||||
choices.push({
|
||||
label: htmlMatch[1],
|
||||
text: htmlMatch[2].trim()
|
||||
})
|
||||
}
|
||||
|
||||
if (choices.length > 0) {
|
||||
return { choices, 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(true) // 默认启用脚本
|
||||
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)
|
||||
|
||||
// 解析状态面板
|
||||
const { status, cleanContent: contentAfterStatus } = parseStatusPanel(content)
|
||||
console.log('[MessageContent] 状态面板:', status)
|
||||
setStatusPanel(status)
|
||||
|
||||
// 解析选择项
|
||||
const { choices: parsedChoices, cleanContent } = parseChoices(contentAfterStatus)
|
||||
console.log('[MessageContent] 选择项:', parsedChoices)
|
||||
setChoices(parsedChoices)
|
||||
|
||||
// 清理脚本输出
|
||||
const finalContent = cleanScriptOutput(cleanContent)
|
||||
console.log('[MessageContent] 清理后内容:', finalContent)
|
||||
setDisplayContent(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)
|
||||
|
||||
setHasHtml(hasHtmlContent)
|
||||
setHasScript(hasScriptContent)
|
||||
}, [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>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${displayContent}
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
doc.close()
|
||||
|
||||
// 自动调整iframe高度
|
||||
setTimeout(() => {
|
||||
if (doc.body) {
|
||||
const height = doc.body.scrollHeight
|
||||
iframe.style.height = `${Math.max(height, 100)}px`
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (allowScript && hasScript) {
|
||||
renderInIframe()
|
||||
}
|
||||
}, [allowScript, 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-yellow-500/20 text-yellow-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
|
||||
ref={iframeRef}
|
||||
className="w-full border border-white/10 rounded-lg bg-black/20"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
style={{ minHeight: '100px' }}
|
||||
/>
|
||||
) : hasHtml ? (
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-sm leading-relaxed break-words overflow-wrap-anywhere"
|
||||
dangerouslySetInnerHTML={{ __html: displayContent }}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user