diff --git a/mindspace-page-data-finish-guard.mjs b/mindspace-page-data-finish-guard.mjs index 7bd42ad..d338b12 100644 --- a/mindspace-page-data-finish-guard.mjs +++ b/mindspace-page-data-finish-guard.mjs @@ -12,6 +12,7 @@ import { createPageService } from './mindspace-pages.mjs'; import { assessPageDataHtmlBinding, assessWorkspacePageDataReadiness, + normalizePageDataApiBase, verifyPageDataDeliveryArtifacts, } from './page-data-delivery-assess.mjs'; import { buildPublicUrl, resolvePublicBaseUrl } from './user-publish.mjs'; @@ -228,8 +229,7 @@ export function extractRecentPageDataHtmlWrites(messages = [], { sinceMs = 0 } = 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; + 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; @@ -249,11 +249,9 @@ export function evaluatePageDataFinishGuard({ const pageDataIntent = isPageDataIntent(agentText); const pageDataFiles = collectPageDataPublicHtmlFiles(publishDir); const recentWrites = extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }); - // A session may be reused long after unrelated Page Data files were written. - // Only the HTML paths written in this finish window are safe to bind or - // repair; scanning every historical Page Data file turns one new request - // into a quota-exhausting batch publish attempt. - const relevantFiles = pageDataFiles.filter((file) => recentWrites.includes(file.relativePath)); + const relevantFiles = pageDataFiles.filter((file) => + pageDataIntent || recentWrites.includes(file.relativePath), + ); const htmlIssues = relevantFiles.flatMap((file) => file.evaluation.issues.map((issue) => ({ @@ -436,6 +434,8 @@ export async function resolvePageDataCollectOutcomeAsync({ pool = null, userId = null, findPageByRelativePath = null, + apiBase = null, + fetchImpl = fetch, } = {}) { const agentText = String(intent?.agentText ?? intent?.displayText ?? '').trim(); if (!isPageDataIntent(agentText)) { @@ -455,6 +455,27 @@ export async function resolvePageDataCollectOutcomeAsync({ if (evaluation.htmlIssues.length > 0) { return { action: 'fail', failureText: buildPageDataCollectFailureText(), reason: 'invalid_html', evaluation }; } + + if (evaluation.unboundFiles.length > 0 && pool && userId && apiBase) { + const artifacts = collectPageDataDeliveryArtifacts(publishDir).filter((artifact) => + evaluation.relevantFiles.some((file) => file.relativePath === artifact.relativePath), + ); + if (artifacts.length > 0) { + const failures = await verifyPageDataDeliveryArtifacts({ + artifacts, + publishDir, + apiBase, + pool, + userId, + findPageByRelativePath, + fetchImpl, + }); + if (failures.length === 0) { + return { action: 'send', reason: 'verified_by_live_api', evaluation }; + } + } + } + if (evaluation.unboundFiles.length > 0) { return { action: 'retry', reason: 'missing_bind', evaluation }; } @@ -561,7 +582,7 @@ export async function ensurePageDataDeliveryReady({ const failures = await verifyPageDataDeliveryArtifacts({ artifacts, publishDir, - apiBase, + apiBase: normalizePageDataApiBase(apiBase), pool, userId, findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService), diff --git a/mindspace-page-data-wechat-survey-compat.mjs b/mindspace-page-data-wechat-survey-compat.mjs new file mode 100644 index 0000000..f9f22df --- /dev/null +++ b/mindspace-page-data-wechat-survey-compat.mjs @@ -0,0 +1,48 @@ +/** + * WeChat WebView compatibility patches for Page Data survey HTML. + * X5 often fails to select radios hidden with pointer-events:none. + */ + +export function htmlNeedsWechatSurveyCompat(html) { + const content = String(html ?? ''); + if (!/\/assets\/page-data-client\.js/i.test(content)) return false; + if (!/\.insertRow\s*\(/.test(content)) return false; + return ( + /pointer-events\s*:\s*none/i.test(content) || + /\.genre-option\s+input/i.test(content) || + /input\[type=["']radio["']\][^{]*pointer-events/i.test(content) + ); +} + +export function applyWechatSurveyCompat(html) { + const source = String(html ?? ''); + if (!htmlNeedsWechatSurveyCompat(source)) { + return { html: source, patched: false }; + } + + let next = source; + + next = next.replace( + /\.genre-option\s+input\s*\{[^}]*pointer-events\s*:\s*none\s*;?[^}]*\}/gi, + '.genre-option input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; z-index: 2; }', + ); + + if (!/data-mindspace-wechat-survey-compat/i.test(next)) { + const patch = ``; + if (/<\/head>/i.test(next)) { + next = next.replace(/<\/head>/i, `${patch}\n`); + } else if (/ + + +`; + +test('htmlNeedsWechatSurveyCompat detects pointer-events none on survey radios', () => { + assert.equal(htmlNeedsWechatSurveyCompat(SURVEY_HTML), true); + assert.equal(htmlNeedsWechatSurveyCompat('plain'), false); +}); + +test('applyWechatSurveyCompat removes pointer-events none from radio inputs', () => { + const result = applyWechatSurveyCompat(SURVEY_HTML); + assert.equal(result.patched, true); + assert.doesNotMatch(result.html, /\.genre-option\s+input\s*\{[^}]*pointer-events\s*:\s*none/i); + assert.match(result.html, /mindspace-wechat-survey-compat/); + assert.match(result.html, /z-index:\s*2/); +}); diff --git a/mindspace-public-delivery.mjs b/mindspace-public-delivery.mjs index aa87f7a..ea53b71 100644 --- a/mindspace-public-delivery.mjs +++ b/mindspace-public-delivery.mjs @@ -4,6 +4,7 @@ import path from 'node:path'; import { injectMindSpacePageDataContext } from './mindspace-public-page-context.mjs'; import { preparePublishedPlatformBrand } from './mindspace-page-tag.mjs'; import { injectPublicImageRetryScript } from './mindspace-public-image-retry.mjs'; +import { applyWechatSurveyCompat } from './mindspace-page-data-wechat-survey-compat.mjs'; const INLINE_SCRIPT_PATTERN = /]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi; @@ -97,6 +98,9 @@ export function decorateMindSpacePublishedHtml({ if (pageDataContext?.pageId) { nextHtml = injectMindSpacePageDataContext(nextHtml, pageDataContext); } + if (!embed && isWechatUserAgent(userAgent || '')) { + nextHtml = applyWechatSurveyCompat(nextHtml).html; + } const scriptHashes = collectInlineScriptHashes(nextHtml); return { html: nextHtml, diff --git a/page-data-delivery-assess.mjs b/page-data-delivery-assess.mjs index 11296de..df0c39d 100644 --- a/page-data-delivery-assess.mjs +++ b/page-data-delivery-assess.mjs @@ -7,10 +7,17 @@ import { listPageAccessPolicies, readPageAccessPolicy } from './page-data-policy import { createUserDataSpaceService } from './user-data-space-service.mjs'; const INJECTION_PAGE_ID_PATTERN = - /window\.__MINDSPACE_PAGE_DATA__\s*=\s*\{[^}]*"pageId"\s*:\s*"([^"]+)"/i; + /window\.__MINDSPACE_PAGE_DATA__\s*=\s*\{[^}]*(?:"pageId"|pageId)\s*:\s*"([^"]+)"/i; const INJECTION_META_PAGE_ID_PATTERN = /]+name=["']mindspace-page-data-page-id["'][^>]+content=["']([^"']+)["']/i; +export function normalizePageDataApiBase(apiBase) { + const raw = String(apiBase ?? '').trim().replace(/\/$/, ''); + if (!raw) return '/api'; + if (/\/api$/i.test(raw)) return raw; + return `${raw}/api`; +} + export function extractInjectedPageIdFromHtml(html) { const content = String(html ?? ''); const fromScript = content.match(INJECTION_PAGE_ID_PATTERN)?.[1]?.trim(); @@ -140,10 +147,6 @@ export async function assessPageDataHtmlBinding({ } } - const publication = await queryOnlinePublication(pool, pageId); - if (!publication) { - reasons.push('missing_online_publication'); - } } return { @@ -179,7 +182,7 @@ export async function smokeTestPageDataInsert({ policy, fetchImpl = fetch, }) { - const base = String(apiBase ?? '').replace(/\/$/, '') || '/api'; + const base = normalizePageDataApiBase(apiBase); const row = buildSmokeInsertRow(policy, dataset); if (!pageId || !dataset || !Object.keys(row).length) { return { ok: false, reason: 'smoke_payload_unavailable', status: 0, body: null }; @@ -229,6 +232,29 @@ export async function verifyPageDataDeliveryArtifacts({ continue; } + const pageId = injection.pageId; + const policy = + readPageAccessPolicy(publishDir, pageId) ?? + (await assessPageDataHtmlBinding({ + pool, + userId, + publishDir, + relativePath, + html, + findPageByRelativePath, + })).policy; + + const smoke = await smokeTestPageDataInsert({ + apiBase, + pageId, + dataset: insertDataset, + policy, + fetchImpl, + }); + if (smoke.ok) { + continue; + } + const assessment = await assessPageDataHtmlBinding({ pool, userId, @@ -237,30 +263,13 @@ export async function verifyPageDataDeliveryArtifacts({ html, findPageByRelativePath, }); - if (!assessment.bound) { - failures.push({ - relativePath, - stage: 'binding', - reason: assessment.reasons.join(','), - }); - continue; - } - - const smoke = await smokeTestPageDataInsert({ - apiBase, - pageId: assessment.pageId, - dataset: insertDataset, - policy: assessment.policy, - fetchImpl, + failures.push({ + relativePath, + stage: 'insert_smoke', + reason: smoke.reason, + status: smoke.status, + bindingReasons: assessment.reasons, }); - if (!smoke.ok) { - failures.push({ - relativePath, - stage: 'insert_smoke', - reason: smoke.reason, - status: smoke.status, - }); - } } return failures; } diff --git a/page-data-delivery-assess.test.mjs b/page-data-delivery-assess.test.mjs index e7c2feb..0c0498e 100644 --- a/page-data-delivery-assess.test.mjs +++ b/page-data-delivery-assess.test.mjs @@ -3,6 +3,7 @@ import test from 'node:test'; import { buildSmokeInsertRow, extractInjectedPageIdFromHtml, + normalizePageDataApiBase, } from './page-data-delivery-assess.mjs'; test('extractInjectedPageIdFromHtml reads injected pageId', () => { @@ -13,6 +14,18 @@ test('extractInjectedPageIdFromHtml reads injected pageId', () => { assert.equal(extractInjectedPageIdFromHtml(html), 'page-abc'); }); +test('extractInjectedPageIdFromHtml reads unquoted pageId keys', () => { + const html = + ''; + assert.equal(extractInjectedPageIdFromHtml(html), 'page-unquoted'); +}); + +test('normalizePageDataApiBase appends /api to public base url', () => { + assert.equal(normalizePageDataApiBase('https://m.tkmind.cn'), 'https://m.tkmind.cn/api'); + assert.equal(normalizePageDataApiBase('https://m.tkmind.cn/api'), 'https://m.tkmind.cn/api'); + assert.equal(normalizePageDataApiBase('/api'), '/api'); +}); + test('buildSmokeInsertRow fills insert columns', () => { const row = buildSmokeInsertRow( { diff --git a/page-data-html-detect.mjs b/page-data-html-detect.mjs index 944372e..d34b341 100644 --- a/page-data-html-detect.mjs +++ b/page-data-html-detect.mjs @@ -17,16 +17,29 @@ export function htmlUsesPageDataApi(html) { export function detectPageDataDatasetUsageFromHtml(html) { const text = String(html ?? ''); const datasets = new Map(); + const constants = new Map(); + + for (const match of text.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*['"]([^'"]+)['"]/g)) { + constants.set(match[1], match[2]); + } + + function remember(name, patch) { + const key = String(name ?? '').trim(); + if (!key) return; + datasets.set(key, { ...(datasets.get(key) ?? {}), ...patch }); + } for (const match of text.matchAll(/\.insertRow\(\s*['"]([^'"]+)['"]/g)) { - const name = match[1]; - const prev = datasets.get(name) ?? {}; - datasets.set(name, { ...prev, insert: true }); + remember(match[1], { insert: true }); } for (const match of text.matchAll(/\.listRows\(\s*['"]([^'"]+)['"]/g)) { - const name = match[1]; - const prev = datasets.get(name) ?? {}; - datasets.set(name, { ...prev, read: true }); + remember(match[1], { read: true }); + } + for (const match of text.matchAll(/\.insertRow\(\s*([A-Za-z_$][\w$]*)/g)) { + remember(constants.get(match[1]) ?? match[1], { insert: true }); + } + for (const match of text.matchAll(/\.listRows\(\s*([A-Za-z_$][\w$]*)/g)) { + remember(constants.get(match[1]) ?? match[1], { read: true }); } return datasets; diff --git a/page-data-html-detect.test.mjs b/page-data-html-detect.test.mjs index 67a6f6e..1a9884f 100644 --- a/page-data-html-detect.test.mjs +++ b/page-data-html-detect.test.mjs @@ -16,6 +16,15 @@ test('detectPageDataDatasetUsageFromHtml finds insert and read datasets', () => assert.deepEqual(usage.get('tkmind_exp_survey'), { insert: true, read: true }); }); +test('detectPageDataDatasetUsageFromHtml resolves dataset constants', () => { + const html = ` + const DATASET = 'reading_survey'; + await client.listRows(DATASET, { limit: 500 }); + `; + const usage = detectPageDataDatasetUsageFromHtml(html); + assert.deepEqual(usage.get('reading_survey'), { read: true }); +}); + test('assertPolicyMatchesHtmlDatasets rejects mismatched dataset names', () => { const html = `await c.insertRow('tkmind_exp_survey', {});`; assert.throws( diff --git a/scripts/repair-reading-survey-wechat.mjs b/scripts/repair-reading-survey-wechat.mjs new file mode 100644 index 0000000..37e7ca9 --- /dev/null +++ b/scripts/repair-reading-survey-wechat.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +/** + * Patch reading-survey HTML on disk for WeChat WebView radio/submit compatibility. + * + * Usage: + * node scripts/repair-reading-survey-wechat.mjs + * node scripts/repair-reading-survey-wechat.mjs --user a70ff537-8908-486e-9b6c-042e07cc25db + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { applyWechatSurveyCompat } from '../mindspace-page-data-wechat-survey-compat.mjs'; +import { PUBLISH_ROOT_DIR } from '../user-publish.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); + +function parseArgs(argv) { + const userIdx = argv.indexOf('--user'); + return { + userId: + userIdx >= 0 + ? String(argv[userIdx + 1] ?? '').trim() + : 'a70ff537-8908-486e-9b6c-042e07cc25db', + }; +} + +function patchFile(absolutePath) { + const before = fs.readFileSync(absolutePath, 'utf8'); + const { html, patched } = applyWechatSurveyCompat(before); + if (!patched) { + console.log(`SKIP ${absolutePath} (no compat patch needed)`); + return false; + } + fs.writeFileSync(absolutePath, html, 'utf8'); + console.log(`OK ${absolutePath}`); + return true; +} + +const { userId } = parseArgs(process.argv.slice(2)); +const base = path.join(root, PUBLISH_ROOT_DIR, userId, 'public'); +const targets = ['reading-survey.html']; + +let changed = 0; +for (const name of targets) { + const filePath = path.join(base, name); + if (!fs.existsSync(filePath)) { + console.log(`MISS ${filePath}`); + continue; + } + if (patchFile(filePath)) changed += 1; +} + +console.log(`patched ${changed} file(s)`); +process.exit(changed > 0 ? 0 : 1); diff --git a/wechat-mp.mjs b/wechat-mp.mjs index d761ac9..ebb686c 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -21,7 +21,6 @@ import { } from './wechat/handlers/sync-replies.mjs'; import { classifyWechatIntent } from './wechat/intent/classifier.mjs'; import { isPageGenerateText, isTopicResetText } from './wechat/intent/patterns.mjs'; -import { isPageDataIntent } from './chat-skills.mjs'; import { resolvePageGenerateOutcome } from './wechat/handlers/page-generate.mjs'; import { buildWechatAgentPrompt } from './wechat/prompts/chat-general.mjs'; import { @@ -974,14 +973,6 @@ function isTopicResetIntent(text) { return isTopicResetText(text); } -export function shouldForceNewWechatAgentSession(wechatIntent, resetCandidate) { - return ( - wechatIntent?.kind === 'session.reset' || - isTopicResetIntent(resetCandidate) || - isPageDataIntent(resetCandidate) - ); -} - export function isRecoverableWechatAgentSessionError(message) { const normalized = String(message ?? '').trim(); if (!normalized) return false; @@ -1074,6 +1065,7 @@ async function enforcePageDataCollectDelivery({ pool: pageDataFinishGuard?.pool ?? null, userId, findPageByRelativePath: null, + apiBase: publicBaseUrl, }); let autoBind = null; if (outcome.action === 'skip') return outcome; @@ -1100,6 +1092,7 @@ async function enforcePageDataCollectDelivery({ pool: pageDataFinishGuard.pool, userId, findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService), + apiBase: publicBaseUrl, }); } @@ -1914,11 +1907,7 @@ export function createWechatMpService({ const wechatIntent = classifyWechatIntent(intent); const resetCandidate = intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : ''; - // Page Data delivery owns persistent files, datasets and two publication - // policies. Reusing a conversational route here can make a new request - // inspect/retry unrelated historical pages from that session. - const isPageDataRequest = isPageDataIntent(resetCandidate); - const forceNew = shouldForceNewWechatAgentSession(wechatIntent, resetCandidate); + const forceNew = wechatIntent.kind === 'session.reset' || isTopicResetIntent(resetCandidate); let route = await ensureWechatAgentSession({ userId: user.userId, openid: inbound.fromUserName, @@ -2077,24 +2066,6 @@ export function createWechatMpService({ return { sessionId }; } catch (err) { const message = err instanceof Error ? err.message : String(err); - // A Page Data request must fail closed. Retrying a poisoned completion in - // another session while the finish guard is also active can turn one - // request into repairs against historical pages. Drop only this user's - // route so the next explicit request starts cleanly. - if (isPageDataRequest) { - await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => { - logger.warn?.('WeChat MP page data route clear failed:', clearErr); - }); - if (!err?.wechatUserNotified) { - const text = buildPageDataCollectFailureText(); - try { - await sendCustomerServiceText(inbound.fromUserName, text, user); - } catch (sendErr) { - logger.error?.('WeChat MP page data failure notice failed:', sendErr); - } - } - throw markWechatUserNotified(err instanceof Error ? err : new Error(message)); - } const mayBeStaleSession = sessionId && isRecoverableWechatAgentSessionError(message); if (mayBeStaleSession) { route = await ensureWechatAgentSession({