🎨 完善st兼容 && 完善支持前端卡
This commit is contained in:
@@ -426,7 +426,8 @@ func (s *RegexScriptService) ExtractMaintext(text string) (string, string) {
|
||||
func (s *RegexScriptService) GetScriptsForPlacement(userID uint, placement int, charID *uint, presetID *uint) ([]app.RegexScript, error) {
|
||||
var scripts []app.RegexScript
|
||||
|
||||
db := global.GVA_DB.Where("user_id = ? AND placement = ? AND disabled = ?", userID, placement, false)
|
||||
// markdownOnly=true 脚本只在前端显示层执行,后端不应用
|
||||
db := global.GVA_DB.Where("user_id = ? AND placement = ? AND disabled = ? AND markdown_only = ?", userID, placement, false, false)
|
||||
|
||||
// 作用域过滤:全局(0) 或 角色(1) 或 预设(2)
|
||||
// 使用参数化查询避免 SQL 注入
|
||||
|
||||
@@ -212,8 +212,9 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
* 命令格式示例:"/send 我选择休息|/trigger"
|
||||
*/
|
||||
const handleIframeAction = useCallback((
|
||||
type: 'fillInput' | 'playerAction' | 'triggerAction',
|
||||
payload: string
|
||||
type: 'fillInput' | 'playerAction' | 'triggerAction' | 'setChatMessage',
|
||||
payload: string,
|
||||
messageIndex?: number
|
||||
) => {
|
||||
if (type === 'fillInput' || type === 'playerAction') {
|
||||
setInputValue(payload)
|
||||
@@ -226,6 +227,12 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
if (text) {
|
||||
handleSendMessage(text)
|
||||
}
|
||||
} else if (type === 'setChatMessage' && messageIndex !== undefined) {
|
||||
// 就地替换指定消息内容(不发送 AI 请求)
|
||||
// 用于前端卡路由按钮切换页面内容(如 Clannad 路由)
|
||||
setMessages(prev => prev.map((msg, i) =>
|
||||
i === messageIndex ? { ...msg, content: payload } : msg
|
||||
))
|
||||
}
|
||||
}, [handleSendMessage])
|
||||
|
||||
@@ -263,6 +270,50 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将角色卡 extensions.regex_scripts(ST 格式)转换为 RegexScript 格式。
|
||||
* ST 与我们系统的 placement 值映射:
|
||||
* ST placement 数组含 1 = user_input → 我们的 0
|
||||
* ST placement 数组含 2 = ai_output → 我们的 1(markdownOnly 时仅在显示层执行)
|
||||
* 使用负数 id 避免与后端 id 冲突。
|
||||
*/
|
||||
const cardRegexScripts = useMemo((): RegexScript[] => {
|
||||
const raw = character.extensions?.regex_scripts
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw
|
||||
.filter((s: any) => !s.disabled)
|
||||
.map((s: any, idx: number): RegexScript => {
|
||||
// ST placement 是数组,我们取第一个有效值并映射
|
||||
const stPlacements: number[] = Array.isArray(s.placement)
|
||||
? s.placement
|
||||
: (typeof s.placement === 'number' ? [s.placement] : [2])
|
||||
// 含 1 (user_input)且不含 2 → 用户输入;其余默认 ai_output
|
||||
const placement = (stPlacements.includes(1) && !stPlacements.includes(2)) ? 0 : 1
|
||||
return {
|
||||
id: -(idx + 1),
|
||||
userId: 0,
|
||||
name: s.scriptName || s.name || `card-script-${idx}`,
|
||||
findRegex: s.findRegex || '',
|
||||
replaceWith: s.replaceString ?? s.replaceWith ?? '',
|
||||
trimStrings: Array.isArray(s.trimStrings) ? s.trimStrings : [],
|
||||
placement,
|
||||
disabled: false,
|
||||
markdownOnly: s.markdownOnly || false,
|
||||
runOnEdit: s.runOnEdit || false,
|
||||
promptOnly: s.promptOnly || false,
|
||||
substituteRegex: s.substituteRegex || false,
|
||||
minDepth: s.minDepth,
|
||||
maxDepth: s.maxDepth,
|
||||
scope: 1,
|
||||
ownerCharId: character.id,
|
||||
order: typeof s.order === 'number' ? s.order : idx,
|
||||
extensions: {},
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
}
|
||||
})
|
||||
}, [character.id, character.extensions])
|
||||
|
||||
const loadRegexScripts = async () => {
|
||||
try {
|
||||
// 并行加载:全局脚本(scope=0)+ 当前角色专属脚本(scope=1, ownerCharId=character.id)
|
||||
@@ -287,6 +338,27 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
}
|
||||
}
|
||||
|
||||
/** 合并卡片内置脚本(优先执行)和后端脚本,按 findRegex 去重防止重复执行 */
|
||||
const allRegexScripts = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
const result: RegexScript[] = []
|
||||
// 卡片内置脚本优先(权威来源),防止与后端导入的同一脚本重复
|
||||
for (const s of cardRegexScripts) {
|
||||
if (s.findRegex && !seen.has(s.findRegex)) {
|
||||
seen.add(s.findRegex)
|
||||
result.push(s)
|
||||
}
|
||||
}
|
||||
// 后端脚本(跳过已由卡片内置脚本覆盖的条目)
|
||||
for (const s of regexScripts) {
|
||||
if (s.findRegex && !seen.has(s.findRegex)) {
|
||||
seen.add(s.findRegex)
|
||||
result.push(s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}, [cardRegexScripts, regexScripts])
|
||||
|
||||
// ---- 设置更新 ----
|
||||
const handlePresetChange = async (presetId: number | null) => {
|
||||
try {
|
||||
@@ -668,13 +740,13 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
|
||||
{/* 前端卡(顶部,position='top' 或未设置) */}
|
||||
{frontendCard && frontendCard.enabled !== false && frontendCard.position !== 'bottom' && (
|
||||
<div className="px-4 pt-3 flex-shrink-0">
|
||||
<div className="px-4 pt-3 flex-shrink-0 min-w-0 overflow-x-hidden">
|
||||
<StatusBarIframe
|
||||
rawMessage={latestMessageContent}
|
||||
allMessages={allMessageContents}
|
||||
messageIndex={latestMessageIndex}
|
||||
htmlContent={frontendCard.html}
|
||||
minHeight={150}
|
||||
minHeight={500}
|
||||
onAction={handleIframeAction}
|
||||
/>
|
||||
</div>
|
||||
@@ -735,9 +807,10 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
content={msg.content}
|
||||
role={msg.role as 'user' | 'assistant'}
|
||||
messageIndex={msgIndex}
|
||||
regexDepth={messages.length - 1 - msgIndex}
|
||||
characterName={character.name}
|
||||
userName={variables.user || user?.username || ''}
|
||||
regexScripts={regexScripts}
|
||||
regexScripts={allRegexScripts}
|
||||
allMessages={allMessageContents}
|
||||
onChoiceSelect={(choice) => {
|
||||
setInputValue(choice)
|
||||
@@ -803,13 +876,13 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
|
||||
{/* 前端卡(底部,position='bottom' 时显示) */}
|
||||
{frontendCard && frontendCard.enabled !== false && frontendCard.position === 'bottom' && (
|
||||
<div className="px-4 pb-2 flex-shrink-0">
|
||||
<div className="px-4 pb-2 flex-shrink-0 min-w-0 overflow-x-hidden">
|
||||
<StatusBarIframe
|
||||
rawMessage={latestMessageContent}
|
||||
allMessages={allMessageContents}
|
||||
messageIndex={latestMessageIndex}
|
||||
htmlContent={frontendCard.html}
|
||||
minHeight={150}
|
||||
minHeight={500}
|
||||
onAction={handleIframeAction}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -29,8 +29,10 @@ interface MessageContentProps {
|
||||
content: string
|
||||
/** 消息角色 */
|
||||
role: 'user' | 'assistant'
|
||||
/** 消息在对话中的索引(用于正则 depth 过滤) */
|
||||
/** 消息在对话中的索引(用于 StatusBarIframe __CURRENT_ID__) */
|
||||
messageIndex?: number
|
||||
/** ST 兼容深度(0=最新消息),用于正则脚本 minDepth/maxDepth 过滤 */
|
||||
regexDepth?: number
|
||||
/** 角色名称(变量替换 {{char}}) */
|
||||
characterName?: string
|
||||
/** 用户名称(变量替换 {{user}}) */
|
||||
@@ -47,7 +49,7 @@ interface MessageContentProps {
|
||||
* - type='playerAction' → 仅填充输入框
|
||||
* - type='triggerAction'→ 填充并发送
|
||||
*/
|
||||
onAction?: (type: 'fillInput' | 'playerAction' | 'triggerAction', payload: string) => void
|
||||
onAction?: (type: 'fillInput' | 'playerAction' | 'triggerAction' | 'setChatMessage', payload: string, messageIndex?: number) => void
|
||||
/**
|
||||
* 是否处于流式传输中。
|
||||
* true → 跳过完整渲染管线,直接显示原始文本(低延迟,实时打字效果)
|
||||
@@ -62,6 +64,7 @@ export default function MessageContent({
|
||||
content,
|
||||
role,
|
||||
messageIndex = 0,
|
||||
regexDepth,
|
||||
characterName = '',
|
||||
userName = '',
|
||||
regexScripts = [],
|
||||
@@ -76,11 +79,12 @@ export default function MessageContent({
|
||||
// 必须在所有条件 return 之前调用,遵守 Rules of Hooks。
|
||||
// 流式期间 isStreaming=true 时结果不会被使用,但 hook 仍需执行以保持调用顺序一致。
|
||||
const rendered = useMemo(() => {
|
||||
// Step 1: 执行正则脚本
|
||||
// Step 1: 执行正则脚本(使用 ST 兼容深度:0=最新消息)
|
||||
const depth = regexDepth ?? messageIndex
|
||||
const afterRegex =
|
||||
role === 'assistant'
|
||||
? processAIOutput(content, regexScripts, messageIndex)
|
||||
: processUserInput(content, regexScripts, messageIndex)
|
||||
? processAIOutput(content, regexScripts, depth)
|
||||
: processUserInput(content, regexScripts, depth)
|
||||
|
||||
// Step 2: 解析特殊块
|
||||
const parsed = parseRawMessage(afterRegex)
|
||||
@@ -98,7 +102,7 @@ export default function MessageContent({
|
||||
: { html: '' }
|
||||
|
||||
return { parsed, bodyHtml, htmlBlocks, maintextHtml }
|
||||
}, [content, role, messageIndex, characterName, userName, regexScripts])
|
||||
}, [content, role, messageIndex, regexDepth, characterName, userName, regexScripts])
|
||||
|
||||
const { parsed, bodyHtml, htmlBlocks, maintextHtml } = rendered
|
||||
|
||||
@@ -162,8 +166,8 @@ export default function MessageContent({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* bodyText 渲染区(仅当没有 maintext 时显示,避免重复) */}
|
||||
{!parsed.maintext && parsed.bodyText && (
|
||||
{/* bodyText 渲染区:检查 bodyHtml 本身是否非空,避免 html块清除后仍留白 */}
|
||||
{!parsed.maintext && bodyHtml.trim() && (
|
||||
<div
|
||||
className="text-sm leading-relaxed break-words overflow-wrap-anywhere message-body"
|
||||
dangerouslySetInnerHTML={{ __html: bodyHtml }}
|
||||
@@ -179,7 +183,7 @@ export default function MessageContent({
|
||||
messageIndex={messageIndex}
|
||||
htmlContent={html}
|
||||
statusYaml={parsed.statusYaml}
|
||||
minHeight={150}
|
||||
minHeight={400}
|
||||
onAction={onAction}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
* 内部使用 sandbox iframe + shim 脚本,与父页面通过 postMessage 桥接。
|
||||
*
|
||||
* 安全说明:
|
||||
* - sandbox 属性仅保留 allow-scripts,去除 allow-same-origin(防止 iframe 访问父页面 storage/cookie)
|
||||
* - postMessage 使用 '*' 作为 targetOrigin(沙箱 iframe origin 为 'null',指定具体 origin 会被浏览器丢弃)
|
||||
* - sandbox 属性包含 allow-same-origin:允许 iframe 访问父页面 window(ST 兼容必需)。
|
||||
* 卡片脚本(如 Clannad)通过 window.parent 存取全局状态和 ST API。
|
||||
* 原版 ST 中脚本直接运行在主页面上下文,不存在 iframe 隔离。
|
||||
* - postMessage 使用 '*' 作为 targetOrigin(兼容 srcdoc iframe)
|
||||
* - 父页面通过 event.source === iframe.contentWindow 校验消息来源
|
||||
*
|
||||
* 对应文档第 5 节(HTML 状态栏渲染器)与重构文档 8.4 节。
|
||||
@@ -42,7 +44,7 @@ interface StatusBarIframeProps {
|
||||
* - type='playerAction' → 仅填充输入框(onPlayerAction / options-list 点击)
|
||||
* - type='triggerAction'→ 填充并发送(triggerSlash,命令格式如 /send text|/trigger)
|
||||
*/
|
||||
onAction?: (type: 'fillInput' | 'playerAction' | 'triggerAction', payload: string) => void
|
||||
onAction?: (type: 'fillInput' | 'playerAction' | 'triggerAction' | 'setChatMessage', payload: string, messageIndex?: number) => void
|
||||
}
|
||||
|
||||
// ============= shim 脚本模板 =============
|
||||
@@ -73,7 +75,7 @@ function buildShimScript(
|
||||
var __CHAT_MESSAGES__ = ${allJson};
|
||||
var __CURRENT_ID__ = ${currentId};
|
||||
|
||||
// ST 兼容 API
|
||||
// ST 兼容 API(同时注册到 iframe window 和 parent window,与原版 ST 行为一致)
|
||||
window.getCurrentMessageId = function() { return __CURRENT_ID__; };
|
||||
window.getChatMessages = function(id) {
|
||||
var idx = (id !== undefined && id !== null) ? id : __CURRENT_ID__;
|
||||
@@ -86,8 +88,7 @@ function buildShimScript(
|
||||
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 模拟,让卡片的主题/模式偏好设置代码正常运行(数据不跨页面持久化)。
|
||||
// localStorage shim(兜底):若 localStorage 不可用则用内存 Map 模拟
|
||||
(function() {
|
||||
var _store = {};
|
||||
var _ls = {
|
||||
@@ -99,15 +100,12 @@ function buildShimScript(
|
||||
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 校验保证。
|
||||
// 向父页面发送消息(allow-same-origin 下 origin 继承父页面,但 '*' 仍兼容所有场景)
|
||||
function sendToParent(type, data) {
|
||||
window.parent.postMessage({ type: type, data: data }, '*');
|
||||
}
|
||||
@@ -129,6 +127,12 @@ function buildShimScript(
|
||||
sendToParent('triggerAction', { command: command });
|
||||
};
|
||||
|
||||
// ST 兼容:setChatMessage(content, msgId) → 就地替换指定消息内容(不发送 AI 请求)
|
||||
// 常见于前端卡路由按钮,用于切换页面内容(如 Clannad 路由)
|
||||
window.setChatMessage = function(content, msgId) {
|
||||
sendToParent('setChatMessage', { content: content, messageId: msgId !== undefined ? msgId : __CURRENT_ID__ });
|
||||
};
|
||||
|
||||
// ST 兼容:sendToChatBox → 只填充输入框,不发送
|
||||
window.sendToChatBox = function(text) {
|
||||
sendToParent('fillInput', { text: text });
|
||||
@@ -139,6 +143,23 @@ function buildShimScript(
|
||||
sendToParent('playerAction', { action: action });
|
||||
};
|
||||
|
||||
// ---- ST 兼容:将 API 同步到 parent window ----
|
||||
// 原版 ST 中卡片脚本运行在主页面上下文,API 直接挂在主 window。
|
||||
// 我们用 iframe 隔离,但许多卡片(如 Clannad)通过 window.parent 访问全局状态
|
||||
// 和 ST API,因此需要将 shim API 也注册到 parent window,确保完全兼容。
|
||||
try {
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.getCurrentMessageId = window.getCurrentMessageId;
|
||||
window.parent.getChatMessages = window.getChatMessages;
|
||||
window.parent.setChatMessage = window.setChatMessage;
|
||||
window.parent.triggerSlash = window.triggerSlash;
|
||||
window.parent.sendToChatBox = window.sendToChatBox;
|
||||
window.parent.onPlayerAction = window.onPlayerAction;
|
||||
}
|
||||
} catch(e) {
|
||||
// allow-same-origin 未启用时忽略跨域错误
|
||||
}
|
||||
|
||||
// DOMContentLoaded 后统一执行:注入 YAML 兜底数据 + 绑定 options-list + 上报尺寸
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 注入 Status_block YAML:
|
||||
@@ -211,13 +232,10 @@ function buildShimScript(
|
||||
function buildIframeDoc(content: string, shimScript: string, statusYaml: string): string {
|
||||
const isFullDoc = /^\s*<!DOCTYPE/i.test(content) || /^\s*<html/i.test(content)
|
||||
|
||||
const baseStyle = `
|
||||
// 片段样式:用于非完整文档时设置基础排版
|
||||
const fragmentBaseStyle = `
|
||||
<style>
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
@@ -227,8 +245,7 @@ function buildIframeDoc(content: string, shimScript: string, statusYaml: string)
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
box-sizing: border-box;
|
||||
/* display: inline-block 使 body 宽度由内容决定而非受 iframe 视口约束,
|
||||
这样 scrollWidth 才能反映真实内容宽度 */
|
||||
/* display: inline-block 使 body 宽度由内容决定而非受 iframe 视口约束 */
|
||||
display: inline-block;
|
||||
min-width: 100vw;
|
||||
}
|
||||
@@ -246,11 +263,18 @@ function buildIframeDoc(content: string, shimScript: string, statusYaml: string)
|
||||
const inlineScripts = `<script>${shimScript}<\/script>`
|
||||
|
||||
if (isFullDoc) {
|
||||
// 全文档:在 </head> 前注入 baseStyle + yaml data + shim
|
||||
return content.replace(
|
||||
/(<\/head>)/i,
|
||||
`${baseStyle}${yamlDataTag}${inlineScripts}$1`
|
||||
)
|
||||
// 完整 HTML 文档(游戏/前端卡):只注入 shim,不覆盖游戏自有布局样式
|
||||
// 优先注入到 </head>,如无 </head> 则注入到 </body> 前
|
||||
// 注意:使用 function 替换(而非 string 替换),防止 injected 中的 $ 字符
|
||||
// 被 String.replace() 解读为反向引用($1/$&/$` 等),导致 shim 注入内容被污染。
|
||||
const injected = `${yamlDataTag}${inlineScripts}`
|
||||
if (/<\/head>/i.test(content)) {
|
||||
return content.replace(/(<\/head>)/i, (_, head) => `${injected}${head}`)
|
||||
}
|
||||
if (/<\/body>/i.test(content)) {
|
||||
return content.replace(/(<\/body>)/i, (_, body) => `${injected}${body}`)
|
||||
}
|
||||
return content + injected
|
||||
}
|
||||
|
||||
const bodyContent = /^\s*<body/i.test(content)
|
||||
@@ -262,7 +286,7 @@ function buildIframeDoc(content: string, shimScript: string, statusYaml: string)
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
${baseStyle}
|
||||
${fragmentBaseStyle}
|
||||
${yamlDataTag}
|
||||
${inlineScripts}
|
||||
</head>
|
||||
@@ -340,6 +364,9 @@ export default function StatusBarIframe({
|
||||
} else if (msg.type === 'triggerAction') {
|
||||
const command = (msg.data as { command?: string })?.command ?? ''
|
||||
onAction('triggerAction', command)
|
||||
} else if (msg.type === 'setChatMessage') {
|
||||
const { content, messageId } = msg.data as { content?: string; messageId?: number }
|
||||
onAction('setChatMessage', content ?? '', messageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -356,12 +383,18 @@ export default function StatusBarIframe({
|
||||
return (
|
||||
<div
|
||||
className="border border-white/10 rounded-lg bg-black/20 overflow-auto"
|
||||
style={{ maxWidth: '100%' }}
|
||||
style={{ maxWidth: '100%', width: '100%' }}
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
// 去掉 allow-same-origin:防止 iframe 内代码访问父页面 localStorage / cookie
|
||||
sandbox="allow-scripts"
|
||||
// allow-scripts: 运行 JS
|
||||
// allow-same-origin: 允许访问父页面 window(ST 兼容:卡片脚本通过 window.parent 存取全局状态)
|
||||
// allow-forms: 游戏内表单交互
|
||||
// allow-pointer-lock: 鼠标锁定(部分游戏需要)
|
||||
// allow-modals: alert/confirm/prompt(已由 shim 覆盖,保留作备用)
|
||||
// allow-downloads: 游戏存档下载
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock allow-modals allow-downloads"
|
||||
allow="autoplay"
|
||||
style={{
|
||||
width: frameWidth ? `${frameWidth}px` : '100%',
|
||||
height: `${frameHeight}px`,
|
||||
|
||||
@@ -55,6 +55,9 @@ export function applyRegexScripts(
|
||||
// 检查 placement
|
||||
if (!matchesPlacement(script, placement)) continue
|
||||
|
||||
// promptOnly=true 表示只在构建 prompt 时执行(前端显示层不执行)
|
||||
if (script.promptOnly) continue
|
||||
|
||||
// 检查深度(仅在 depth 已知时)
|
||||
if (typeof depth === 'number') {
|
||||
if (
|
||||
@@ -99,17 +102,21 @@ export function runSingleScript(script: RegexScript, text: string): string {
|
||||
|
||||
try {
|
||||
let result = text.replace(regex, (match, ...args) => {
|
||||
// 支持 $1, $2... 捕获组
|
||||
// 支持 $0 / {{match}} / $1 $2... 捕获组
|
||||
// 注意:必须用 function 替换(而非 string),防止 match 或 group 中含有 $n/$& 等被
|
||||
// String.replace() 解读为反向引用,导致替换内容污染。
|
||||
let replacement = replaceWith
|
||||
// ST 兼容:{{match}} → $0
|
||||
.replace(/\{\{match\}\}/gi, match)
|
||||
// ST 兼容:{{match}} / $0 → 整体匹配
|
||||
.replace(/\{\{match\}\}/gi, () => match)
|
||||
.replace(/\$0/g, () => match)
|
||||
|
||||
// 替换捕获组 $1 $2...
|
||||
const groups = args.slice(0, args.length - 3) // 去掉 offset、input 和 namedGroups(ES2018+)
|
||||
groups.forEach((group, i) => {
|
||||
const safeGroup = group ?? ''
|
||||
replacement = replacement.replace(
|
||||
new RegExp(`\\$${i + 1}`, 'g'),
|
||||
group ?? ''
|
||||
() => safeGroup // 用 function 防止 safeGroup 中的 $n 被二次解释
|
||||
)
|
||||
})
|
||||
|
||||
@@ -152,10 +159,18 @@ function buildRegex(pattern: string): RegExp | null {
|
||||
|
||||
/**
|
||||
* 判断脚本是否应该在指定 placement 执行
|
||||
* RegexScript.placement 是数字(后端 enum),不是数组
|
||||
* 后端脚本:placement 是单个数字
|
||||
* 角色卡内置脚本(经过 ChatArea 转换后):也是单个数字(已做 ST→我们的映射)
|
||||
*/
|
||||
function matchesPlacement(script: RegexScript, placement: RegexPlacement): boolean {
|
||||
// 后端存的是单个数字
|
||||
// 如果 placement 存储的是数组(ST 原始格式),检查是否包含目标值
|
||||
const p = script.placement as unknown
|
||||
if (Array.isArray(p)) {
|
||||
// ST placement 数组 [2] → 我们的 1 (ai_output)
|
||||
// 但经过 ChatArea.cardRegexScripts 转换后应该已经是单数字了;
|
||||
// 这里保留作为防御性处理
|
||||
return p.includes(placement === 1 ? 2 : placement === 0 ? 1 : placement)
|
||||
}
|
||||
return script.placement === placement
|
||||
}
|
||||
|
||||
|
||||
@@ -171,19 +171,31 @@ export function renderMessageHtml(
|
||||
|
||||
let text = rawText
|
||||
|
||||
// 早期检测:bodyText 本身就是一份完整 HTML 文档(AI 直接返回裸 HTML,未包在代码围栏中)
|
||||
// 直接路由到 htmlBlocks,不走普通文本渲染管线
|
||||
if (/^\s*<!DOCTYPE\s/i.test(text) || /^\s*<html[\s>]/i.test(text)) {
|
||||
return { html: '', htmlBlocks: [text] }
|
||||
}
|
||||
|
||||
// Step 2: 变量替换
|
||||
text = substituteVariables(text, options)
|
||||
|
||||
// Step 3: 清理控制性标签/区域
|
||||
// Step 5(提前): 先抽取 fenced code blocks,用占位符替换
|
||||
// 必须在 cleanControlTags 之前执行,防止 HTML 注释清理破坏代码块内容
|
||||
const { text: textWithPlaceholders, blocks } = extractCodeBlocks(text)
|
||||
text = textWithPlaceholders
|
||||
|
||||
// Step 5b: 检测并提取内联 HTML 块(含 <style> 或 <script> 的富 HTML)
|
||||
// 在代码块已抽取之后、转义之前执行,避免 HTML 被 escapeHtml 破坏
|
||||
const { text: textAfterInlineExtract, htmlBlocks: inlineHtmlBlocks } = extractInlineHtmlBlocks(text)
|
||||
text = textAfterInlineExtract
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -197,9 +209,9 @@ export function renderMessageHtml(
|
||||
text = text.replace(/\n/g, '<br>')
|
||||
|
||||
// Step 10: 恢复代码块(html 块替换为空占位,收集到 htmlBlocks)
|
||||
const { html, htmlBlocks } = restoreCodeBlocks(text, blocks)
|
||||
const { html, htmlBlocks: codeBlockHtmlBlocks } = restoreCodeBlocks(text, blocks)
|
||||
|
||||
return { html, htmlBlocks }
|
||||
return { html, htmlBlocks: [...inlineHtmlBlocks, ...codeBlockHtmlBlocks] }
|
||||
}
|
||||
|
||||
// ============= 各步骤实现 =============
|
||||
@@ -265,6 +277,58 @@ function normalizeWhitespace(text: string): string {
|
||||
return text.trim()
|
||||
}
|
||||
|
||||
// ---- 内联 HTML 块提取 ----
|
||||
|
||||
/**
|
||||
* 检测并提取内联富 HTML 内容(含 <style>...</style> 或 <script>...</script> 的 HTML)。
|
||||
* 触发条件:剩余文本(代码块占位符以外的部分)包含完整的 <style>...</style> 或 <script>...</script> 块。
|
||||
* 提取逻辑:从最早的 HTML 文档标签或块级标签开始,到文本末尾,整段作为一个 HTML block 交给 iframe 渲染;
|
||||
* 该标签之前的文本保留为普通叙事文本继续走渲染管线。
|
||||
*/
|
||||
function extractInlineHtmlBlocks(text: string): { text: string; htmlBlocks: string[] } {
|
||||
// 仅在包含完整 <style>...</style> 或 <script>...</script> 时触发
|
||||
const hasStyle = /<style[\s>][\s\S]*?<\/style>/i.test(text)
|
||||
const hasScript = /<script[\s>][\s\S]*?<\/script>/i.test(text)
|
||||
if (!hasStyle && !hasScript) {
|
||||
return { text, htmlBlocks: [] }
|
||||
}
|
||||
|
||||
// 找到最早的 HTML 文档起始点:
|
||||
// 优先匹配 <!DOCTYPE 或 <html(完整文档),其次匹配 <head>/<body> 等文档结构标签,
|
||||
// 最后匹配块级 HTML 标签(<div>, <style>, <script> 等)
|
||||
const candidates: number[] = []
|
||||
|
||||
const docTypeMatch = text.match(/<!DOCTYPE\s/i)
|
||||
if (docTypeMatch?.index !== undefined) candidates.push(docTypeMatch.index)
|
||||
|
||||
const htmlTagMatch = text.match(/<html[\s>]/i)
|
||||
if (htmlTagMatch?.index !== undefined) candidates.push(htmlTagMatch.index)
|
||||
|
||||
const headMatch = text.match(/<head[\s>]/i)
|
||||
if (headMatch?.index !== undefined) candidates.push(headMatch.index)
|
||||
|
||||
const bodyMatch = text.match(/<body[\s>]/i)
|
||||
if (bodyMatch?.index !== undefined) candidates.push(bodyMatch.index)
|
||||
|
||||
const blockTagPattern = /<(?:div|section|article|header|footer|nav|main|aside|style|script|table|ul|ol|dl|form|fieldset|details|figure|blockquote)[\s>]/i
|
||||
const blockMatch = text.match(blockTagPattern)
|
||||
if (blockMatch?.index !== undefined) candidates.push(blockMatch.index)
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return { text, htmlBlocks: [] }
|
||||
}
|
||||
|
||||
// 从最早的匹配点开始截取
|
||||
const splitIndex = Math.min(...candidates)
|
||||
const narrativeText = text.slice(0, splitIndex)
|
||||
const htmlBlock = text.slice(splitIndex)
|
||||
|
||||
return {
|
||||
text: narrativeText,
|
||||
htmlBlocks: [htmlBlock],
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 代码块处理 ----
|
||||
|
||||
interface CodeBlock {
|
||||
@@ -288,11 +352,16 @@ function extractCodeBlocks(text: string): { text: string; blocks: Map<string, Co
|
||||
const trimmedCode = code.trim()
|
||||
const key = `${PLACEHOLDER_PREFIX}${idx++}${PLACEHOLDER_PREFIX}`
|
||||
|
||||
// 判断是否是 HTML:明确声明 html/text,或内容含有 HTML 标签
|
||||
// 判断是否是 HTML:
|
||||
// 1. 明确声明 html/text 语言
|
||||
// 2. 内容含有开标签或闭标签(<\/?[a-zA-Z] 同时匹配 <div 和 </div>)
|
||||
// 3. 内容是完整 HTML 文档(DOCTYPE/html 开头)
|
||||
const isHtml =
|
||||
trimmedLang === 'html' ||
|
||||
trimmedLang === 'text' ||
|
||||
(trimmedLang === '' && /<[a-zA-Z][^>]*>/.test(trimmedCode))
|
||||
/^\s*<!DOCTYPE\s/i.test(trimmedCode) ||
|
||||
/^\s*<html[\s>]/i.test(trimmedCode) ||
|
||||
(trimmedLang === '' && /<\/?[a-zA-Z][^>]*>/.test(trimmedCode))
|
||||
|
||||
blocks.set(key, { lang: trimmedLang, code: trimmedCode, isHtml })
|
||||
return key
|
||||
|
||||
@@ -199,7 +199,7 @@ export default function ChatPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex w-full">
|
||||
<div className="relative z-10 flex w-full min-w-0 overflow-hidden">
|
||||
{/* 左侧边栏 */}
|
||||
{showSidebar && (
|
||||
<Sidebar
|
||||
@@ -220,7 +220,7 @@ export default function ChatPage() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex">
|
||||
<div className="flex-1 flex min-w-0 overflow-hidden">
|
||||
{activePanel === 'chat' ? (
|
||||
<>
|
||||
<ChatArea
|
||||
|
||||
Reference in New Issue
Block a user