🎉 初始化项目

Signed-off-by: Echo <1711788888@qq.com>
This commit is contained in:
2026-02-27 21:52:00 +08:00
commit f4e166c5ee
482 changed files with 55079 additions and 0 deletions

View 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>
)
}