501 lines
19 KiB
TypeScript
501 lines
19 KiB
TypeScript
/**
|
||
* 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
|
||
}
|