diff --git a/server/service/app/regex_script.go b/server/service/app/regex_script.go index 3dd9ce3..bfac854 100644 --- a/server/service/app/regex_script.go +++ b/server/service/app/regex_script.go @@ -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 注入 diff --git a/web-app/src/components/ChatArea.tsx b/web-app/src/components/ChatArea.tsx index ef15a24..e87bafc 100644 --- a/web-app/src/components/ChatArea.tsx +++ b/web-app/src/components/ChatArea.tsx @@ -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() + 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' && ( -
+
@@ -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' && ( -
+
diff --git a/web-app/src/components/MessageContent.tsx b/web-app/src/components/MessageContent.tsx index 8a697cf..765000c 100644 --- a/web-app/src/components/MessageContent.tsx +++ b/web-app/src/components/MessageContent.tsx @@ -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() && (
))} diff --git a/web-app/src/components/StatusBarIframe.tsx b/web-app/src/components/StatusBarIframe.tsx index e05feb3..6ac5bcd 100644 --- a/web-app/src/components/StatusBarIframe.tsx +++ b/web-app/src/components/StatusBarIframe.tsx @@ -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* - 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 = ` 的 HTML)。 + * 触发条件:剩余文本(代码块占位符以外的部分)包含完整的 块。 + * 提取逻辑:从最早的 HTML 文档标签或块级标签开始,到文本末尾,整段作为一个 HTML block 交给 iframe 渲染; + * 该标签之前的文本保留为普通叙事文本继续走渲染管线。 + */ +function extractInlineHtmlBlocks(text: string): { text: string; htmlBlocks: string[] } { + // 仅在包含完整 时触发 + const hasStyle = /][\s\S]*?<\/style>/i.test(text) + const hasScript = /][\s\S]*?<\/script>/i.test(text) + if (!hasStyle && !hasScript) { + return { text, htmlBlocks: [] } + } + + // 找到最早的 HTML 文档起始点: + // 优先匹配 / 等文档结构标签, + // 最后匹配块级 HTML 标签(
,