08feae8bef
落地 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>
276 lines
9.3 KiB
JavaScript
276 lines
9.3 KiB
JavaScript
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,
|
||
};
|
||
}
|
||
}
|