import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { isPageDataIntent } from './chat-skills.mjs'; import { detectPageDataDatasetUsageFromHtml, htmlUsesPageDataApi, inferPageDataBindAccessMode, } from './page-data-html-detect.mjs'; import { bindWorkspaceHtmlForPageData } from './page-data-workspace-bind.mjs'; import { createPageService } from './mindspace-pages.mjs'; import { assessPageDataHtmlBinding, assessWorkspacePageDataReadiness, verifyPageDataDeliveryArtifacts, } from './page-data-delivery-assess.mjs'; import { buildPublicUrl, resolvePublicBaseUrl } from './user-publish.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 LOCAL_STORAGE_DATA_PATTERN = /localStorage\.(?:getItem|setItem)\s*\(/i; const LOCAL_STORAGE_FALLBACK_HINT_PATTERN = /fallback\s*到\s*localStorage|localStorage\s*fallback/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 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 = resolvePublicBaseUrl() } = {}, ) { 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 usesPageDataApi = usage.size > 0 || PAGE_DATA_CLIENT_SCRIPT_PATTERN.test(content) || /\bMindSpacePageData\b/.test(content); if (!usesPageDataApi) { return { usesPageDataApi: false, issues: [] }; } const issues = []; if (!PAGE_DATA_CLIENT_SCRIPT_PATTERN.test(content)) { issues.push('missing_page_data_client_script'); } if (LOCAL_STORAGE_DATA_PATTERN.test(content)) { issues.push('forbidden_local_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\.html$/i.test(relativePath); if (looksAdmin && hasInsert && !hasRead) { issues.push('admin_page_should_not_insert_only'); } if (!looksAdmin && 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 }) { return assessWorkspacePageDataReadiness({ publishDir, relativePath, html }).ready; } 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; } if (!isPageDataHtmlWorkspaceReady({ publishDir, relativePath: file.relativePath, html: file.content, })) { 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(); if (!['write_file', 'edit_file', 'write', 'edit'].includes(name)) 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 evaluatePageDataFinishGuard({ publishDir, agentText = '', messages = [], requestStartedAt = 0, } = {}) { const pageDataIntent = isPageDataIntent(agentText); const pageDataFiles = collectPageDataPublicHtmlFiles(publishDir); const recentWrites = extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }); const relevantFiles = pageDataFiles.filter((file) => pageDataIntent || recentWrites.includes(file.relativePath), ); 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 && (htmlIssues.length > 0 || unboundFiles.length > 0 || (relevantFiles.length === 0 && extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }).length === 0 && usedPageDataCollectSkill(messages))); return { pageDataIntent, 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.pageDataIntent && (base.htmlIssues.length > 0 || unboundFiles.length > 0 || (base.relevantFiles.length === 0 && extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }).length === 0 && usedPageDataCollectSkill(messages))); 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 写入 SQLite,禁止 localStorage 或自建后端。', ].join(''); } export function buildPageDataCollectRepairPrompt({ htmlIssues = [], unboundFiles = [], } = {}) { const lines = [ '【系统补绑请求】检测到 Page Data 问卷/数据页交付不完整。请立即按 page-data-collect 技能修复:', '1. load_skill → page-data-collect', '2. 确保 public/*.html 引入 /assets/page-data-client.js,且 JS 使用 MindSpacePageData.createClient({ apiBase: "/api" })', '3. 禁止 localStorage / 浏览器本地存储 fallback', '4. 对每个 public/*.html 调用 private_data_bind_workspace_page(问卷页 public insert,后台页 password read,口令默认 88888888)', ]; if (htmlIssues.length) { lines.push('', 'HTML 问题:'); for (const item of htmlIssues) { lines.push(`- ${item.relativePath}: ${item.issue}`); } } if (unboundFiles.length) { lines.push('', '尚未 bind 的 Page Data 页面:'); for (const file of unboundFiles) { const reasons = Array.isArray(file.bindReasons) && file.bindReasons.length ? ` (${file.bindReasons.join(', ')})` : ''; lines.push(`- ${file.relativePath}${reasons}`); } } lines.push('', '修复完成前不要告诉用户“已发布/已可提交”。'); return lines.join('\n'); } 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(); if (!isPageDataIntent(agentText)) { return { action: 'skip' }; } const evaluation = evaluatePageDataFinishGuard({ publishDir, agentText, messages: reply?.messages ?? [], requestStartedAt, }); 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, } = {}) { const agentText = String(intent?.agentText ?? intent?.displayText ?? '').trim(); if (!isPageDataIntent(agentText)) { return { action: 'skip' }; } const evaluation = await evaluatePageDataFinishGuardAsync({ publishDir, agentText, messages: reply?.messages ?? [], requestStartedAt, pool, userId, findPageByRelativePath, }); 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 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, } = {}) { if (!pool) { return { bound: [], skipped: [], errors: [{ code: 'database_unconfigured' }] }; } const pageLookup = typeof findPageByRelativePath === 'function' ? findPageByRelativePath : createPageService(pool, { h5Root, storageRoot }).findPageByRelativePath; const bound = []; const skipped = []; const errors = []; const allowList = onlyRelativePaths ? new Set(onlyRelativePaths) : null; for (const file of collectPageDataPublicHtmlFiles(publishDir)) { if (allowList && !allowList.has(file.relativePath)) continue; if (file.evaluation.issues.length > 0) { skipped.push({ relativePath: file.relativePath, reason: 'invalid_html', issues: file.evaluation.issues }); continue; } if (file.evaluation.usage?.size === 0) { skipped.push({ relativePath: file.relativePath, reason: 'no_dataset_usage' }); continue; } const assessment = await assessPageDataHtmlBinding({ pool, userId, publishDir, relativePath: file.relativePath, html: file.content, findPageByRelativePath: pageLookup, }); if (assessment.bound) { skipped.push({ relativePath: file.relativePath, reason: 'already_bound' }); continue; } try { const accessMode = inferPageDataBindAccessMode(file.relativePath, file.content); const result = await bindWorkspaceHtmlForPageData({ pool, h5Root, storageRoot, userId, workspaceRoot: publishDir, relativePath: file.relativePath, accessMode, password: accessMode === 'password' ? '88888888' : null, }); bound.push({ relativePath: file.relativePath, pageId: result.pageId, workspaceUrl: result.workspaceUrl }); } catch (err) { errors.push({ relativePath: file.relativePath, message: err instanceof Error ? err.message : String(err), code: err?.code ?? 'bind_failed', }); } } return { bound, skipped, errors }; } 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, 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: recentUserText, messages, pool, userId, findPageByRelativePath: pageService?.findPageByRelativePath?.bind(pageService) ?? null, }); if (!evaluation.pageDataIntent && 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.map((file) => file.relativePath), findPageByRelativePath: pageService?.findPageByRelativePath?.bind(pageService) ?? null, }); const afterBind = await evaluatePageDataFinishGuardAsync({ publishDir, agentText: recentUserText, 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, }; } }