769 lines
26 KiB
JavaScript
769 lines
26 KiB
JavaScript
import crypto from 'node:crypto';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { isPageDataIntent } from './chat-skills.mjs';
|
||
import {
|
||
detectPageDataDatasetUsageFromHtml,
|
||
htmlUsesForbiddenLegacyPageDataApi,
|
||
htmlUsesPageDataApi,
|
||
inferPageDataBindAccessMode,
|
||
} from './page-data-html-detect.mjs';
|
||
import { createPageService } from './mindspace-pages.mjs';
|
||
import { ensurePageDataHtmlPagesBound } from './page-data-workspace-ensure.mjs';
|
||
import {
|
||
assessPageDataHtmlBinding,
|
||
assessWorkspacePageDataReadiness,
|
||
normalizePageDataApiBase,
|
||
verifyPageDataDeliveryArtifacts,
|
||
} from './page-data-delivery-assess.mjs';
|
||
import { listPageAccessPolicies } from './page-data-policy-store.mjs';
|
||
import { buildPublicUrl, resolvePageDataDeliveryBaseUrl } from './user-publish.mjs';
|
||
import { detectProhibitedBrowserStorage } from './mindspace-browser-storage-policy.mjs';
|
||
import { buildPageDataValidationRepairPrompt } from './page-data-validation-suggestions.mjs';
|
||
|
||
export { inferPageDataBindAccessMode };
|
||
|
||
const PUBLICATION_ROUTE_LINK_PATTERN =
|
||
/https?:\/\/[^\s<>"')\]]+\/u\/([0-9a-f-]{36}|[a-z0-9._-]+)\/pages\/([^\s<>"')\]]+)/gi;
|
||
|
||
const PAGE_DATA_CLIENT_SCRIPT_PATTERN = /\/assets\/page-data-client\.js/i;
|
||
const LEGACY_PAGE_DATA_OWNER_API_PATTERN = /['"`]\/api\/page-data(?:\/|['"`])/i;
|
||
const LOCAL_STORAGE_FALLBACK_HINT_PATTERN = /fallback\s*到\s*localStorage|localStorage\s*fallback/i;
|
||
|
||
const repairAttemptsBySession = new Map();
|
||
|
||
/**
|
||
* Explicit user choice wins over fallible text-intent classification. The UI
|
||
* records this on the original user message before any agent execution starts.
|
||
*/
|
||
function isPgRequiredByMessage(messages = []) {
|
||
return messages.some(
|
||
(message) => message?.role === 'user' && message?.metadata?.memindRun?.pgRequired === true,
|
||
);
|
||
}
|
||
|
||
function envFlag(value, fallback = false) {
|
||
const raw = String(value ?? '').trim().toLowerCase();
|
||
if (!raw) return fallback;
|
||
return ['1', 'true', 'yes', 'on'].includes(raw);
|
||
}
|
||
|
||
function readPublicHtmlFiles(publishDir) {
|
||
const publicDir = path.join(path.resolve(String(publishDir ?? '')), 'public');
|
||
if (!fs.existsSync(publicDir) || !fs.statSync(publicDir).isDirectory()) return [];
|
||
return fs
|
||
.readdirSync(publicDir)
|
||
.filter((name) => name.toLowerCase().endsWith('.html'))
|
||
.map((name) => {
|
||
const relativePath = `public/${name}`;
|
||
const absolutePath = path.join(publicDir, name);
|
||
const content = fs.readFileSync(absolutePath, 'utf8');
|
||
return { relativePath, absolutePath, content };
|
||
});
|
||
}
|
||
|
||
export function collectPageDataDeliveryArtifacts(
|
||
publishDir,
|
||
{ publicBaseUrl = resolvePageDataDeliveryBaseUrl() } = {},
|
||
) {
|
||
const ownerKey = path.basename(path.resolve(String(publishDir ?? '')));
|
||
if (!ownerKey) return [];
|
||
return collectPageDataPublicHtmlFiles(publishDir)
|
||
.map((file) => ({
|
||
localPath: file.absolutePath,
|
||
relativePath: file.relativePath,
|
||
isAdmin: /-admin\.html$/i.test(file.relativePath),
|
||
url: buildPublicUrl(publicBaseUrl, ownerKey, file.relativePath),
|
||
}));
|
||
}
|
||
|
||
function pickPublicationRouteReplacement(slug, artifacts, { adminUsed, surveyIndex }) {
|
||
const normalizedSlug = String(slug ?? '').toLowerCase();
|
||
const adminArtifact = artifacts.find((artifact) => artifact.isAdmin);
|
||
const surveyArtifacts = artifacts.filter((artifact) => !artifact.isAdmin);
|
||
if (adminArtifact && /admin/.test(normalizedSlug) && !adminUsed.value) {
|
||
adminUsed.value = true;
|
||
return adminArtifact.url;
|
||
}
|
||
if (surveyArtifacts[surveyIndex.value]) {
|
||
surveyIndex.value += 1;
|
||
return surveyArtifacts[surveyIndex.value - 1].url;
|
||
}
|
||
if (adminArtifact && !adminUsed.value) {
|
||
adminUsed.value = true;
|
||
return adminArtifact.url;
|
||
}
|
||
return artifacts[0]?.url ?? null;
|
||
}
|
||
|
||
/** 将 Agent 误发的 /u/用户名/pages/... 链接改写为 MindSpace 工作区 URL。 */
|
||
export function rewritePageDataDeliveryLinks(text, artifacts = []) {
|
||
const value = String(text ?? '');
|
||
if (!value || !Array.isArray(artifacts) || artifacts.length === 0) return value;
|
||
const adminUsed = { value: false };
|
||
const surveyIndex = { value: 0 };
|
||
return value.replace(PUBLICATION_ROUTE_LINK_PATTERN, (match, _owner, slug) => {
|
||
const replacement = pickPublicationRouteReplacement(slug, artifacts, { adminUsed, surveyIndex });
|
||
return replacement ?? match;
|
||
});
|
||
}
|
||
|
||
export function buildPageDataDeliveryArtifactsFromBindResult(autoBind, publishDir, options = {}) {
|
||
const boundUrls = new Map(
|
||
(autoBind?.bound ?? [])
|
||
.filter((item) => item?.relativePath && item?.workspaceUrl)
|
||
.map((item) => [item.relativePath, item.workspaceUrl]),
|
||
);
|
||
return collectPageDataDeliveryArtifacts(publishDir, options).map((artifact) => ({
|
||
...artifact,
|
||
url: boundUrls.get(artifact.relativePath) ?? artifact.url,
|
||
}));
|
||
}
|
||
|
||
export function evaluatePageDataHtmlContent(html, { relativePath = '' } = {}) {
|
||
const content = String(html ?? '');
|
||
const usage = detectPageDataDatasetUsageFromHtml(content);
|
||
const prohibitedBrowserStorage = detectProhibitedBrowserStorage(content, { relativePath });
|
||
const usesPageDataApi =
|
||
usage.size > 0 ||
|
||
PAGE_DATA_CLIENT_SCRIPT_PATTERN.test(content) ||
|
||
/\bMindSpacePageData\b/.test(content) ||
|
||
htmlUsesForbiddenLegacyPageDataApi(content) ||
|
||
LEGACY_PAGE_DATA_OWNER_API_PATTERN.test(content) ||
|
||
prohibitedBrowserStorage.length > 0 ||
|
||
LOCAL_STORAGE_FALLBACK_HINT_PATTERN.test(content);
|
||
|
||
if (!usesPageDataApi) {
|
||
return { usesPageDataApi: false, issues: [] };
|
||
}
|
||
|
||
const issues = [];
|
||
if (htmlUsesForbiddenLegacyPageDataApi(content)) {
|
||
issues.push('forbidden_legacy_page_data_api');
|
||
}
|
||
if (!PAGE_DATA_CLIENT_SCRIPT_PATTERN.test(content)) {
|
||
issues.push('missing_page_data_client_script');
|
||
}
|
||
if (LEGACY_PAGE_DATA_OWNER_API_PATTERN.test(content)) {
|
||
issues.push('forbidden_legacy_owner_api');
|
||
}
|
||
if (prohibitedBrowserStorage.includes('localStorage')) {
|
||
issues.push('forbidden_local_storage');
|
||
}
|
||
if (prohibitedBrowserStorage.some((api) => api !== 'localStorage')) {
|
||
issues.push('forbidden_browser_storage');
|
||
}
|
||
if (LOCAL_STORAGE_FALLBACK_HINT_PATTERN.test(content)) {
|
||
issues.push('forbidden_local_storage_fallback');
|
||
}
|
||
if (usage.size > 0 && !/\bMindSpacePageData\b/.test(content)) {
|
||
issues.push('missing_page_data_client_api');
|
||
}
|
||
if (relativePath && usage.size > 0) {
|
||
const hasInsert = [...usage.values()].some((item) => item.insert);
|
||
const hasRead = [...usage.values()].some((item) => item.read);
|
||
const looksAdmin = /(?:-admin|\/admin)\.html$/i.test(relativePath);
|
||
const looksNeutral = /(?:^|\/)index\.html$/i.test(relativePath);
|
||
if (looksAdmin && hasInsert && !hasRead) {
|
||
issues.push('admin_page_should_not_insert_only');
|
||
}
|
||
if (!looksAdmin && !looksNeutral && hasRead && !hasInsert) {
|
||
issues.push('survey_page_should_not_be_read_only');
|
||
}
|
||
}
|
||
|
||
return { usesPageDataApi: true, issues, usage };
|
||
}
|
||
|
||
export function collectPageDataPublicHtmlFiles(publishDir) {
|
||
return readPublicHtmlFiles(publishDir)
|
||
.map((file) => ({
|
||
...file,
|
||
evaluation: evaluatePageDataHtmlContent(file.content, { relativePath: file.relativePath }),
|
||
}))
|
||
.filter((file) => file.evaluation.usesPageDataApi);
|
||
}
|
||
|
||
function isPageDataHtmlWorkspaceReady({ publishDir, relativePath, html }) {
|
||
const usage = detectPageDataDatasetUsageFromHtml(html);
|
||
if (!usage.size) return true;
|
||
return Boolean(findWorkspacePolicyForPageDataHtml({ publishDir, relativePath, html }));
|
||
}
|
||
|
||
function findWorkspacePolicyForPageDataHtml({ publishDir, relativePath, html }) {
|
||
const usage = detectPageDataDatasetUsageFromHtml(html);
|
||
const expectedAccessMode = inferPageDataBindAccessMode(relativePath, html);
|
||
for (const policy of listPageAccessPolicies(publishDir)) {
|
||
if (String(policy?.accessMode ?? '') !== expectedAccessMode) continue;
|
||
if ([...usage.entries()].every(([name, perms]) => {
|
||
const granted = policy?.datasets?.[name];
|
||
return Boolean(granted && (!perms.read || granted.read) && (!perms.insert || granted.insert));
|
||
})) return policy;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function collectUnboundPageDataFiles({
|
||
relevantFiles,
|
||
publishDir,
|
||
pool = null,
|
||
userId = null,
|
||
findPageByRelativePath = null,
|
||
}) {
|
||
const unboundFiles = [];
|
||
for (const file of relevantFiles) {
|
||
if (file.evaluation.usage?.size === 0) continue;
|
||
if (pool && userId && typeof findPageByRelativePath === 'function') {
|
||
const assessment = await assessPageDataHtmlBinding({
|
||
pool,
|
||
userId,
|
||
publishDir,
|
||
relativePath: file.relativePath,
|
||
html: file.content,
|
||
findPageByRelativePath,
|
||
});
|
||
if (!assessment.bound) {
|
||
unboundFiles.push({
|
||
...file,
|
||
bindReasons: assessment.reasons,
|
||
});
|
||
}
|
||
continue;
|
||
}
|
||
const workspace = await assessWorkspacePageDataReadiness({
|
||
userId,
|
||
publishDir,
|
||
relativePath: file.relativePath,
|
||
html: file.content,
|
||
});
|
||
if (!workspace.ready) {
|
||
unboundFiles.push(file);
|
||
}
|
||
}
|
||
return unboundFiles;
|
||
}
|
||
|
||
export function usedPageDataCollectSkill(messages = []) {
|
||
return messages.some((message) =>
|
||
message?.content?.some((item) => {
|
||
if (item?.type !== 'toolRequest') return false;
|
||
const toolCall = item.toolCall?.value;
|
||
const name = String(toolCall?.name ?? '').trim();
|
||
const args = toolCall?.arguments ?? {};
|
||
const skillName = String(args.name ?? '').trim();
|
||
return name === 'load_skill' && skillName === 'page-data-collect';
|
||
}),
|
||
);
|
||
}
|
||
|
||
export function usedPageDataBindTool(messages = []) {
|
||
return messages.some((message) =>
|
||
message?.content?.some((item) => {
|
||
if (item?.type !== 'toolRequest') return false;
|
||
const name = String(item.toolCall?.value?.name ?? '').trim();
|
||
return name === 'private_data_bind_workspace_page';
|
||
}),
|
||
);
|
||
}
|
||
|
||
export function extractRecentPageDataHtmlWrites(messages = [], { sinceMs = 0 } = {}) {
|
||
const targets = new Set();
|
||
for (const message of Array.isArray(messages) ? messages : []) {
|
||
const createdAt = Number(message?.created ?? message?.createdAt ?? 0);
|
||
if (sinceMs > 0 && createdAt > 0 && createdAt < sinceMs) continue;
|
||
for (const item of message?.content ?? []) {
|
||
if (item?.type !== 'toolRequest') continue;
|
||
const toolCall = item.toolCall?.value;
|
||
const name = String(toolCall?.name ?? '').trim();
|
||
const normalizedName = name.split('__').at(-1);
|
||
if (!['write_file', 'edit_file', 'write', 'edit'].includes(normalizedName)) continue;
|
||
const args = toolCall?.arguments ?? {};
|
||
const candidate = String(args.path ?? args.file_path ?? '').trim().replace(/\\/g, '/');
|
||
if (!candidate.toLowerCase().endsWith('.html')) continue;
|
||
const normalized = candidate.startsWith('public/') ? candidate : path.posix.basename(candidate);
|
||
if (normalized.startsWith('public/')) targets.add(normalized);
|
||
}
|
||
}
|
||
return [...targets];
|
||
}
|
||
|
||
export function extractRecentPageDataBindTargets(messages = [], { sinceMs = 0 } = {}) {
|
||
const targets = new Set();
|
||
for (const message of Array.isArray(messages) ? messages : []) {
|
||
const createdAt = Number(message?.created ?? message?.createdAt ?? 0);
|
||
if (sinceMs > 0 && createdAt > 0 && createdAt < sinceMs) continue;
|
||
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 (name !== 'private_data_bind_workspace_page') continue;
|
||
const candidate = String(toolCall?.arguments?.relativePath ?? '')
|
||
.trim()
|
||
.replace(/\\/g, '/');
|
||
if (!candidate.toLowerCase().endsWith('.html')) continue;
|
||
targets.add(candidate.startsWith('public/') ? candidate : `public/${path.posix.basename(candidate)}`);
|
||
}
|
||
}
|
||
return [...targets];
|
||
}
|
||
|
||
function isStructuralPageDataHtmlFile(file) {
|
||
return Boolean(file?.evaluation?.usage?.size > 0 || file?.evaluation?.usesPageDataApi);
|
||
}
|
||
|
||
function resolvePageDataGuardAgentText({ agentText = '', messages = [] } = {}) {
|
||
const direct = String(agentText ?? '').trim();
|
||
if (isPageDataIntent(direct)) return direct;
|
||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||
const message = messages[i];
|
||
if (message?.role !== 'user') continue;
|
||
const text = Array.isArray(message?.content)
|
||
? message.content
|
||
.filter((item) => item?.type === 'text')
|
||
.map((item) => String(item.text ?? '').trim())
|
||
.filter(Boolean)
|
||
.join('\n')
|
||
: String(message?.content ?? '').trim();
|
||
if (isPageDataIntent(text)) return text;
|
||
}
|
||
return direct;
|
||
}
|
||
|
||
export function evaluatePageDataFinishGuard({
|
||
publishDir,
|
||
agentText = '',
|
||
messages = [],
|
||
requestStartedAt = 0,
|
||
} = {}) {
|
||
const resolvedAgentText = resolvePageDataGuardAgentText({ agentText, messages });
|
||
const pgRequired = isPgRequiredByMessage(messages);
|
||
const pageDataIntent = pgRequired || isPageDataIntent(resolvedAgentText);
|
||
const pageDataFiles = collectPageDataPublicHtmlFiles(publishDir);
|
||
const recentWrites = extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt });
|
||
const recentBinds = extractRecentPageDataBindTargets(messages, { sinceMs: requestStartedAt });
|
||
const relevantPaths = new Set([...recentWrites, ...recentBinds]);
|
||
// A reused session/workspace can contain many historical Page Data pages.
|
||
// Only files touched or explicitly bound in this request belong to this
|
||
// delivery. Scanning the whole workspace lets one stale localStorage page
|
||
// incorrectly block every later, correctly bound survey.
|
||
const relevantFiles = pageDataFiles.filter((file) => relevantPaths.has(file.relativePath));
|
||
const structuralFiles = relevantFiles.filter(isStructuralPageDataHtmlFile);
|
||
|
||
const htmlIssues = relevantFiles.flatMap((file) =>
|
||
file.evaluation.issues.map((issue) => ({
|
||
issue,
|
||
relativePath: file.relativePath,
|
||
})),
|
||
);
|
||
|
||
const unboundFiles = relevantFiles.filter(
|
||
(file) =>
|
||
file.evaluation.usage?.size > 0 &&
|
||
!isPageDataHtmlWorkspaceReady({
|
||
publishDir,
|
||
relativePath: file.relativePath,
|
||
html: file.content,
|
||
}),
|
||
);
|
||
|
||
const needsRepair =
|
||
(pageDataIntent || structuralFiles.length > 0) &&
|
||
(htmlIssues.length > 0 ||
|
||
unboundFiles.length > 0 ||
|
||
(relevantFiles.length === 0 &&
|
||
extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }).length === 0 &&
|
||
(usedPageDataCollectSkill(messages) || pgRequired)));
|
||
|
||
return {
|
||
pageDataIntent,
|
||
pgRequired,
|
||
structuralPageData: structuralFiles.length > 0,
|
||
relevantFiles,
|
||
htmlIssues,
|
||
unboundFiles,
|
||
needsRepair,
|
||
usedSkill: usedPageDataCollectSkill(messages),
|
||
usedBindTool: usedPageDataBindTool(messages),
|
||
};
|
||
}
|
||
|
||
export async function evaluatePageDataFinishGuardAsync({
|
||
publishDir,
|
||
agentText = '',
|
||
messages = [],
|
||
requestStartedAt = 0,
|
||
pool = null,
|
||
userId = null,
|
||
findPageByRelativePath = null,
|
||
} = {}) {
|
||
const base = evaluatePageDataFinishGuard({
|
||
publishDir,
|
||
agentText,
|
||
messages,
|
||
requestStartedAt,
|
||
});
|
||
const unboundFiles = await collectUnboundPageDataFiles({
|
||
relevantFiles: base.relevantFiles,
|
||
publishDir,
|
||
pool,
|
||
userId,
|
||
findPageByRelativePath,
|
||
});
|
||
const needsRepair =
|
||
(base.structuralPageData || base.pgRequired) &&
|
||
(base.htmlIssues.length > 0 ||
|
||
unboundFiles.length > 0 ||
|
||
(base.pageDataIntent &&
|
||
base.relevantFiles.length === 0 &&
|
||
extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }).length === 0 &&
|
||
(usedPageDataCollectSkill(messages) || base.pgRequired)));
|
||
|
||
return {
|
||
...base,
|
||
unboundFiles,
|
||
needsRepair,
|
||
};
|
||
}
|
||
|
||
export function buildPageDataCollectFailureText() {
|
||
return [
|
||
'这次问卷/数据收集页面没有完成 Page Data API 绑定,所以我先不发链接。',
|
||
'请直接重发一次完整需求(例如:调查问卷 + 后台查看),我会按 page-data-collect 技能:',
|
||
'建表 → 注册 dataset → 写含 page-data-client.js 的 HTML → private_data_bind_workspace_page 发布。',
|
||
'数据必须走平台 API(MindSpacePageData.createClient + /api/public/pages/...)写入用户私有数据空间,禁止 /api/page-data/ 旁路、localStorage、sessionStorage、IndexedDB 或自建后端。',
|
||
].join('');
|
||
}
|
||
|
||
export function buildPageDataCollectRepairPrompt({
|
||
htmlIssues = [],
|
||
unboundFiles = [],
|
||
} = {}) {
|
||
// Tier A suggestions are folded into the goosed repair turn so the agent
|
||
// receives actionable Chinese remediation, not only raw issue ids.
|
||
return buildPageDataValidationRepairPrompt({
|
||
htmlIssues,
|
||
unboundFiles,
|
||
message: '本轮用户已要求使用专属 PostgreSQL 数据空间,但 Page Data 页面交付尚未验证完成。',
|
||
});
|
||
}
|
||
|
||
export function shouldRetryPageDataCollectReply({
|
||
reply,
|
||
intent,
|
||
publishDir,
|
||
confirmedArtifacts = [],
|
||
requestStartedAt = 0,
|
||
}) {
|
||
const agentText = String(intent?.agentText ?? intent?.displayText ?? '').trim();
|
||
if (!isPageDataIntent(agentText)) return false;
|
||
|
||
const evaluation = evaluatePageDataFinishGuard({
|
||
publishDir,
|
||
agentText,
|
||
messages: reply?.messages ?? [],
|
||
requestStartedAt,
|
||
});
|
||
|
||
if (!evaluation.pageDataIntent) return false;
|
||
if (evaluation.htmlIssues.length > 0) return true;
|
||
if (evaluation.unboundFiles.length > 0) return true;
|
||
|
||
const wrotePageDataHtml = evaluation.relevantFiles.length > 0;
|
||
const wroteAnyHtml = confirmedArtifacts.length > 0 || extractRecentPageDataHtmlWrites(reply?.messages ?? [], { sinceMs: requestStartedAt }).length > 0;
|
||
if (wroteAnyHtml && !usedPageDataCollectSkill(reply?.messages ?? []) && wrotePageDataHtml) {
|
||
return true;
|
||
}
|
||
|
||
const replyClaimsDone = /(?:问卷|报名|后台|数据).*(?:已创建|已生成|已发布|可以提交|完成)/iu.test(String(reply?.text ?? ''));
|
||
if (replyClaimsDone && evaluation.unboundFiles.length > 0) return true;
|
||
|
||
return false;
|
||
}
|
||
|
||
export function resolvePageDataCollectOutcome({
|
||
reply,
|
||
intent,
|
||
publishDir,
|
||
requestStartedAt = 0,
|
||
}) {
|
||
const agentText = String(intent?.agentText ?? intent?.displayText ?? '').trim();
|
||
const evaluation = evaluatePageDataFinishGuard({
|
||
publishDir,
|
||
agentText,
|
||
messages: reply?.messages ?? [],
|
||
requestStartedAt,
|
||
});
|
||
if (!evaluation.structuralPageData && !evaluation.pageDataIntent) {
|
||
return { action: 'skip' };
|
||
}
|
||
|
||
if (evaluation.htmlIssues.length > 0) {
|
||
return { action: 'fail', failureText: buildPageDataCollectFailureText(), reason: 'invalid_html', evaluation };
|
||
}
|
||
if (evaluation.unboundFiles.length > 0) {
|
||
return { action: 'retry', reason: 'missing_bind', evaluation };
|
||
}
|
||
if (shouldRetryPageDataCollectReply({ reply, intent, publishDir, requestStartedAt })) {
|
||
return { action: 'retry', reason: 'incomplete_delivery', evaluation };
|
||
}
|
||
return { action: 'send', evaluation };
|
||
}
|
||
|
||
export async function resolvePageDataCollectOutcomeAsync({
|
||
reply,
|
||
intent,
|
||
publishDir,
|
||
requestStartedAt = 0,
|
||
pool = null,
|
||
userId = null,
|
||
findPageByRelativePath = null,
|
||
apiBase = null,
|
||
fetchImpl = fetch,
|
||
} = {}) {
|
||
const agentText = String(intent?.agentText ?? intent?.displayText ?? '').trim();
|
||
const evaluation = await evaluatePageDataFinishGuardAsync({
|
||
publishDir,
|
||
agentText,
|
||
messages: reply?.messages ?? [],
|
||
requestStartedAt,
|
||
pool,
|
||
userId,
|
||
findPageByRelativePath,
|
||
});
|
||
if (!evaluation.structuralPageData && !evaluation.pageDataIntent) {
|
||
return { action: 'skip' };
|
||
}
|
||
|
||
if (evaluation.htmlIssues.length > 0) {
|
||
return { action: 'fail', failureText: buildPageDataCollectFailureText(), reason: 'invalid_html', evaluation };
|
||
}
|
||
|
||
const deliveryArtifacts =
|
||
evaluation.relevantFiles.length > 0
|
||
? collectPageDataDeliveryArtifacts(publishDir).filter((artifact) =>
|
||
evaluation.relevantFiles.some((file) => file.relativePath === artifact.relativePath),
|
||
)
|
||
: [];
|
||
|
||
if (pool && userId && apiBase && deliveryArtifacts.length > 0) {
|
||
const deliveryFailures = await verifyPageDataDeliveryArtifacts({
|
||
artifacts: deliveryArtifacts,
|
||
publishDir,
|
||
apiBase,
|
||
pool,
|
||
userId,
|
||
findPageByRelativePath,
|
||
fetchImpl,
|
||
});
|
||
if (deliveryFailures.length === 0) {
|
||
const reassessment = await evaluatePageDataFinishGuardAsync({
|
||
publishDir,
|
||
agentText,
|
||
messages: reply?.messages ?? [],
|
||
requestStartedAt,
|
||
pool,
|
||
userId,
|
||
findPageByRelativePath,
|
||
});
|
||
if (reassessment.unboundFiles.length === 0 && reassessment.htmlIssues.length === 0) {
|
||
return { action: 'send', reason: 'verified_by_live_api', evaluation: reassessment };
|
||
}
|
||
} else if (evaluation.unboundFiles.length > 0) {
|
||
return {
|
||
action: 'retry',
|
||
reason: 'missing_bind',
|
||
evaluation,
|
||
deliveryFailures,
|
||
};
|
||
} else {
|
||
return {
|
||
action: 'retry',
|
||
reason: 'delivery_smoke_failed',
|
||
evaluation,
|
||
deliveryFailures,
|
||
};
|
||
}
|
||
}
|
||
|
||
if (evaluation.unboundFiles.length > 0) {
|
||
return { action: 'retry', reason: 'missing_bind', evaluation };
|
||
}
|
||
if (shouldRetryPageDataCollectReply({ reply, intent, publishDir, requestStartedAt })) {
|
||
return { action: 'retry', reason: 'incomplete_delivery', evaluation };
|
||
}
|
||
return { action: 'send', evaluation };
|
||
}
|
||
|
||
export function isPageDataFinishGuardEnabled(env = process.env) {
|
||
return envFlag(env.MEMIND_PAGE_DATA_FINISH_GUARD, true);
|
||
}
|
||
|
||
export function resetPageDataFinishGuardAttempts(sessionId = null) {
|
||
if (sessionId) repairAttemptsBySession.delete(String(sessionId));
|
||
else repairAttemptsBySession.clear();
|
||
}
|
||
|
||
export async function maybeAutoBindPageDataHtmlPages({
|
||
pool,
|
||
userId,
|
||
publishDir,
|
||
h5Root,
|
||
storageRoot,
|
||
onlyRelativePaths = null,
|
||
findPageByRelativePath = null,
|
||
} = {}) {
|
||
return ensurePageDataHtmlPagesBound({
|
||
pool,
|
||
h5Root,
|
||
storageRoot,
|
||
userId,
|
||
workspaceRoot: publishDir,
|
||
findPageByRelativePath,
|
||
onlyRelativePaths,
|
||
});
|
||
}
|
||
|
||
export async function ensurePageDataDeliveryReady({
|
||
publishDir,
|
||
userId,
|
||
pool,
|
||
h5Root,
|
||
storageRoot,
|
||
apiBase,
|
||
artifacts = [],
|
||
fetchImpl = fetch,
|
||
}) {
|
||
if (!pool || !userId) {
|
||
return { ok: false, failures: [{ stage: 'binding', reason: 'database_unconfigured' }] };
|
||
}
|
||
const pageService = createPageService(pool, { h5Root, storageRoot });
|
||
const failures = await verifyPageDataDeliveryArtifacts({
|
||
artifacts,
|
||
publishDir,
|
||
apiBase: normalizePageDataApiBase(apiBase),
|
||
pool,
|
||
userId,
|
||
findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService),
|
||
fetchImpl,
|
||
});
|
||
return { ok: failures.length === 0, failures };
|
||
}
|
||
|
||
export async function maybeRepairPageDataAfterFinish({
|
||
sessionId,
|
||
userId,
|
||
publishDir,
|
||
messages,
|
||
pool,
|
||
h5Root,
|
||
storageRoot,
|
||
tkmindProxy = null,
|
||
maxAttempts = 1,
|
||
env = process.env,
|
||
logger = console,
|
||
userText = '',
|
||
} = {}) {
|
||
const recentUserText = String(userText ?? '').trim();
|
||
const pageService = pool ? createPageService(pool, { h5Root, storageRoot }) : null;
|
||
const evaluation = await evaluatePageDataFinishGuardAsync({
|
||
publishDir,
|
||
agentText: resolvePageDataGuardAgentText({ agentText: recentUserText, messages }),
|
||
messages,
|
||
pool,
|
||
userId,
|
||
findPageByRelativePath: pageService?.findPageByRelativePath?.bind(pageService) ?? null,
|
||
});
|
||
|
||
if (!evaluation.structuralPageData && evaluation.relevantFiles.length === 0) {
|
||
resetPageDataFinishGuardAttempts(sessionId);
|
||
return { repaired: false, skipped: 'not_page_data', ...evaluation };
|
||
}
|
||
|
||
const autoBind = await maybeAutoBindPageDataHtmlPages({
|
||
pool,
|
||
userId,
|
||
publishDir,
|
||
h5Root,
|
||
storageRoot,
|
||
onlyRelativePaths: evaluation.relevantFiles.length
|
||
? evaluation.relevantFiles.map((file) => file.relativePath)
|
||
: null,
|
||
findPageByRelativePath: pageService?.findPageByRelativePath?.bind(pageService) ?? null,
|
||
});
|
||
|
||
const afterBind = await evaluatePageDataFinishGuardAsync({
|
||
publishDir,
|
||
agentText: resolvePageDataGuardAgentText({ agentText: recentUserText, messages }),
|
||
messages,
|
||
pool,
|
||
userId,
|
||
findPageByRelativePath: pageService?.findPageByRelativePath?.bind(pageService) ?? null,
|
||
});
|
||
|
||
if (!afterBind.needsRepair) {
|
||
resetPageDataFinishGuardAttempts(sessionId);
|
||
return { repaired: autoBind.bound.length > 0, skipped: 'ok', autoBind, ...afterBind };
|
||
}
|
||
|
||
if (!isPageDataFinishGuardEnabled(env)) {
|
||
logger.warn?.(
|
||
`[MindSpace][page-data-guard] incomplete page data delivery for session ${sessionId}: `
|
||
+ `issues=${afterBind.htmlIssues.map((item) => `${item.relativePath}:${item.issue}`).join(',') || 'none'} `
|
||
+ `unbound=${afterBind.unboundFiles.map((file) => file.relativePath).join(',') || 'none'}`,
|
||
);
|
||
return { repaired: false, skipped: 'disabled', autoBind, ...afterBind };
|
||
}
|
||
|
||
if (!tkmindProxy?.submitSessionReplyForUser) {
|
||
return { repaired: false, skipped: 'no_proxy', autoBind, ...afterBind };
|
||
}
|
||
|
||
const key = String(sessionId ?? '');
|
||
const attempts = repairAttemptsBySession.get(key) ?? 0;
|
||
if (!key || attempts >= maxAttempts) {
|
||
logger.warn?.(
|
||
`[MindSpace][page-data-guard] repair limit reached for session ${sessionId}`,
|
||
afterBind,
|
||
);
|
||
return { repaired: false, skipped: 'limit', attempts, autoBind, ...afterBind };
|
||
}
|
||
repairAttemptsBySession.set(key, attempts + 1);
|
||
|
||
const prompt = buildPageDataCollectRepairPrompt(afterBind);
|
||
// goosed validates request_id as a UUID. Keep the repair classification in
|
||
// message metadata rather than making the request id non-conformant.
|
||
const requestId = crypto.randomUUID();
|
||
const userMessage = {
|
||
role: 'user',
|
||
content: [{ type: 'text', text: prompt }],
|
||
metadata: {
|
||
displayText: '请补全 Page Data 问卷绑定与页面脚本',
|
||
userVisible: false,
|
||
agentVisible: true,
|
||
memindRun: { pageDataFinishRepair: true },
|
||
},
|
||
};
|
||
|
||
try {
|
||
await tkmindProxy.submitSessionReplyForUser(userId, sessionId, requestId, userMessage);
|
||
logger.info?.(
|
||
`[MindSpace][page-data-guard] triggered repair for session ${sessionId} `
|
||
+ `(attempt ${attempts + 1}/${maxAttempts})`,
|
||
);
|
||
return { repaired: true, triggered: true, attempts: attempts + 1, autoBind, ...afterBind };
|
||
} catch (err) {
|
||
logger.warn?.(
|
||
`[MindSpace][page-data-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,
|
||
autoBind,
|
||
...afterBind,
|
||
};
|
||
}
|
||
}
|