🎨 1.优化前端渲染功能(html和对话消息格式)
2.优化流式传输,新增流式渲染功能 3.优化正则处理逻辑 4.新增context budget管理系统 5.优化对话消息失败处理逻辑 6.新增前端卡功能(待完整测试)
This commit is contained in:
27
web-app/package-lock.json
generated
27
web-app/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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_ui(ST 社区卡常用)
|
||||
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
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface GetRegexScriptListRequest {
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
scope?: number
|
||||
ownerCharId?: number
|
||||
}
|
||||
|
||||
export interface RegexScriptListResponse {
|
||||
|
||||
@@ -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 节点稳定)
|
||||
* - 否则分配新 key(conversationId + 消息在列表中的位置索引,流式期间不会变)
|
||||
* 这样即使服务端刷新后 msg.id 从 tempId 变为真实 ID,React 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
500
web-app/src/components/StatusBarIframe.tsx
Normal file
500
web-app/src/components/StatusBarIframe.tsx
Normal 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
|
||||
/**
|
||||
* 父页面 origin(postMessage 安全检查)。
|
||||
* 默认为 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-origin,iframe 内脚本无法访问父页面 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 → 始终返回 true;prompt → 返回空字符串
|
||||
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 shim:sandbox 无 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
172
web-app/src/lib/regexEngine.ts
Normal file
172
web-app/src/lib/regexEngine.ts
Normal 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 和 namedGroups(ES2018+)
|
||||
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
73
web-app/src/lib/sse.ts
Normal 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(() => {})
|
||||
}
|
||||
}
|
||||
371
web-app/src/lib/textRenderer.ts
Normal file
371
web-app/src/lib/textRenderer.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/** 正则元字符转义 */
|
||||
function escapeRegexStr(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user