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 建表和注册 dataset,HTML 只能通过 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, }; } }