Files
memind/mindspace-h5-html-finish-guard.mjs
T

318 lines
11 KiB
JavaScript
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.
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';
import { scanWorkspaceFilesForProhibitedBrowserStorage } from './mindspace-browser-storage-policy.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 collectTouchedPublicCodePaths(messages, publishDir, syncResult = null) {
const paths = new Set(
(syncResult?.publicHtmlRelativePaths ?? [])
.map((item) => normalizePublicRelativePath(item, { publishDir }))
.filter(Boolean),
);
for (const message of Array.isArray(messages) ? messages : []) {
for (const item of message?.content ?? []) {
if (item?.type !== 'toolRequest') continue;
const toolCall = item.toolCall?.value;
const name = String(toolCall?.name ?? '').trim().split('__').at(-1);
if (!['write_file', 'edit_file', 'write', 'edit'].includes(name)) continue;
const args = toolCall?.arguments ?? {};
const relativePath = normalizePublicRelativePath(args.path ?? args.file_path, { publishDir });
if (relativePath) paths.add(relativePath);
}
}
return [...paths];
}
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 browserStorageViolations = scanWorkspaceFilesForProhibitedBrowserStorage({
publishDir,
relativePaths: collectTouchedPublicCodePaths(messages, publishDir, syncResult),
});
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 ||
browserStorageViolations.length > 0 ||
htmlGenerationNeedsRetry;
return {
needsRepair,
missingHtml,
missingAssets,
browserStorageViolations,
htmlGenerationNeedsRetry,
linkExists,
};
}
export function buildH5HtmlRepairPrompt({
missingHtml = [],
missingAssets = [],
browserStorageViolations = [],
} = {}) {
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}`);
}
}
if (browserStorageViolations.length) {
lines.push(
'',
'以下页面违反数据存储硬约束:',
...browserStorageViolations.map((item) => `- ${item.relativePath}:禁止 ${item.apis.join(', ')}`),
'',
'必须删除浏览器存储代码,load_skill → page-data-collect,使用 private_data_* 在当前用户专属 PostgreSQL schema 建表和注册 datasetHTML 只能通过 Page Data API 读写。禁止 SQLite 或任何本地 fallback。',
);
}
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);
// goosed validates request_id as a UUID, so repair metadata must not be
// encoded by prefixing the id. The message metadata below carries the type.
const requestId = 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,
};
}
}