🎨 1.优化前端渲染功能(html和对话消息格式)

2.优化流式传输,新增流式渲染功能
3.优化正则处理逻辑
4.新增context budget管理系统
5.优化对话消息失败处理逻辑
6.新增前端卡功能(待完整测试)
This commit is contained in:
2026-03-13 15:58:33 +08:00
parent c267b6c76e
commit 4cecfd6589
22 changed files with 2492 additions and 2164 deletions

View File

@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"axios": "^1.13.5",
"js-yaml": "^4.1.1",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -20,6 +21,7 @@
"zustand": "^5.0.11"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
@@ -1239,6 +1241,13 @@
"@types/unist": "*"
}
},
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -1341,6 +1350,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -2458,6 +2473,18 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"axios": "^1.13.5",
"js-yaml": "^4.1.1",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -21,6 +22,7 @@
"zustand": "^5.0.11"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",

View File

@@ -1,6 +1,54 @@
import apiClient from './client'
// 类型定义
// ============= 前端卡类型 =============
/**
* 前端卡:存储在角色卡 extensions.frontend_card 中的 HTML/JS 面板。
* 在聊天界面中固定显示,内容完全由卡作者自定义。
*/
export interface FrontendCard {
html: string
enabled?: boolean
/** 显示位置:顶部(默认)或底部 */
position?: 'top' | 'bottom'
}
/**
* 从角色卡 extensions 中提取前端卡配置。
* 支持多种常见格式:
* - extensions.frontend_card.html本平台标准格式
* - extensions.frontend_card字符串直接就是 HTML
* - extensions.chara_card_ui字符串ST 社区常见格式)
*/
export function extractFrontendCard(extensions: Record<string, any> | null | undefined): FrontendCard | null {
if (!extensions) return null
// 标准格式extensions.frontend_card 是对象
const fc = extensions['frontend_card']
if (fc) {
if (typeof fc === 'string' && fc.trim()) {
return { html: fc, enabled: true, position: 'top' }
}
if (typeof fc === 'object' && typeof fc.html === 'string' && fc.html.trim()) {
return {
html: fc.html,
enabled: fc.enabled !== false,
position: fc.position === 'bottom' ? 'bottom' : 'top',
}
}
}
// 兼容格式extensions.chara_card_uiST 社区卡常用)
const charaCardUi = extensions['chara_card_ui']
if (typeof charaCardUi === 'string' && charaCardUi.trim()) {
return { html: charaCardUi, enabled: true, position: 'top' }
}
return null
}
// ============= 角色卡类型定义 =============
export interface Character {
id: number
name: string

View File

@@ -70,6 +70,7 @@ export interface GetRegexScriptListRequest {
pageSize?: number
keyword?: string
scope?: number
ownerCharId?: number
}
export interface RegexScriptListResponse {

View File

@@ -13,12 +13,17 @@ import {
Waves,
Zap
} from 'lucide-react'
import {useEffect, useRef, useState} from 'react'
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {useNavigate} from 'react-router-dom'
import {type Conversation, conversationApi, type Message} from '../api/conversation'
import {type Character} from '../api/character'
import {type Character, extractFrontendCard} from '../api/character'
import {type AIConfig, aiConfigApi} from '../api/aiConfig'
import {type Preset, presetApi} from '../api/preset'
import {type RegexScript, regexAPI} from '../api/regex'
import MessageContent from './MessageContent'
import StatusBarIframe from './StatusBarIframe'
import {useAppStore} from '../store'
import {streamSSE} from '../lib/sse'
interface ChatAreaProps {
conversation: Conversation
@@ -26,7 +31,18 @@ interface ChatAreaProps {
onConversationUpdate: (conversation: Conversation) => void
}
/** 解析 conversation.settings兼容 string 与 object 两种形式) */
function parseSettings(raw: unknown): Record<string, unknown> {
if (!raw) return {}
if (typeof raw === 'string') {
try { return JSON.parse(raw) } catch { return {} }
}
if (typeof raw === 'object') return raw as Record<string, unknown>
return {}
}
export default function ChatArea({ conversation, character, onConversationUpdate }: ChatAreaProps) {
const navigate = useNavigate()
const [messages, setMessages] = useState<Message[]>([])
const [inputValue, setInputValue] = useState('')
const [sending, setSending] = useState(false)
@@ -40,12 +56,29 @@ export default function ChatArea({ conversation, character, onConversationUpdate
const [presets, setPresets] = useState<Preset[]>([])
const [selectedPresetId, setSelectedPresetId] = useState<number>()
const [showPresetSelector, setShowPresetSelector] = useState(false)
const [regexScripts, setRegexScripts] = useState<RegexScript[]>([])
/** 当前正在流式输出的消息 ID流结束后清除为 null */
const [streamingMsgId, setStreamingMsgId] = useState<number | null>(null)
/** 发送/重新生成失败时的错误提示(显示在输入框上方,自动清除) */
const [sendError, setSendError] = useState<string | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const modelSelectorRef = useRef<HTMLDivElement>(null)
const presetSelectorRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
// 用 ref 跟踪 sending 状态,避免事件监听器的 stale closure 问题
const sendingRef = useRef(false)
/**
* 稳定 key 映射tempId → stableKey字符串
* 流式期间 key 保持不变,流结束后服务端 ID 替换 tempId 时 key 也不变,
* 防止 React 因 key 变化而卸载/重新挂载消息节点,消除闪屏。
*/
const stableKeyMap = useRef<Map<number, string>>(new Map())
const { user, variables } = useAppStore()
// ---- click-outside 关闭下拉菜单 ----
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
@@ -63,70 +96,164 @@ export default function ChatArea({ conversation, character, onConversationUpdate
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showModelSelector, showPresetSelector, showMenu])
// ---- 初始化加载 ----
useEffect(() => {
loadMessages()
loadAIConfigs()
loadCurrentConfig()
loadPresets()
loadCurrentPreset()
loadRegexScripts()
const settings = parseSettings(conversation.settings)
if (settings.aiConfigId) setSelectedConfigId(settings.aiConfigId as number)
// 优先用 conversation.presetId 字段,再降级到 settings.presetId
const presetId = conversation.presetId ?? (settings.presetId as number | undefined)
setSelectedPresetId(presetId)
}, [conversation.id])
// ---- 消息滚动 ----
useEffect(() => {
scrollToBottom()
}, [messages])
// 监听状态栏按钮点击事件
// ---- 状态栏操作监听(用 ref 跟踪 sending消除 stale closure ----
// handleSendMessage 通过 useCallback 保持稳定引用
const handleSendMessage = useCallback(async (message: string) => {
if (!message.trim() || sendingRef.current) return
const userMessage = message.trim()
setSending(true)
sendingRef.current = true
setSendError(null)
const tempUserMsg: Message = {
id: Date.now(),
conversationId: conversation.id,
role: 'user',
content: userMessage,
tokenCount: 0,
createdAt: new Date().toISOString(),
}
setMessages(prev => [...prev, tempUserMsg])
const tempAIMsg: Message = {
id: Date.now() + 1,
conversationId: conversation.id,
role: 'assistant',
content: '',
tokenCount: 0,
createdAt: new Date().toISOString(),
}
try {
if (streamEnabled) {
setMessages(prev => [...prev, tempAIMsg])
setStreamingMsgId(tempAIMsg.id)
let fullContent = ''
for await (const ev of streamSSE(
`/app/conversation/${conversation.id}/message?stream=true`,
'POST',
{ content: userMessage }
)) {
if (ev.event === 'message') {
fullContent += ev.data
setMessages(prev =>
prev.map(m => m.id === tempAIMsg.id ? { ...m, content: fullContent } : m)
)
} else if (ev.event === 'done') {
break
} else if (ev.event === 'error') {
throw new Error(ev.data)
}
}
// 先原地清除 streaming 标记(内容已完整),不触发任何额外渲染
setStreamingMsgId(null)
// 后台静默拉取服务端最终数据(同步真实 ID 和 tokenCount不显示 loading
loadMessages(true).then(() => {
conversationApi.getConversationById(conversation.id).then(convResp => {
onConversationUpdate(convResp.data)
}).catch(() => {})
}).catch(() => {})
} else {
await conversationApi.sendMessage(conversation.id, { content: userMessage })
await loadMessages()
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '发送消息失败,请重试'
console.error('发送消息失败:', err)
setStreamingMsgId(null)
// 撤回用户消息到输入框,移除临时消息气泡
setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id && m.id !== tempAIMsg.id))
setInputValue(userMessage)
setSendError(msg)
} finally {
setSending(false)
sendingRef.current = false
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversation.id, streamEnabled])
useEffect(() => {
const handleStatusBarAction = (event: CustomEvent) => {
const action = event.detail
if (action && typeof action === 'string' && !sending) {
if (action && typeof action === 'string' && !sendingRef.current) {
console.log('[ChatArea] 收到状态栏操作,自动发送消息:', action)
setInputValue(action)
// 延迟发送,确保 inputValue 已更新
setTimeout(() => {
handleSendMessage(action)
}, 100)
handleSendMessage(action)
}
}
window.addEventListener('sendMessageFromStatusBar', handleStatusBarAction as EventListener)
return () => window.removeEventListener('sendMessageFromStatusBar', handleStatusBarAction as EventListener)
}, [sending])
}, [handleSendMessage])
const loadMessages = async () => {
/**
* 处理来自 iframe 状态栏的操作:
* - fillInput / playerAction → 填充输入框(不发送)
* - triggerAction → 解析 /send <text> 并自动发送
* 命令格式示例:"/send 我选择休息|/trigger"
*/
const handleIframeAction = useCallback((
type: 'fillInput' | 'playerAction' | 'triggerAction',
payload: string
) => {
if (type === 'fillInput' || type === 'playerAction') {
setInputValue(payload)
textareaRef.current?.focus()
} else if (type === 'triggerAction') {
// 解析 ST slash 命令:取第一段 /send 之后的文本
// 例:" /send 我选择攻击|/trigger " → "我选择攻击"
const sendMatch = payload.match(/\/send\s+([^|]+)/i)
const text = sendMatch ? sendMatch[1].trim() : payload.trim()
if (text) {
handleSendMessage(text)
}
}
}, [handleSendMessage])
// ---- 数据加载 ----
/**
* @param silent - true 时不设置 loading 状态(后台静默刷新,不触发整屏 loading
*/
const loadMessages = async (silent = false) => {
try {
setLoading(true)
if (!silent) setLoading(true)
const response = await conversationApi.getMessageList(conversation.id, { page: 1, pageSize: 100 })
setMessages(response.data.list || [])
} catch (err) {
console.error('加载消息失败:', err)
} finally {
setLoading(false)
if (!silent) setLoading(false)
}
}
const loadAIConfigs = async () => {
try {
const response = await aiConfigApi.getAIConfigList()
setAiConfigs(response.data.list.filter(config => config.isActive))
setAiConfigs(response.data.list.filter((c: AIConfig) => c.isActive))
} catch (err) {
console.error('加载 AI 配置失败:', err)
}
}
const loadCurrentConfig = () => {
if (conversation.settings) {
try {
const settings = typeof conversation.settings === 'string'
? JSON.parse(conversation.settings)
: conversation.settings
if (settings.aiConfigId) setSelectedConfigId(settings.aiConfigId)
} catch (e) {
console.error('解析设置失败:', e)
}
}
}
const loadPresets = async () => {
try {
const response = await presetApi.getPresetList({ page: 1, pageSize: 100 })
@@ -136,37 +263,36 @@ export default function ChatArea({ conversation, character, onConversationUpdate
}
}
const loadCurrentPreset = () => {
if (conversation.presetId) {
setSelectedPresetId(conversation.presetId)
return
const loadRegexScripts = async () => {
try {
// 并行加载全局脚本scope=0+ 当前角色专属脚本scope=1, ownerCharId=character.id
const globalScope = 0
const charScope = 1
const [globalResp, charResp] = await Promise.all([
regexAPI.getList({ page: 1, pageSize: 200, scope: globalScope }),
regexAPI.getList({ page: 1, pageSize: 200, scope: charScope, ownerCharId: character.id }),
])
const globalScripts: RegexScript[] = (globalResp.data.list || []).filter((s: RegexScript) => !s.disabled)
const charScripts: RegexScript[] = (charResp.data.list || []).filter((s: RegexScript) => !s.disabled)
// 合并并按 order 去重(以 id 为主键)
const merged = [...globalScripts, ...charScripts]
const seen = new Set<number>()
setRegexScripts(merged.filter(s => {
if (seen.has(s.id)) return false
seen.add(s.id)
return true
}))
} catch (err) {
console.error('加载正则脚本失败:', err)
}
if (conversation.settings) {
try {
const settings = typeof conversation.settings === 'string'
? JSON.parse(conversation.settings)
: conversation.settings
if (settings.presetId) {
setSelectedPresetId(settings.presetId)
return
}
} catch (e) {
console.error('解析设置失败:', e)
}
}
setSelectedPresetId(undefined)
}
// ---- 设置更新 ----
const handlePresetChange = async (presetId: number | null) => {
try {
const settings = conversation.settings
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : { ...conversation.settings })
: {}
if (presetId === null) {
delete settings.presetId
} else {
settings.presetId = presetId
}
const settings = parseSettings(conversation.settings)
if (presetId === null) delete settings.presetId
else settings.presetId = presetId
await conversationApi.updateConversationSettings(conversation.id, { settings })
setSelectedPresetId(presetId ?? undefined)
setShowPresetSelector(false)
@@ -180,14 +306,9 @@ export default function ChatArea({ conversation, character, onConversationUpdate
const handleModelChange = async (configId: number | null) => {
try {
const settings = conversation.settings
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : { ...conversation.settings })
: {}
if (configId === null) {
delete settings.aiConfigId
} else {
settings.aiConfigId = configId
}
const settings = parseSettings(conversation.settings)
if (configId === null) delete settings.aiConfigId
else settings.aiConfigId = configId
await conversationApi.updateConversationSettings(conversation.id, { settings })
setSelectedConfigId(configId ?? undefined)
setShowModelSelector(false)
@@ -199,101 +320,11 @@ export default function ChatArea({ conversation, character, onConversationUpdate
}
}
// ---- 消息操作 ----
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
const handleSendMessage = async (message: string) => {
if (!message.trim() || sending) return
const userMessage = message.trim()
setSending(true)
const tempUserMessage: Message = {
id: Date.now(),
conversationId: conversation.id,
role: 'user',
content: userMessage,
tokenCount: 0,
createdAt: new Date().toISOString(),
}
setMessages(prev => [...prev, tempUserMessage])
const tempAIMessage: Message = {
id: Date.now() + 1,
conversationId: conversation.id,
role: 'assistant',
content: '',
tokenCount: 0,
createdAt: new Date().toISOString(),
}
try {
if (streamEnabled) {
setMessages(prev => [...prev, tempAIMessage])
const response = await fetch(
`${import.meta.env.VITE_API_BASE_URL || '/api'}/app/conversation/${conversation.id}/message?stream=true`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({ content: userMessage }),
}
)
if (!response.ok) throw new Error('流式传输失败')
const reader = response.body?.getReader()
const decoder = new TextDecoder()
if (reader) {
let fullContent = ''
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
let currentEvent = ''
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim()
} else if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (currentEvent === 'message') {
fullContent += data
setMessages(prev =>
prev.map(m => m.id === tempAIMessage.id ? { ...m, content: fullContent } : m)
)
} else if (currentEvent === 'done') {
await loadMessages()
break
} else if (currentEvent === 'error') {
throw new Error(data)
}
currentEvent = ''
}
}
}
}
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
} else {
await conversationApi.sendMessage(conversation.id, { content: userMessage })
await loadMessages()
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
}
} catch (err: any) {
console.error('发送消息失败:', err)
alert(err.response?.data?.msg || '发送消息失败,请重试')
setMessages(prev => prev.filter(m => m.id !== tempUserMessage.id && m.id !== tempAIMessage.id))
} finally {
setSending(false)
}
}
const handleSend = async () => {
if (!inputValue.trim() || sending) return
const userMessage = inputValue.trim()
@@ -320,12 +351,17 @@ export default function ChatArea({ conversation, character, onConversationUpdate
if (!hasAssistantMsg) return
setSending(true)
sendingRef.current = true
setSendError(null)
// 记录被移除的最后一条 assistant 消息,失败时可恢复
const lastAssistantIndex = [...messages].map(m => m.role).lastIndexOf('assistant')
const removedMsg = lastAssistantIndex !== -1 ? messages[lastAssistantIndex] : null
if (lastAssistantIndex !== -1) {
setMessages(prev => prev.filter((_, i) => i !== lastAssistantIndex))
}
const tempAIMessage: Message = {
const tempAIMsg: Message = {
id: Date.now(),
conversationId: conversation.id,
role: 'assistant',
@@ -336,64 +372,50 @@ export default function ChatArea({ conversation, character, onConversationUpdate
try {
if (streamEnabled) {
setMessages(prev => [...prev, tempAIMessage])
const response = await fetch(
`${import.meta.env.VITE_API_BASE_URL || '/api'}/app/conversation/${conversation.id}/regenerate?stream=true`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
}
)
if (!response.ok) throw new Error('重新生成失败')
const reader = response.body?.getReader()
const decoder = new TextDecoder()
if (reader) {
let fullContent = ''
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
let currentEvent = ''
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim()
} else if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (currentEvent === 'message') {
fullContent += data
setMessages(prev =>
prev.map(m => m.id === tempAIMessage.id ? { ...m, content: fullContent } : m)
)
} else if (currentEvent === 'done') {
await loadMessages()
break
} else if (currentEvent === 'error') {
throw new Error(data)
}
currentEvent = ''
}
}
setMessages(prev => [...prev, tempAIMsg])
setStreamingMsgId(tempAIMsg.id)
let fullContent = ''
for await (const ev of streamSSE(
`/app/conversation/${conversation.id}/regenerate?stream=true`,
'POST'
)) {
if (ev.event === 'message') {
fullContent += ev.data
setMessages(prev =>
prev.map(m => m.id === tempAIMsg.id ? { ...m, content: fullContent } : m)
)
} else if (ev.event === 'done') {
break
} else if (ev.event === 'error') {
throw new Error(ev.data)
}
}
// 先原地清除 streaming 标记,再后台静默同步
setStreamingMsgId(null)
loadMessages(true).then(() => {
conversationApi.getConversationById(conversation.id).then(convResp => {
onConversationUpdate(convResp.data)
}).catch(() => {})
}).catch(() => {})
} else {
await conversationApi.regenerateMessage(conversation.id)
await loadMessages()
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
}
const convResp = await conversationApi.getConversationById(conversation.id)
onConversationUpdate(convResp.data)
} catch (err: any) {
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '重新生成失败,请重试'
console.error('重新生成失败:', err)
alert(err.message || '重新生成失败,请重试')
await loadMessages()
setStreamingMsgId(null)
// 移除临时 AI 消息,恢复原来被删除的 assistant 消息
setMessages(prev => {
const withoutTemp = prev.filter(m => m.id !== tempAIMsg.id)
return removedMsg ? [...withoutTemp, removedMsg] : withoutTemp
})
setSendError(msg)
} finally {
setSending(false)
sendingRef.current = false
}
}
@@ -401,7 +423,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
if (!confirm('确定要删除这个对话吗?')) return
try {
await conversationApi.deleteConversation(conversation.id)
window.location.href = '/chat'
navigate('/chat')
} catch (err) {
console.error('删除对话失败:', err)
alert('删除失败')
@@ -437,20 +459,52 @@ export default function ChatArea({ conversation, character, onConversationUpdate
const selectedPreset = presets.find(p => p.id === selectedPresetId)
const lastAssistantMsgId = [...messages].reverse().find(m => m.role === 'assistant')?.id
/** 从角色卡 extensions 中提取前端卡配置memo 缓存,避免不必要重计算) */
const frontendCard = useMemo(() => extractFrontendCard(character.extensions), [character.id, character.extensions])
/**
* 前端卡用的最新消息内容:始终取消息列表最后一条的原始内容。
* 流式期间每个 token 都会变,但前端卡 iframe 本身有防抖isStreaming=true 时冻结刷新)。
*/
const latestMessageContent = messages.length > 0 ? messages[messages.length - 1].content : ''
const latestMessageIndex = Math.max(0, messages.length - 1)
/**
* 稳定的消息内容数组引用:只有消息数量或内容实际变化时才重建,
* 避免每个 SSE token 都产生新数组引用,防止 StatusBarIframe 无限重建。
*/
const allMessageContents = useMemo(
() => messages.map(m => m.content),
// eslint-disable-next-line react-hooks/exhaustive-deps
[messages.length, messages.map(m => m.content).join('\x00')]
)
/**
* 为每条消息分配稳定 key
* - 如果 stableKeyMap 中已有该消息 ID 的 key直接复用保持 DOM 节点稳定)
* - 否则分配新 keyconversationId + 消息在列表中的位置索引,流式期间不会变)
* 这样即使服务端刷新后 msg.id 从 tempId 变为真实 IDReact key 也不变,不会触发重新挂载。
*/
const getStableKey = useCallback((msg: Message, index: number): string => {
if (stableKeyMap.current.has(msg.id)) {
return stableKeyMap.current.get(msg.id)!
}
const key = `${conversation.id}-${index}`
stableKeyMap.current.set(msg.id, key)
return key
}, [conversation.id])
return (
<div className="flex-1 flex flex-col min-w-0">
{/* 顶部工具栏 */}
<div className="px-4 py-3 glass border-b border-white/10">
<div className="flex items-center justify-between gap-3">
{/* 左侧:标题 */}
<div className="min-w-0 flex-1">
<h2 className="text-base font-semibold truncate">{conversation.title}</h2>
<p className="text-xs text-white/50 truncate"> {character.name} </p>
</div>
{/* 右侧:工具按钮组 */}
<div className="flex items-center gap-1 flex-shrink-0">
{/* 模型选择器 */}
<div className="relative" ref={modelSelectorRef}>
<button
@@ -557,7 +611,6 @@ export default function ChatArea({ conversation, character, onConversationUpdate
)}
</div>
{/* 分隔线 */}
<div className="w-px h-5 bg-white/10 mx-1" />
{/* 流式传输切换 */}
@@ -613,6 +666,20 @@ export default function ChatArea({ conversation, character, onConversationUpdate
</div>
</div>
{/* 前端卡顶部position='top' 或未设置) */}
{frontendCard && frontendCard.enabled !== false && frontendCard.position !== 'bottom' && (
<div className="px-4 pt-3 flex-shrink-0">
<StatusBarIframe
rawMessage={latestMessageContent}
allMessages={allMessageContents}
messageIndex={latestMessageIndex}
htmlContent={frontendCard.html}
minHeight={150}
onAction={handleIframeAction}
/>
</div>
)}
{/* 消息列表 */}
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-5">
{loading ? (
@@ -631,11 +698,11 @@ export default function ChatArea({ conversation, character, onConversationUpdate
<p className="text-white/50 text-sm"> {character.name} </p>
</div>
) : (
messages.map((msg) => {
messages.map((msg, msgIndex) => {
const isLastAssistant = msg.id === lastAssistantMsgId
return (
<div
key={msg.id}
key={getStableKey(msg, msgIndex)}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} group`}
>
{/* 助手头像 */}
@@ -652,12 +719,10 @@ export default function ChatArea({ conversation, character, onConversationUpdate
)}
<div className={`min-w-0 flex flex-col ${msg.role === 'user' ? 'max-w-[70%] items-end' : 'w-[70%] items-start'}`}>
{/* 助手名称 */}
{msg.role === 'assistant' && (
<span className="text-xs text-white/40 mb-1 ml-1">{character.name}</span>
)}
{/* 消息气泡 */}
<div
className={`relative px-4 py-3 rounded-2xl ${
msg.role === 'user'
@@ -669,10 +734,17 @@ export default function ChatArea({ conversation, character, onConversationUpdate
<MessageContent
content={msg.content}
role={msg.role as 'user' | 'assistant'}
messageIndex={msgIndex}
characterName={character.name}
userName={variables.user || user?.username || ''}
regexScripts={regexScripts}
allMessages={allMessageContents}
onChoiceSelect={(choice) => {
setInputValue(choice)
textareaRef.current?.focus()
}}
onAction={handleIframeAction}
isStreaming={msg.id === streamingMsgId}
/>
</div>
@@ -689,7 +761,6 @@ export default function ChatArea({ conversation, character, onConversationUpdate
: <Copy className="w-3.5 h-3.5 text-white/40 hover:text-white/70" />
}
</button>
{/* 最后一条 AI 消息显示重新生成按钮 */}
{msg.role === 'assistant' && isLastAssistant && (
<button
onClick={handleRegenerateResponse}
@@ -703,14 +774,16 @@ export default function ChatArea({ conversation, character, onConversationUpdate
</div>
</div>
{/* 用户头像占位 */}
{msg.role === 'user' && <div className="flex-shrink-0 ml-2.5 mt-1 w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-xs"></div>}
{/* 用户头像 */}
{msg.role === 'user' && (
<div className="flex-shrink-0 ml-2.5 mt-1 w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-xs"></div>
)}
</div>
)
})
)}
{/* 发送中动画(流式时不需要,已有临时消息 */}
{/* 发送中动画(流式模式下显示 */}
{sending && !streamEnabled && (
<div className="flex justify-start">
<div className="flex-shrink-0 mr-2.5 mt-1 w-8 h-8 rounded-full bg-primary/30 flex items-center justify-center text-xs">
@@ -728,8 +801,35 @@ export default function ChatArea({ conversation, character, onConversationUpdate
<div ref={messagesEndRef} />
</div>
{/* 前端卡底部position='bottom' 时显示) */}
{frontendCard && frontendCard.enabled !== false && frontendCard.position === 'bottom' && (
<div className="px-4 pb-2 flex-shrink-0">
<StatusBarIframe
rawMessage={latestMessageContent}
allMessages={allMessageContents}
messageIndex={latestMessageIndex}
htmlContent={frontendCard.html}
minHeight={150}
onAction={handleIframeAction}
/>
</div>
)}
{/* 输入区域 */}
<div className="px-4 pb-4 pt-2 glass border-t border-white/10">
{/* 发送失败错误提示 */}
{sendError && (
<div className="flex items-center justify-between gap-2 mb-2 px-3 py-2 rounded-lg bg-red-500/10 border border-red-500/20 text-xs text-red-400">
<span className="flex-1 truncate">{sendError}</span>
<button
onClick={() => setSendError(null)}
className="flex-shrink-0 hover:text-red-300 transition-colors cursor-pointer"
title="关闭"
>
</button>
</div>
)}
<div className="flex items-end gap-2">
<button className="p-2.5 glass-hover rounded-xl cursor-not-allowed opacity-30" title="附件(开发中)" disabled>
<Paperclip className="w-5 h-5" />
@@ -741,6 +841,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
if (sendError) setSendError(null)
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 120) + 'px'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,500 @@
/**
* StatusBarIframe 组件
*
* 负责渲染消息中嵌入的 HTML 内容(包含 <script> 的完整 HTML、html-code-block、或 YAML 状态栏)。
* 内部使用 sandbox iframe + shim 脚本,与父页面通过 postMessage 桥接。
*
* 安全说明:
* - sandbox 属性仅保留 allow-scripts去除 allow-same-origin防止 iframe 访问父页面 storage/cookie
* - postMessage 使用 '*' 作为 targetOrigin沙箱 iframe origin 为 'null',指定具体 origin 会被浏览器丢弃)
* - 父页面通过 event.source === iframe.contentWindow 校验消息来源
*
* 对应文档第 5 节HTML 状态栏渲染器)与重构文档 8.4 节。
*/
import { useEffect, useRef, useState } from 'react'
import { load as parseYaml } from 'js-yaml'
// ============= 类型定义 =============
interface StatusBarIframeProps {
/** 当前消息原始内容(用于 shim 注入 __RAW_MESSAGE__ */
rawMessage: string
/** 所有消息原始内容(用于 shim 注入 __CHAT_MESSAGES__ */
allMessages?: string[]
/** 当前消息在对话中的索引 */
messageIndex?: number
/** 要渲染的 HTML 内容(来自 html-code-block 或直接 HTML
* 若同时提供 statusYaml则以 HTML 为主YAML 注入作兜底 */
htmlContent?: string
/** 状态栏 YAML 数据(来自 <Status_block>,不存在则为空字符串) */
statusYaml?: string
/** iframe 最小高度px默认 200 */
minHeight?: number
/**
* 父页面 originpostMessage 安全检查)。
* 默认为 window.location.origin运行时获取而非硬编码 '*'。
*/
parentOrigin?: string
/**
* iframe 内操作回调。
* - type='fillInput' → 仅填充输入框sendToChatBox
* - type='playerAction' → 仅填充输入框onPlayerAction / options-list 点击)
* - type='triggerAction'→ 填充并发送triggerSlash命令格式如 /send text|/trigger
*/
onAction?: (type: 'fillInput' | 'playerAction' | 'triggerAction', payload: string) => void
}
// ============= shim 脚本模板 =============
/**
* 生成注入到 iframe 的 shim 脚本
* 提供 ST 兼容 API + 与宿主页桥接
*
* 注意:由于 sandbox 去掉了 allow-same-originiframe 内脚本无法访问父页面 DOM / storage
* 与父页通信必须通过 postMessage。
*/
function buildShimScript(
rawMessage: string,
allMessages: string[],
currentId: number
): string {
// Escape </script (the HTML-spec pattern that closes a script element) to prevent
// the inline <script> tag from being prematurely terminated by message content.
// Per spec, </script is closed by </script followed by whitespace, '/' or '>'.
// Replacing '</script' with '<\/script' is safe: JS treats \/ as / inside strings.
const escapeForScript = (s: string) => s.replace(/<\/script/gi, '<\\/script')
const rawJson = escapeForScript(JSON.stringify(rawMessage))
const allJson = escapeForScript(JSON.stringify(allMessages))
return `
(function() {
var __RAW_MESSAGE__ = ${rawJson};
var __CHAT_MESSAGES__ = ${allJson};
var __CURRENT_ID__ = ${currentId};
// ST 兼容 API
window.getCurrentMessageId = function() { return __CURRENT_ID__; };
window.getChatMessages = function(id) {
var idx = (id !== undefined && id !== null) ? id : __CURRENT_ID__;
return [{ message: __CHAT_MESSAGES__[idx] !== undefined ? __CHAT_MESSAGES__[idx] : __RAW_MESSAGE__ }];
};
// 沙箱内禁用模态对话框sandbox 不允许 allow-modals直接覆盖避免报错
// alert → 控制台输出confirm → 始终返回 trueprompt → 返回空字符串
window.alert = function(msg) { console.log('[sandbox alert]', msg); };
window.confirm = function(msg) { console.log('[sandbox confirm]', msg); return true; };
window.prompt = function(msg, def) { console.log('[sandbox prompt]', msg); return (def !== undefined ? String(def) : ''); };
// localStorage shimsandbox 无 allow-same-origin直接访问 localStorage 会抛 DOMException。
// 用内存 Map 模拟,让卡片的主题/模式偏好设置代码正常运行(数据不跨页面持久化)。
(function() {
var _store = {};
var _ls = {
getItem: function(k) { return Object.prototype.hasOwnProperty.call(_store, k) ? _store[k] : null; },
setItem: function(k, v) { _store[k] = String(v); },
removeItem: function(k) { delete _store[k]; },
clear: function() { _store = {}; },
key: function(i) { return Object.keys(_store)[i] || null; },
get length() { return Object.keys(_store).length; }
};
try { window.localStorage; } catch(e) { Object.defineProperty(window, 'localStorage', { value: _ls, writable: false }); return; }
// 若访问 localStorage 不抛错则直接替换(通常在 sandbox 无 allow-same-origin 时会抛)
try { window.localStorage.getItem('__probe__'); } catch(e) {
Object.defineProperty(window, 'localStorage', { value: _ls, writable: false });
}
})();
// 向父页面发送消息
// 沙箱 iframe 的 origin 是 'null',必须使用 '*' 作为 targetOrigin
// 否则浏览器会静默丢弃消息。安全性由父页面用 event.source 校验保证。
function sendToParent(type, data) {
window.parent.postMessage({ type: type, data: data }, '*');
}
// 上报内容尺寸(父页面据此调整 iframe 大小)
function reportSize() {
var body = document.body;
var html = document.documentElement;
if (!body || !html) return;
// body 是 inline-block其 offsetWidth/scrollWidth 反映真实内容宽度
// html 的 scrollHeight 反映完整文档高度
var width = Math.max(body.offsetWidth, body.scrollWidth);
var height = Math.max(html.scrollHeight, html.offsetHeight, body.scrollHeight, body.offsetHeight);
sendToParent('resize', { width: width, height: height });
}
// ST 兼容triggerSlash → 发送到父页面并触发发送(例:/send text|/trigger
window.triggerSlash = function(command) {
sendToParent('triggerAction', { command: command });
};
// ST 兼容sendToChatBox → 只填充输入框,不发送
window.sendToChatBox = function(text) {
sendToParent('fillInput', { text: text });
};
// 用户操作触发(供状态栏按钮调用)
window.onPlayerAction = function(action) {
sendToParent('playerAction', { action: action });
};
// DOMContentLoaded 后统一执行:注入 YAML 兜底数据 + 绑定 options-list + 上报尺寸
document.addEventListener('DOMContentLoaded', function() {
// 注入 Status_block YAML
// - 若 yaml-data-source 元素不存在,创建并追加到 <head>
// - 若存在但内容为空(卡片模板占位),填入 YAML 内容
var statusMatch = __RAW_MESSAGE__.match(/<Status_block>([\\s\\S]*?)<\\/Status_block>/i);
if (statusMatch) {
var yamlContent = statusMatch[1].trim();
var existing = document.getElementById('yaml-data-source');
if (!existing) {
var s = document.createElement('script');
s.id = 'yaml-data-source';
s.type = 'text/yaml';
s.textContent = yamlContent;
document.head && document.head.appendChild(s);
} else if (!existing.textContent || !existing.textContent.trim()) {
// 元素存在但为空(卡片模板占位),填入数据
existing.textContent = yamlContent;
}
}
// 注入 maintext若元素不存在则创建若存在但为空则填入
var maintextMatch = __RAW_MESSAGE__.match(/<maintext>([\\s\\S]*?)<\\/maintext>/i);
if (maintextMatch) {
var maintextContent = maintextMatch[1].trim();
var existingMaintext = document.getElementById('maintext');
if (!existingMaintext) {
var d = document.createElement('div');
d.id = 'maintext';
d.style.display = 'none';
d.textContent = maintextContent;
document.body && document.body.appendChild(d);
} else if (!existingMaintext.textContent || existingMaintext.textContent.trim() === '加载中...') {
existingMaintext.textContent = maintextContent;
}
}
// options-list 点击兜底
var optionsList = document.getElementById('options-list');
if (optionsList) {
optionsList.addEventListener('click', function(event) {
var target = event.target;
if (target && target.textContent) {
sendToParent('playerAction', { action: target.textContent.trim() });
}
}, true);
}
// 首次上报尺寸
reportSize();
// 图片/字体等异步资源加载完成后再次上报
window.addEventListener('load', reportSize);
// ResizeObserver 监听内容变化(脚本动态修改 DOM 时也能及时上报)
if (window.ResizeObserver && document.body) {
var ro = new ResizeObserver(function() { reportSize(); });
ro.observe(document.body);
}
});
})();
`
}
// ============= 构建 iframe srcdoc =============
/**
* 构建完整的 HTML 文档字符串(用于 iframe.srcdoc
*/
function buildIframeDoc(content: string, shimScript: string, statusYaml: string): string {
const isFullDoc = /^\s*<!DOCTYPE/i.test(content) || /^\s*<html/i.test(content)
const baseStyle = `
<style>
html {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
margin: 0;
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;
/* display: inline-block 使 body 宽度由内容决定而非受 iframe 视口约束,
这样 scrollWidth 才能反映真实内容宽度 */
display: inline-block;
min-width: 100vw;
}
* { box-sizing: border-box; }
img { height: auto; }
</style>
`
// YAML 数据内联 script 标签(兜底)
const yamlDataTag = statusYaml
? `<script id="yaml-data-source" type="text/yaml">${statusYaml.replace(/<\/script>/gi, '<\\/script>')}<\/script>`
: ''
// shim 内联(避免 CDN 依赖)
const inlineScripts = `<script>${shimScript}<\/script>`
if (isFullDoc) {
// 全文档:在 </head> 前注入 baseStyle + yaml data + shim
return content.replace(
/(<\/head>)/i,
`${baseStyle}${yamlDataTag}${inlineScripts}$1`
)
}
const bodyContent = /^\s*<body/i.test(content)
? content
: `<body>${content}</body>`
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
${baseStyle}
${yamlDataTag}
${inlineScripts}
</head>
${bodyContent}
</html>`
}
// ============= 组件 =============
export default function StatusBarIframe({
rawMessage,
allMessages = [],
messageIndex = 0,
htmlContent = '',
statusYaml = '',
minHeight = 200,
parentOrigin,
onAction,
}: StatusBarIframeProps) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const [iframeSize, setIframeSize] = useState<{ width: number; height: number } | null>(null)
/**
* 冻结 allMessages组件挂载时取快照之后不再跟随父组件更新。
* 原因:每当对话新增消息时父组件的 allMessages 引用会重建,若历史消息的 iframe
* 跟随更新,会触发 srcdoc 重置 + setIframeSize(null),导致已渲染的 HTML 内容消失后重建(闪烁)。
* 历史消息的 HTML 渲染不依赖后续新增消息的内容,冻结快照即可。
*/
const frozenAllMessages = useRef<string[]>(allMessages)
useEffect(() => {
if (!iframeRef.current) return
// parentOrigin is kept as a prop for future use (e.g. if we add non-sandboxed iframes)
// For the current sandboxed iframe, postMessage uses '*' — see buildShimScript.
const shim = buildShimScript(rawMessage, frozenAllMessages.current, messageIndex)
const content = htmlContent || buildDefaultYamlRenderer(statusYaml)
const doc = buildIframeDoc(content, shim, statusYaml)
const iframe = iframeRef.current
iframe.srcdoc = doc
// 重置尺寸(避免切换内容时残留旧尺寸)
setIframeSize(null)
}, [rawMessage, messageIndex, htmlContent, statusYaml, minHeight, parentOrigin])
// 监听 iframe 内通过 postMessage 上报的消息
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// 安全检查:只接受来自本 iframe 的消息srcdoc 的 origin 是 'null',用 source 做身份校验)
if (event.source !== iframeRef.current?.contentWindow) return
const msg = event.data
if (!msg) return
if (msg.type === 'resize') {
const { width, height } = msg.data as { width: number; height: number }
if (typeof width === 'number' && typeof height === 'number') {
setIframeSize({
width: Math.max(width, 0),
height: Math.max(height, minHeight),
})
}
return
}
// 操作类消息:转发给父组件
if (onAction) {
if (msg.type === 'fillInput') {
const text = (msg.data as { text?: string })?.text ?? ''
onAction('fillInput', text)
} else if (msg.type === 'playerAction') {
const action = (msg.data as { action?: string })?.action ?? ''
onAction('playerAction', action)
} else if (msg.type === 'triggerAction') {
const command = (msg.data as { command?: string })?.command ?? ''
onAction('triggerAction', command)
}
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [minHeight, parentOrigin, onAction])
// 计算实际宽高:有内容尺寸时用内容尺寸,否则用 minHeight / 撑满父容器
const frameHeight = iframeSize ? iframeSize.height : minHeight
// 宽度:若内容比父容器宽则横向滚动,否则撑满父容器
const frameWidth = iframeSize ? iframeSize.width : undefined
return (
<div
className="border border-white/10 rounded-lg bg-black/20 overflow-auto"
style={{ maxWidth: '100%' }}
>
<iframe
ref={iframeRef}
// 去掉 allow-same-origin防止 iframe 内代码访问父页面 localStorage / cookie
sandbox="allow-scripts"
style={{
width: frameWidth ? `${frameWidth}px` : '100%',
height: `${frameHeight}px`,
border: 'none',
display: 'block',
minWidth: '100%',
}}
title="status-bar"
/>
</div>
)
}
// ============= 默认 YAML 渲染器模板 =============
/**
* 当没有提供自定义 HTML 时,使用内置 YAML 状态栏渲染器。
* 在父页面中使用 js-yaml 解析 YAML再将渲染好的 HTML 注入 iframe。
*/
function buildDefaultYamlRenderer(statusYaml: string): string {
const base = `
<body>
<style>
.status-block { background: rgba(0,0,0,0.3); border-radius: 12px; padding: 16px; border: 1px solid rgba(255,255,255,0.1); }
.status-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid rgba(255,255,255,0.1); }
.status-title { font-size: 16px; font-weight: 600; color: rgba(157,124,245,1); }
.status-info { font-size: 13px; color: rgba(255,255,255,0.7); margin: 4px 0; }
.character-card { background: rgba(0,0,0,0.2); border-radius: 8px; padding: 12px; margin: 8px 0; border: 1px solid rgba(255,255,255,0.05); }
.character-name { font-weight: 600; font-size: 15px; margin-bottom: 8px; color: rgba(157,124,245,1); }
.attribute { margin: 4px 0; font-size: 13px; line-height: 1.5; }
.attribute-key { color: rgba(157,124,245,0.8); font-weight: 500; }
.option-item { padding: 8px 12px; margin: 4px 0; background: rgba(157,124,245,0.1); border-radius: 6px; cursor: pointer; transition: all 0.2s; border-left: 2px solid rgba(157,124,245,0.3); }
.option-item:hover { background: rgba(157,124,245,0.2); border-left-color: rgba(157,124,245,0.7); }
.loading { padding: 20px; text-align: center; color: rgba(255,255,255,0.5); }
</style>
`
const emptyHtml = `${base}
<div class="loading">状态栏数据为空</div>
</body>`
if (!statusYaml || !statusYaml.trim()) return emptyHtml
let data: unknown
try {
data = parseYaml(statusYaml)
} catch (e: any) {
return `${base}
<div class="loading" style="color:rgba(255,100,100,0.8)">YAML 解析失败: ${String(
e?.message ?? e
)}</div>
</body>`
}
if (!data || typeof data !== 'object') {
return emptyHtml
}
const escHtml = (s: unknown) =>
String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
const obj = data as Record<string, unknown>
const rootKey = Object.keys(obj)[0]
if (!rootKey) return emptyHtml
const block = obj[rootKey] as any
let html = `${base}
<div class="status-block">
<div class="status-header"><div class="status-title">${escHtml(rootKey)}</div></div>
`
const appendBlock = (key: string, value: any) => {
if (typeof value === 'string' || typeof value === 'number') {
html += ` <div class="status-info"><span class="attribute-key">${escHtml(
key
)}:</span> ${escHtml(value)}</div>\n`
} else if (Array.isArray(value)) {
html += ` <div style="margin-top:8px"><div class="attribute-key">${escHtml(key)}</div>\n`
value.forEach((item) => {
if (item && typeof item === 'object') {
const rec = item as Record<string, any>
const name = rec['名字'] ?? rec['name'] ?? ''
html += ' <div class="character-card">\n'
if (name) {
html += ` <div class="character-name">${escHtml(name)}</div>\n`
}
Object.entries(rec).forEach(([k, v]) => {
if (k === '名字' || k === 'name') return
html += ` <div class="attribute"><span class="attribute-key">${escHtml(
k
)}:</span> ${escHtml(String(v))}</div>\n`
})
html += ' </div>\n'
} else {
html += ` <div class="attribute">${escHtml(String(item))}</div>\n`
}
})
html += ' </div>\n'
} else if (value && typeof value === 'object') {
const rec = value as Record<string, any>
const options = rec['选项']
if (Array.isArray(options)) {
const name = rec['名字'] ?? key
html +=
' <div style="margin-top:12px"><div style="font-weight:600;margin-bottom:8px;color:rgba(157,124,245,1)">'
html += `${escHtml(String(name))} 的行动选项</div>\n`
options.forEach((opt) => {
html += ` <div class="option-item" onclick="window.onPlayerAction && window.onPlayerAction(${escHtml(
JSON.stringify(opt)
)})">${escHtml(opt)}</div>\n`
})
html += ' </div>\n'
} else {
html += ' <div class="character-card">\n'
html += ` <div class="character-name">${escHtml(key)}</div>\n`
Object.entries(rec).forEach(([k, v]) => {
html += ` <div class="attribute"><span class="attribute-key">${escHtml(
k
)}:</span> ${escHtml(String(v))}</div>\n`
})
html += ' </div>\n'
}
}
}
if (block && typeof block === 'object') {
Object.entries(block as Record<string, any>).forEach(([k, v]) => appendBlock(k, v))
}
html += ' </div>\n</body>'
return html
}

View File

@@ -25,3 +25,50 @@
@apply transition-all duration-200 hover:bg-white/10 hover:border-white/20;
}
}
/* ============= 消息渲染样式 ============= */
/* 引号美化 */
.message-body .quote-open,
.message-body .quote-close {
color: rgba(139, 92, 246, 0.6);
}
.message-body .quote-content {
color: rgb(139, 92, 246);
font-weight: 500;
padding: 0 2px;
}
/* 动作文本美化 *动作* */
.message-body .action-text {
color: rgba(167, 139, 250, 0.85);
font-style: italic;
}
/* 代码块样式 */
.message-body .code-block {
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;
position: relative;
}
.message-body .code-lang-label {
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
display: block;
margin-bottom: 4px;
font-family: ui-monospace, 'Cascadia Code', monospace;
}
.message-body .code-block code {
color: rgba(255, 255, 255, 0.9);
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
}
/* html-code-block 默认隐藏(由 StatusBarIframe 处理) */
.message-body .html-code-block {
display: none;
}

View File

@@ -0,0 +1,172 @@
/**
* 正则脚本执行引擎Regex Engine
*
* 对应 ST 中 extensions/regex/engine.js 的前端执行逻辑。
* 从后端加载的 RegexScript 列表,在前端对消息文本执行正则替换。
*
* placement 枚举(与后端 model/app/regex_script.go 保持一致):
* 0 = USER_INPUT — 对用户输入执行
* 1 = AI_OUTPUT — 对 AI 输出执行
* 2 = WORLD_INFO — 世界书注入(前端暂不处理)
* 3 = DISPLAY — 仅展示层(前端暂不处理)
*
* 注意:本模块是纯函数,不持有任何状态,调用方负责传入脚本列表。
*/
import type { RegexScript } from '../api/regex'
export const REGEX_PLACEMENT = {
USER_INPUT: 0,
AI_OUTPUT: 1,
WORLD_INFO: 2,
DISPLAY: 3,
} as const
export type RegexPlacement = (typeof REGEX_PLACEMENT)[keyof typeof REGEX_PLACEMENT]
// ============= 核心函数 =============
/**
* 对文本执行所有符合条件的正则脚本
*
* @param text 原始文本
* @param placement 执行阶段USER_INPUT | AI_OUTPUT
* @param scripts 正则脚本列表(应已过滤出 enabled 的)
* @param depth 消息深度(用于 minDepth/maxDepth 过滤,可选)
* @returns 处理后的文本
*/
export function applyRegexScripts(
text: string,
placement: RegexPlacement,
scripts: RegexScript[],
depth?: number
): string {
if (!text || scripts.length === 0) return text
let result = text
// 按 order 字段排序(升序)
const sorted = [...scripts].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
for (const script of sorted) {
// 跳过禁用的脚本
if (script.disabled) continue
// 检查 placement
if (!matchesPlacement(script, placement)) continue
// 检查深度(仅在 depth 已知时)
if (typeof depth === 'number') {
if (
script.minDepth !== undefined &&
script.minDepth !== null &&
script.minDepth >= 0 &&
depth < script.minDepth
) {
continue
}
if (
script.maxDepth !== undefined &&
script.maxDepth !== null &&
script.maxDepth >= 0 &&
depth > script.maxDepth
) {
continue
}
}
result = runSingleScript(script, result)
}
return result
}
/**
* 执行单条正则脚本
*/
export function runSingleScript(script: RegexScript, text: string): string {
if (!script.findRegex || !text) return text
let findPattern = script.findRegex
// substituteRegex: 1=RAW 替换宏2=ESCAPED 转义后替换(简化处理:暂跳过宏替换)
// TODO: 若需要支持宏变量替换可在此扩展
const regex = buildRegex(findPattern)
if (!regex) return text
const replaceWith = script.replaceWith ?? ''
try {
let result = text.replace(regex, (match, ...args) => {
// 支持 $1, $2... 捕获组
let replacement = replaceWith
// ST 兼容:{{match}} → $0
.replace(/\{\{match\}\}/gi, match)
// 替换捕获组 $1 $2...
const groups = args.slice(0, args.length - 3) // 去掉 offset、input 和 namedGroupsES2018+
groups.forEach((group, i) => {
replacement = replacement.replace(
new RegExp(`\\$${i + 1}`, 'g'),
group ?? ''
)
})
// trimStrings 处理
if (script.trimStrings && script.trimStrings.length > 0) {
for (const trim of script.trimStrings) {
if (trim) replacement = replacement.split(trim).join('')
}
}
return replacement
})
return result
} catch (err) {
console.warn(`[regexEngine] 脚本 "${script.name}" 执行失败:`, err)
return text
}
}
// ============= 辅助函数 =============
/**
* 解析正则字符串(支持 /pattern/flags 格式和普通字符串)
*/
function buildRegex(pattern: string): RegExp | null {
try {
// 检测 /pattern/flags 格式
const slashMatch = pattern.match(/^\/(.+)\/([gimsuy]*)$/)
if (slashMatch) {
return new RegExp(slashMatch[1], slashMatch[2])
}
// 普通字符串,默认 global
return new RegExp(pattern, 'g')
} catch (err) {
console.warn(`[regexEngine] 无效正则: "${pattern}"`, err)
return null
}
}
/**
* 判断脚本是否应该在指定 placement 执行
* RegexScript.placement 是数字(后端 enum不是数组
*/
function matchesPlacement(script: RegexScript, placement: RegexPlacement): boolean {
// 后端存的是单个数字
return script.placement === placement
}
// ============= 便捷包装 =============
/** 对 AI 输出文本执行正则脚本(最常用) */
export function processAIOutput(text: string, scripts: RegexScript[], depth?: number): string {
return applyRegexScripts(text, REGEX_PLACEMENT.AI_OUTPUT, scripts, depth)
}
/** 对用户输入文本执行正则脚本 */
export function processUserInput(text: string, scripts: RegexScript[], depth?: number): string {
return applyRegexScripts(text, REGEX_PLACEMENT.USER_INPUT, scripts, depth)
}

73
web-app/src/lib/sse.ts Normal file
View File

@@ -0,0 +1,73 @@
/**
* SSE 流式传输工具
*
* 将重复的 SSE 读取逻辑抽取为一个通用异步生成器,
* 供 ChatArea 中的 handleSendMessage 和 handleRegenerateResponse 共用。
*/
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api'
/** 单条 SSE 事件 */
export interface SSEEvent {
event: string
data: string
}
/**
* 发起 SSE 请求并以异步迭代方式逐条产出事件。
* 调用方可使用 `for await (const ev of streamSSE(...))` 消费。
*
* @param path 相对 API 路径(不含 base如 `/app/conversation/1/message`
* @param method HTTP 方法,默认 POST
* @param body 请求体(可选)
*/
export async function* streamSSE(
path: string,
method: 'POST' | 'GET' = 'POST',
body?: Record<string, unknown>
): AsyncGenerator<SSEEvent> {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!response.ok) {
throw new Error(`SSE 请求失败: ${response.status} ${response.statusText}`)
}
const reader = response.body?.getReader()
if (!reader) throw new Error('无法获取响应流')
const decoder = new TextDecoder()
let buffer = ''
let currentEvent = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim()
} else if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
yield { event: currentEvent, data }
currentEvent = ''
}
}
}
} finally {
reader.cancel().catch(() => {})
}
}

View File

@@ -0,0 +1,371 @@
/**
* 文本渲染引擎Text Renderer
*
* 按照文档描述的 10 步管线,将 LLM 原始文本转换为可直接 dangerouslySetInnerHTML 渲染的 HTML 字符串。
* 这是一个纯函数模块,不依赖任何 React 状态,便于测试和复用。
*
* 渲染管线(与 ST text-decorator.js 对齐):
* 1. 正则脚本处理(由调用方在外部完成,传入 processedText
* 2. 变量替换 {{user}} / {{char}} / ...
* 3. 清理控制标签 <phase>, <maintext>, <!-- ... -->
* 4. 规范行间空白
* 5. 抽取 fenced code blocks用占位符替换暂存
* 6. HTML 转义(防 XSS
* 7. 引号美化
* 8. 动作文本美化 *动作*
* 9. \n → <br>
* 10. 恢复代码块html 块 → html-code-block / 其余 → pre>code
*/
// ============= 类型定义 =============
export interface RenderOptions {
/** 消息在对话中的索引(用于正则 depth 过滤等,暂留扩展) */
index?: number
/** 当前角色名称(用于变量替换 {{char}} */
characterName?: string
/** 当前用户名称(用于变量替换 {{user}} */
userName?: string
/** 额外的自定义变量key → value */
customVars?: Record<string, string>
}
/** 解析后的消息结构(在渲染前从原始内容中提取) */
export interface ParsedMessage {
/** 提取 <maintext>...</maintext> 后的正文(如不存在则为空字符串) */
maintext: string
/** 提取 <Status_block>...</Status_block> 后的 YAML 字符串(如不存在则为空字符串) */
statusYaml: string
/** 提取旧版 <status_current_variable>...</status_current_variable> 的 JSON 对象(兼容) */
statusJson: Record<string, unknown> | null
/** 解析出的选择项列表 */
choices: Choice[]
/** 去掉所有特殊块后、待渲染的主体文本 */
bodyText: string
/** 主体文本是否含有 <script> 标签 */
hasScript: boolean
/** 主体文本是否含有 HTML 标签或代码块 */
hasHtml: boolean
}
export interface Choice {
label: string
text: string
}
// ============= 解析函数(提取特殊块) =============
/** 从原始内容中一次性解析出所有特殊区域 */
export function parseRawMessage(raw: string): ParsedMessage {
let content = raw
// 1. 提取 <maintext>
let maintext = ''
content = content.replace(/<maintext>([\s\S]*?)<\/maintext>/i, (_, inner) => {
maintext = inner.trim()
return ''
})
// 2. 提取 <Status_block> (YAML)
let statusYaml = ''
content = content.replace(/<Status_block>\s*([\s\S]*?)\s*<\/Status_block>/i, (_, inner) => {
statusYaml = inner.trim()
return ''
})
// 3. 提取 <status_current_variable> (JSON兼容旧版)
let statusJson: Record<string, unknown> | null = null
content = content.replace(/<status_current_variable>([\s\S]*?)<\/status_current_variable>/i, (_, inner) => {
try {
statusJson = JSON.parse(inner.trim())
} catch {
// ignore malformed JSON
}
return ''
})
// 4. 提取 choices
const { choices, cleanContent } = extractChoices(content)
content = cleanContent
// 5. 检测 script / html在原始内容上检测而非剥离特殊块后的 bodyText
const hasScript = /<script[\s\S]*?<\/script>/i.test(raw)
const hasHtml = /<[a-zA-Z][^>]*>/.test(raw) || /```[\s\S]*?```/.test(raw)
return {
maintext,
statusYaml,
statusJson,
choices,
bodyText: content.trim(),
hasScript,
hasHtml,
}
}
/** 提取 <choice>...</choice> 或 [choice]...[/choice] 选择项 */
function extractChoices(content: string): { choices: Choice[]; cleanContent: string } {
const tagRegex = /(?:<choice>|\[choice\])([\s\S]*?)(?:<\/choice>|\[\/choice\])/i
const tagMatch = content.match(tagRegex)
if (tagMatch) {
const block = tagMatch[1]
const choices = parseChoiceBlock(block)
const cleanContent = content.replace(tagRegex, '').trim()
return { choices, cleanContent }
}
// 尝试纯文本格式 "A. xxx\nB. xxx"(至少 2 项才认为是选择列表)
const textChoiceRegex = /^([A-E])[.、:]\s*(.+?)(?=\n[A-E][.、:]|\n*$)/gm
const choices: Choice[] = []
let m: RegExpExecArray | null
while ((m = textChoiceRegex.exec(content)) !== null) {
choices.push({ label: m[1], text: m[2].trim() })
}
if (choices.length >= 2) {
const cleanContent = content.replace(textChoiceRegex, '').trim()
return { choices, cleanContent }
}
return { choices: [], cleanContent: content }
}
function parseChoiceBlock(block: string): Choice[] {
const choices: Choice[] = []
const optionRegex = /^([A-Z])[.、:]\s*(.+)$/gm
let m: RegExpExecArray | null
while ((m = optionRegex.exec(block)) !== null) {
choices.push({ label: m[1], text: m[2].trim() })
}
return choices
}
// ============= 文本渲染管线 =============
/** renderMessageHtml 的返回值 */
export interface RenderResult {
/** 可安全用于 dangerouslySetInnerHTML 的 HTML 字符串html-code-block 已被移除) */
html: string
/** 从 fenced code blocks 中提取的 HTML 片段列表(供 StatusBarIframe 渲染) */
htmlBlocks: string[]
}
/**
* 将原始消息正文bodyText渲染为 HTML 字符串。
* 调用前应先通过 parseRawMessage() 拿到 bodyText。
*
* @param rawText 待渲染的原始文本bodyText非完整原始消息
* @param role 消息角色
* @param options 渲染选项
* @returns RenderResult — html 字符串 + htmlBlocks 列表
*/
export function renderMessageHtml(
rawText: string,
role: 'user' | 'assistant',
options: RenderOptions = {}
): RenderResult {
// 用户消息:不做 HTML 渲染,只做基本转义 + 换行
if (role === 'user') {
return { html: escapeHtml(rawText).replace(/\n/g, '<br>'), htmlBlocks: [] }
}
let text = rawText
// Step 2: 变量替换
text = substituteVariables(text, options)
// Step 3: 清理控制性标签/区域
text = cleanControlTags(text)
// Step 4: 规范行间空白
text = normalizeWhitespace(text)
// Step 5: 抽取 fenced code blocks用占位符替换
const { text: textWithPlaceholders, blocks } = extractCodeBlocks(text)
text = textWithPlaceholders
// Step 6: HTML 转义主体文本(代码块已抽出,不会被误转义)
text = escapeHtml(text)
// Step 7: 引号美化
text = beautifyQuotes(text)
// Step 8: 动作文本美化 *动作*
text = beautifyActions(text)
// Step 9: 换行 → <br>
text = text.replace(/\n/g, '<br>')
// Step 10: 恢复代码块html 块替换为空占位,收集到 htmlBlocks
const { html, htmlBlocks } = restoreCodeBlocks(text, blocks)
return { html, htmlBlocks }
}
// ============= 各步骤实现 =============
/** Step 2: 变量替换 */
function substituteVariables(text: string, options: RenderOptions): string {
const vars: Record<string, string> = {
user: options.userName ?? '',
char: options.characterName ?? '',
...options.customVars,
}
// 替换用户自定义变量
for (const [key, value] of Object.entries(vars)) {
text = text.replace(new RegExp(`\\{\\{${escapeRegexStr(key)}\\}\\}`, 'gi'), value)
}
// 时间变量
const now = new Date()
text = text.replace(/\{\{time\}\}/gi, now.toLocaleTimeString('en-US', { hour12: false }))
text = text.replace(/\{\{time_12h\}\}/gi, now.toLocaleTimeString('en-US', { hour12: true }))
text = text.replace(/\{\{date\}\}/gi, now.toLocaleDateString('en-CA'))
text = text.replace(/\{\{datetime\}\}/gi, now.toLocaleString())
// 随机数
text = text.replace(/\{\{random:(\d+)-(\d+)\}\}/gi, (_, min, max) =>
String(Math.floor(Math.random() * (Number(max) - Number(min) + 1) + Number(min)))
)
text = text.replace(/\{\{random\}\}/gi, () => String(Math.floor(Math.random() * 100)))
// pick
text = text.replace(/\{\{pick:([^}]+)\}\}/gi, (_, options) => {
const list = options.split('|')
return list[Math.floor(Math.random() * list.length)]
})
// 特殊字符
text = text.replace(/\{\{newline\}\}/gi, '\n')
return text
}
/** Step 3: 清理控制性标签 */
function cleanControlTags(text: string): string {
// <phase ...> / </phase>
text = text.replace(/<phase[^>]*>/gi, '')
text = text.replace(/<\/phase>/gi, '')
// <!-- consider: ... --> 和普通 HTML 注释
text = text.replace(/<!--[\s\S]*?-->/g, '')
return text
}
/** Step 4: 规范行间空白 */
function normalizeWhitespace(text: string): string {
// 统一换行符
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
// 去除每行尾部空白
text = text.replace(/[ \t]+\n/g, '\n')
// 压缩连续 ≥3 个空行为 2 个
text = text.replace(/\n{3,}/g, '\n\n')
return text.trim()
}
// ---- 代码块处理 ----
interface CodeBlock {
lang: string
code: string
isHtml: boolean
}
const PLACEHOLDER_PREFIX = '\x00CODEBLOCK\x00'
/**
* Step 5: 提取 fenced code blocks返回带占位符的文本和代码块字典
* 支持:```html, ```text, ``` (自动检测是否是 HTML)
*/
function extractCodeBlocks(text: string): { text: string; blocks: Map<string, CodeBlock> } {
const blocks = new Map<string, CodeBlock>()
let idx = 0
const replaced = text.replace(/```([^\n`]*)\n?([\s\S]*?)```/g, (_, lang: string, code: string) => {
const trimmedLang = lang.trim().toLowerCase()
const trimmedCode = code.trim()
const key = `${PLACEHOLDER_PREFIX}${idx++}${PLACEHOLDER_PREFIX}`
// 判断是否是 HTML明确声明 html/text或内容含有 HTML 标签
const isHtml =
trimmedLang === 'html' ||
trimmedLang === 'text' ||
(trimmedLang === '' && /<[a-zA-Z][^>]*>/.test(trimmedCode))
blocks.set(key, { lang: trimmedLang, code: trimmedCode, isHtml })
return key
})
return { text: replaced, blocks }
}
/** Step 10: 恢复代码块html 块直接收集到 htmlBlocks 列表(不插入主 HTML */
function restoreCodeBlocks(
text: string,
blocks: Map<string, CodeBlock>
): { html: string; htmlBlocks: string[] } {
const htmlBlocks: string[] = []
for (const [key, block] of blocks) {
if (block.isHtml) {
// HTML 块:收集到 htmlBlocks主文本中用空字符串替换不污染主渲染区
htmlBlocks.push(block.code)
text = text.split(key).join('')
} else {
// 其他语言:普通代码块,插入主 HTML
const escapedCode = escapeHtml(block.code)
const langLabel = block.lang
? `<span class="code-lang-label">${escapeHtml(block.lang)}</span>`
: ''
const html = `<pre class="code-block">${langLabel}<code class="language-${escapeHtml(block.lang) || 'text'}">${escapedCode}</code></pre>`
text = text.split(key).join(html)
}
}
return { html: text, htmlBlocks }
}
// ---- 美化函数 ----
/** Step 7: 引号美化(英文/中文引号) */
function beautifyQuotes(text: string): string {
// 不在 HTML 标签内的引号对才做高亮
// 用正则匹配:中文书名号「」『』 / 英文 "" / 直双引号 ""
return text.replace(
/([""「『])((?:(?!["」』]).)*?)([""」』])/g,
(_, open, content, close) => {
return (
`<span class="quote-open">${open}</span>` +
`<span class="quote-content">${content}</span>` +
`<span class="quote-close">${close}</span>`
)
}
)
}
/** Step 8: 动作文本美化 *动作* → <span class="action-text">*动作*</span> */
function beautifyActions(text: string): string {
// 匹配 *内容*,但不跨越换行(已转成 <br> 之前,\n 还在)
return text.replace(/\*([^\n*]+)\*/g, (_, inner) => {
return `<span class="action-text">*${inner}*</span>`
})
}
// ============= 工具函数(公开导出) =============
/** HTML 转义(防 XSS */
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
/** 正则元字符转义 */
function escapeRegexStr(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

View File

@@ -1,8 +1,8 @@
import {useEffect, useState} from 'react'
import {useEffect, useRef, useState} from 'react'
import {useNavigate, useSearchParams} from 'react-router-dom'
import Navbar from '../components/Navbar'
import {Book, Code2, Download, Edit, FileJson, FileUp, Image as ImageIcon, Plus, Search, Trash2, X} from 'lucide-react'
import {type Character, characterApi} from '../api/character'
import {Book, Code2, Download, Edit, FileJson, FileUp, Image as ImageIcon, Layout, Plus, Search, Trash2, X} from 'lucide-react'
import {type Character, characterApi, extractFrontendCard} from '../api/character'
import {type RegexScript, regexScriptApi} from '../api/regex'
// import {useAppStore} from '../store'
@@ -37,6 +37,13 @@ export default function CharacterManagePage() {
const [showRegexScriptEditor, setShowRegexScriptEditor] = useState(false)
const [regexScripts, setRegexScripts] = useState<RegexScript[]>([])
const [_editingTab, _setEditingTab] = useState<'basic' | 'worldbook' | 'regex'>('basic')
// 前端卡编辑器
const [showFrontendCardEditor, setShowFrontendCardEditor] = useState(false)
const [frontendCardHtml, setFrontendCardHtml] = useState('')
const [frontendCardEnabled, setFrontendCardEnabled] = useState(true)
const [frontendCardPosition, setFrontendCardPosition] = useState<'top' | 'bottom'>('top')
const [showFrontendCardPreview, setShowFrontendCardPreview] = useState(false)
const frontendCardPreviewRef = useRef<HTMLIFrameElement>(null)
const [showAddRegexModal, setShowAddRegexModal] = useState(false)
const [newRegexForm, setNewRegexForm] = useState({
name: '',
@@ -61,10 +68,24 @@ export default function CharacterManagePage() {
setShowEditModal(true)
loadWorldBook(char)
loadRegexScripts(char.id)
loadFrontendCard(char)
}
}
}, [searchParams, characters])
const loadFrontendCard = (character: Character) => {
const fc = extractFrontendCard(character.extensions)
if (fc) {
setFrontendCardHtml(fc.html)
setFrontendCardEnabled(fc.enabled !== false)
setFrontendCardPosition(fc.position === 'bottom' ? 'bottom' : 'top')
} else {
setFrontendCardHtml('')
setFrontendCardEnabled(true)
setFrontendCardPosition('top')
}
}
const loadWorldBook = (character: Character) => {
if (!character.characterBook) {
setWorldBookEntries([])
@@ -187,6 +208,22 @@ export default function CharacterManagePage() {
entries: worldBookEntries
} : null
// 构建 extensions保留原有字段写入/清除前端卡
const baseExtensions: Record<string, any> = {
...(selectedCharacter.extensions || {}),
}
if (frontendCardHtml.trim()) {
baseExtensions['frontend_card'] = {
html: frontendCardHtml,
enabled: frontendCardEnabled,
position: frontendCardPosition,
}
} else {
// HTML 清空则删除前端卡
delete baseExtensions['frontend_card']
delete baseExtensions['chara_card_ui']
}
const updateData = {
name: formData.get('name') as string,
description: formData.get('description') as string,
@@ -198,6 +235,7 @@ export default function CharacterManagePage() {
tags: (formData.get('tags') as string).split(',').map(t => t.trim()).filter(Boolean),
isPublic: formData.get('isPublic') === 'on',
characterBook: characterBook,
extensions: baseExtensions,
}
try {
@@ -354,6 +392,7 @@ export default function CharacterManagePage() {
setShowEditModal(true)
loadWorldBook(char)
loadRegexScripts(char.id)
loadFrontendCard(char)
}}
className="flex-1 px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer flex items-center justify-center gap-2"
>
@@ -559,7 +598,7 @@ export default function CharacterManagePage() {
<p className="text-xs text-white/40 mt-1">广使</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-3 gap-3">
<button
type="button"
onClick={() => setShowWorldBookEditor(true)}
@@ -576,6 +615,18 @@ export default function CharacterManagePage() {
<Code2 className="w-4 h-4" />
({regexScripts.length})
</button>
<button
type="button"
onClick={() => setShowFrontendCardEditor(true)}
className={`px-4 py-3 rounded-xl text-sm cursor-pointer flex items-center justify-center gap-2 ${
frontendCardHtml.trim()
? 'bg-primary/20 border border-primary/40 text-primary'
: 'glass-hover'
}`}
>
<Layout className="w-4 h-4" />
{frontendCardHtml.trim() ? ' ✓' : ''}
</button>
</div>
</form>
@@ -1206,6 +1257,161 @@ export default function CharacterManagePage() {
</div>
</div>
)}
{/* 前端卡编辑器弹窗 */}
{showFrontendCardEditor && selectedCharacter && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="glass rounded-3xl p-8 max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold flex items-center gap-2">
<Layout className="w-6 h-6 text-primary" />
</h2>
<p className="text-sm text-white/50 mt-1">
extensions.frontend_card HTML
</p>
</div>
<button
onClick={() => { setShowFrontendCardEditor(false); setShowFrontendCardPreview(false) }}
className="p-2 glass-hover rounded-lg cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* 控制栏 */}
<div className="flex items-center gap-4 mb-4 flex-shrink-0">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={frontendCardEnabled}
onChange={(e) => setFrontendCardEnabled(e.target.checked)}
className="w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-white/80"></span>
</label>
<div className="flex items-center gap-2">
<span className="text-sm text-white/60"></span>
<select
value={frontendCardPosition}
onChange={(e) => setFrontendCardPosition(e.target.value as 'top' | 'bottom')}
className="px-3 py-1.5 glass rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="top"></option>
<option value="bottom"></option>
</select>
</div>
<div className="ml-auto flex items-center gap-2">
<button
onClick={() => setShowFrontendCardPreview(v => !v)}
className={`px-4 py-2 rounded-lg text-sm cursor-pointer transition-all ${
showFrontendCardPreview
? 'bg-primary/30 text-primary border border-primary/40'
: 'glass-hover'
}`}
>
{showFrontendCardPreview ? '▶ 隐藏预览' : '▶ 预览'}
</button>
<button
onClick={() => {
setFrontendCardHtml('')
setFrontendCardEnabled(true)
setFrontendCardPosition('top')
}}
className="px-4 py-2 glass-hover rounded-lg text-sm text-red-400 cursor-pointer"
>
</button>
</div>
</div>
{/* 编辑区 + 预览区 */}
<div className={`flex-1 overflow-hidden flex gap-4 min-h-0 ${showFrontendCardPreview ? 'flex-row' : 'flex-col'}`}>
{/* 代码编辑器 */}
<div className={`flex flex-col ${showFrontendCardPreview ? 'w-1/2' : 'flex-1'} min-h-0`}>
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-white/50">HTML / CSS / JS</span>
<span className="text-xs text-white/30">{frontendCardHtml.length} </span>
</div>
<textarea
value={frontendCardHtml}
onChange={(e) => setFrontendCardHtml(e.target.value)}
placeholder={`<!DOCTYPE html>
<html>
<head>
<style>
body { background: transparent; color: white; font-family: sans-serif; }
</style>
</head>
<body>
<div id="status-panel">...</div>
<script>
// 可以使用 ST 兼容 API
// getCurrentMessageId() → 当前消息索引
// getChatMessages(id) → [{ message: "..." }]
// window.onPlayerAction(text) → 触发玩家行动
document.addEventListener('DOMContentLoaded', function() {
var msg = getChatMessages(getCurrentMessageId());
document.getElementById('status-panel').textContent = msg[0]?.message || '';
});
<\/script>
</body>
</html>`}
className="flex-1 w-full px-4 py-3 glass rounded-xl text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none overflow-y-auto"
style={{ minHeight: 0 }}
spellCheck={false}
/>
</div>
{/* 预览区iframe */}
{showFrontendCardPreview && (
<div className="w-1/2 flex flex-col min-h-0">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-white/50"> iframe</span>
<button
onClick={() => {
if (frontendCardPreviewRef.current) {
frontendCardPreviewRef.current.srcdoc = frontendCardHtml
}
}}
className="text-xs px-2 py-1 glass-hover rounded cursor-pointer text-primary"
>
</button>
</div>
<div className="flex-1 border border-white/10 rounded-xl overflow-hidden bg-black/20">
<iframe
ref={frontendCardPreviewRef}
srcDoc={frontendCardHtml}
sandbox="allow-scripts"
className="w-full h-full"
style={{ border: 'none', minHeight: '300px' }}
title="前端卡预览"
/>
</div>
</div>
)}
</div>
{/* 底部说明 + 保存按钮 */}
<div className="flex items-start justify-between gap-4 mt-4 flex-shrink-0">
<div className="text-xs text-white/30 flex-1">
<p> <code className="text-primary/70">sandbox="allow-scripts"</code> iframe HTML/CSS/JS</p>
<p className="mt-0.5">访 ST API<code className="text-primary/70">getCurrentMessageId()</code><code className="text-primary/70">getChatMessages(id)</code><code className="text-primary/70">window.onPlayerAction(text)</code></p>
</div>
<button
onClick={() => setShowFrontendCardEditor(false)}
className="px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer flex-shrink-0"
>
</button>
</div>
</div>
</div>
)}
</div>
)
}