🎨 完善st兼容 && 完善支持前端卡

This commit is contained in:
2026-03-13 20:27:11 +08:00
parent 4cecfd6589
commit 5ca65e3004
7 changed files with 256 additions and 61 deletions

View File

@@ -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 注入

View File

@@ -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_scriptsST 格式)转换为 RegexScript 格式。
* ST 与我们系统的 placement 值映射:
* ST placement 数组含 1 = user_input → 我们的 0
* ST placement 数组含 2 = ai_output → 我们的 1markdownOnly 时仅在显示层执行)
* 使用负数 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>

View File

@@ -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}
/>
))}

View File

@@ -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 访问父页面 windowST 兼容必需)。
* 卡片脚本(如 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 shimsandbox 无 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: 允许访问父页面 windowST 兼容:卡片脚本通过 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`,

View File

@@ -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 和 namedGroupsES2018+
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
}

View File

@@ -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

View File

@@ -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