318 lines
11 KiB
JavaScript
318 lines
11 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';
|
||
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,
|
||
};
|
||
}
|
||
}
|