Files
st-react/web-app/src/components/StatusBarIframe.tsx
Echo 4cecfd6589 🎨 1.优化前端渲染功能(html和对话消息格式)
2.优化流式传输,新增流式渲染功能
3.优化正则处理逻辑
4.新增context budget管理系统
5.优化对话消息失败处理逻辑
6.新增前端卡功能(待完整测试)
2026-03-13 15:58:33 +08:00

501 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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