Files
memind/mindspace-h5-html-finish-guard.mjs
john 08feae8bef feat(h5-session): Session Broker、run SSE replay 与 Finish 竞态修复
落地 H5 Session 架构 Patch 1–5(Broker 收口、Router decision、SSE taxonomy、goosed 边界检查),
并新增可选 MEMIND_RUN_STREAM_REPLAY run 事件回放与 H5 假交付 guard;修复 Finish 先于 agent-run
gate 导致 UI 永久 loading 的竞态,接入 verify:h5-session-patches 回归脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 14:19:48 +08:00

276 lines
9.3 KiB
JavaScript
Raw Permalink 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.
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { extractStaticPageLinks } from './mindspace-chat-save.mjs';
import {
createPublicHtmlLinkExists,
shouldRetryHtmlGenerationReply,
} from './wechat-mp.mjs';
import {
isStubPublicHtmlContent,
materializeMissingPublicHtmlWrites,
} from './mindspace-public-finish-sync.mjs';
const PUBLIC_HTML_PATH_PATTERN_GLOBAL = /(?:^|[^a-z0-9_./-])(public\/[a-z0-9][a-z0-9._/-]{0,255}\.html)\b/gi;
const PUBLIC_ASSET_ATTR_PATTERN = /(?:href|src)=["']([^"'#?\s]+)["']/gi;
const PUBLIC_ASSET_EXT_PATTERN = /\.(svg|png|jpe?g|webp|gif)$/i;
const repairAttemptsBySession = new Map();
function envFlag(value, fallback = false) {
const raw = String(value ?? '').trim().toLowerCase();
if (!raw) return fallback;
return ['1', 'true', 'yes', 'on'].includes(raw);
}
function messageText(message) {
const content = message?.content;
if (typeof content === 'string') return content.trim();
if (!Array.isArray(content)) return '';
return content
.filter((item) => item?.type === 'text')
.map((item) => String(item.text ?? '').trim())
.filter(Boolean)
.join('\n')
.trim();
}
function normalizePublicRelativePath(value, { publishDir } = {}) {
const clean = String(value ?? '')
.split('?')[0]
.split('#')[0]
.replace(/^\.\//, '')
.trim()
.replace(/\\/g, '/');
if (!clean || clean.includes('://') || clean.startsWith('data:')) return null;
if (!clean.startsWith('public/')) {
if (clean.startsWith('assets/')) return `public/${clean}`;
return null;
}
const root = path.resolve(String(publishDir ?? ''));
const target = path.resolve(root, clean);
if (target !== root && !target.startsWith(`${root}${path.sep}`)) return null;
return clean;
}
function publicFileExists(publishDir, relativePath) {
const normalized = normalizePublicRelativePath(relativePath, { publishDir });
if (!normalized) return false;
const target = path.resolve(String(publishDir ?? ''), normalized);
return fs.existsSync(target) && fs.statSync(target).isFile();
}
function publicHtmlIsMissingOrStub(publishDir, relativePath) {
const normalized = normalizePublicRelativePath(relativePath, { publishDir });
if (!normalized || !normalized.toLowerCase().endsWith('.html')) return false;
const target = path.resolve(String(publishDir ?? ''), normalized);
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) return true;
try {
return isStubPublicHtmlContent(fs.readFileSync(target, 'utf8'));
} catch {
return true;
}
}
export function isH5HtmlFinishGuardEnabled(env = process.env) {
return envFlag(env.MEMIND_H5_HTML_FINISH_GUARD, false);
}
export function resetH5HtmlFinishGuardAttempts(sessionId = null) {
if (sessionId) repairAttemptsBySession.delete(String(sessionId));
else repairAttemptsBySession.clear();
}
export function collectReferencedPublicHtmlPaths(messages, currentUser) {
const paths = new Set();
for (const message of Array.isArray(messages) ? messages : []) {
if (message?.role !== 'assistant') continue;
const text = messageText(message);
if (!text) continue;
for (const link of extractStaticPageLinks(text, {
userId: currentUser?.id,
username: currentUser?.username ?? null,
})) {
if (link?.relativePath) paths.add(link.relativePath);
}
for (const match of text.matchAll(PUBLIC_HTML_PATH_PATTERN_GLOBAL)) {
if (match[1]) paths.add(match[1]);
}
}
return [...paths].sort();
}
export function collectMissingPublicHtmlPaths(messages, currentUser, publishDir) {
return collectReferencedPublicHtmlPaths(messages, currentUser).filter((relativePath) =>
publicHtmlIsMissingOrStub(publishDir, relativePath),
);
}
export function collectMissingPublicAssetReferences(publishDir) {
const root = path.resolve(String(publishDir ?? ''));
const publicDir = path.join(root, 'public');
if (!fs.existsSync(publicDir) || !fs.statSync(publicDir).isDirectory()) return [];
const missing = new Map();
for (const file of fs.readdirSync(publicDir)) {
if (!file.toLowerCase().endsWith('.html')) continue;
const htmlRelativePath = `public/${file}`;
const content = fs.readFileSync(path.join(publicDir, file), 'utf8');
for (const match of content.matchAll(PUBLIC_ASSET_ATTR_PATTERN)) {
const relativePath = normalizePublicRelativePath(match[1], { publishDir: root });
if (!relativePath || !PUBLIC_ASSET_EXT_PATTERN.test(relativePath)) continue;
if (publicFileExists(root, relativePath)) continue;
missing.set(relativePath, { relativePath, htmlRelativePath });
}
}
return [...missing.values()].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
}
export function evaluateH5HtmlFinishGuard({
messages,
currentUser,
publishDir,
syncResult = null,
} = {}) {
materializeMissingPublicHtmlWrites({ messages, publishDir });
const missingHtml = collectMissingPublicHtmlPaths(messages, currentUser, publishDir);
const missingAssets = collectMissingPublicAssetReferences(publishDir);
const lastAssistant = [...(Array.isArray(messages) ? messages : [])]
.reverse()
.find((message) => message?.role === 'assistant');
const reply = {
text: messageText(lastAssistant),
messages: Array.isArray(messages) ? messages : [],
};
const intent = { agentText: reply.text };
const linkExists = createPublicHtmlLinkExists(publishDir);
const confirmedArtifacts = (syncResult?.publicHtmlRelativePaths ?? [])
.filter((relativePath) => publicFileExists(publishDir, relativePath))
.map((relativePath) => ({ relativePath, localPath: path.join(publishDir, relativePath) }));
const htmlGenerationNeedsRetry = shouldRetryHtmlGenerationReply({
reply,
intent,
confirmedArtifacts,
hasValidLinkInReply: false,
});
const needsRepair =
missingHtml.length > 0 ||
missingAssets.length > 0 ||
htmlGenerationNeedsRetry;
return {
needsRepair,
missingHtml,
missingAssets,
htmlGenerationNeedsRetry,
linkExists,
};
}
export function buildH5HtmlRepairPrompt({ missingHtml = [], missingAssets = [] } = {}) {
const lines = [
'【系统补盘请求】检测到 public 页面交付不完整。请立即使用 write_file 写入缺失文件(完整内容),并在完成后确认磁盘存在。',
'要求:先 load_skill static-page-publish,再逐个 write_file;不要用空回复或仅 edit_file 改 HTML 代替资源落盘。',
];
if (missingHtml.length) {
lines.push('', '缺失或仍为 stub 的 HTML');
for (const relativePath of missingHtml) lines.push(`- ${relativePath}`);
}
if (missingAssets.length) {
lines.push('', '页面已引用但磁盘缺失的资源:');
for (const item of missingAssets) {
lines.push(`- ${item.relativePath}(来自 ${item.htmlRelativePath}`);
}
}
lines.push('', '写完后请 list_dir public/assets 或对应目录,确认文件真实存在。');
return lines.join('\n');
}
export async function maybeRepairH5HtmlAfterFinish({
sessionId,
userId,
currentUser,
messages,
publishDir,
syncResult = null,
tkmindProxy = null,
maxAttempts = 1,
env = process.env,
logger = console,
} = {}) {
const evaluation = evaluateH5HtmlFinishGuard({
messages,
currentUser,
publishDir,
syncResult,
});
if (!evaluation.needsRepair) {
resetH5HtmlFinishGuardAttempts(sessionId);
return { repaired: false, skipped: 'ok', ...evaluation };
}
if (!isH5HtmlFinishGuardEnabled(env)) {
logger.warn?.(
`[MindSpace][h5-html-guard] incomplete public delivery for session ${sessionId}: `
+ `html=${evaluation.missingHtml.join(',') || 'none'} `
+ `assets=${evaluation.missingAssets.map((item) => item.relativePath).join(',') || 'none'}`,
);
return { repaired: false, skipped: 'disabled', ...evaluation };
}
if (!tkmindProxy?.submitSessionReplyForUser) {
return { repaired: false, skipped: 'no_proxy', ...evaluation };
}
const key = String(sessionId ?? '');
const attempts = repairAttemptsBySession.get(key) ?? 0;
if (!key || attempts >= maxAttempts) {
logger.warn?.(
`[MindSpace][h5-html-guard] repair limit reached for session ${sessionId}`,
evaluation,
);
return { repaired: false, skipped: 'limit', attempts, ...evaluation };
}
repairAttemptsBySession.set(key, attempts + 1);
const prompt = buildH5HtmlRepairPrompt(evaluation);
const requestId = `h5-html-repair-${crypto.randomUUID()}`;
const userMessage = {
role: 'user',
content: [{ type: 'text', text: prompt }],
metadata: {
displayText: '请补全缺失的 public 页面与资源文件',
userVisible: false,
agentVisible: true,
memindRun: { htmlFinishRepair: true },
},
};
try {
await tkmindProxy.submitSessionReplyForUser(
userId,
sessionId,
requestId,
userMessage,
);
logger.info?.(
`[MindSpace][h5-html-guard] triggered repair for session ${sessionId} `
+ `(attempt ${attempts + 1}/${maxAttempts})`,
);
return { repaired: true, triggered: true, attempts: attempts + 1, ...evaluation };
} catch (err) {
logger.warn?.(
`[MindSpace][h5-html-guard] repair failed for session ${sessionId}: `
+ `${err instanceof Error ? err.message : err}`,
);
return {
repaired: false,
skipped: 'error',
error: err instanceof Error ? err.message : String(err),
attempts: attempts + 1,
...evaluation,
};
}
}