diff --git a/mindspace-page-data-finish-guard.mjs b/mindspace-page-data-finish-guard.mjs index 6ab9f3e..5ca7bca 100644 --- a/mindspace-page-data-finish-guard.mjs +++ b/mindspace-page-data-finish-guard.mjs @@ -110,18 +110,20 @@ export function rewritePageDataDeliveryLinks(text, artifacts = []) { function collectPageDataDeliveryRelativePaths(autoBind, relevantRelativePaths = null) { const deliveryPaths = new Set(); - for (const item of autoBind?.bound ?? []) { - if (item?.relativePath) deliveryPaths.add(item.relativePath); - } + const scope = Array.isArray(relevantRelativePaths) && relevantRelativePaths.length > 0 + ? new Set(relevantRelativePaths) + : null; + const include = (relativePath) => { + if (!relativePath) return; + if (scope && !scope.has(relativePath)) return; + deliveryPaths.add(relativePath); + }; + for (const item of autoBind?.bound ?? []) include(item.relativePath); for (const item of autoBind?.skipped ?? []) { - if (item?.reason === 'already_bound' && item?.relativePath) { - deliveryPaths.add(item.relativePath); - } + if (item?.reason === 'already_bound') include(item.relativePath); } - if (Array.isArray(relevantRelativePaths)) { - for (const relativePath of relevantRelativePaths) { - if (relativePath) deliveryPaths.add(relativePath); - } + if (scope) { + for (const relativePath of scope) include(relativePath); } return deliveryPaths; } @@ -339,6 +341,43 @@ export function extractRecentPageDataBindTargets(messages = [], { sinceMs = 0 } return [...targets]; } +const PAGE_DATA_WORKSPACE_MTIME_LOOKBACK_MS = 2000; + +export function resolvePageDataRequestRelativePaths({ + publishDir, + messages = [], + requestStartedAt = 0, +} = {}) { + const relevantPaths = new Set([ + ...extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }), + ...extractRecentPageDataBindTargets(messages, { sinceMs: requestStartedAt }), + ]); + if (relevantPaths.size > 0 || requestStartedAt <= 0) { + return [...relevantPaths]; + } + const publicDir = path.join(path.resolve(String(publishDir ?? '')), 'public'); + if (!fs.existsSync(publicDir) || !fs.statSync(publicDir).isDirectory()) { + return []; + } + for (const name of fs.readdirSync(publicDir)) { + if (!name.toLowerCase().endsWith('.html')) continue; + const relativePath = `public/${name}`; + const absolutePath = path.join(publicDir, name); + let mtimeMs = 0; + try { + mtimeMs = fs.statSync(absolutePath).mtimeMs; + } catch { + continue; + } + if (mtimeMs < requestStartedAt - PAGE_DATA_WORKSPACE_MTIME_LOOKBACK_MS) continue; + const content = fs.readFileSync(absolutePath, 'utf8'); + if (htmlUsesPageDataApi(content)) { + relevantPaths.add(relativePath); + } + } + return [...relevantPaths]; +} + function isStructuralPageDataHtmlFile(file) { return Boolean(file?.evaluation?.usage?.size > 0 || file?.evaluation?.usesPageDataApi); } @@ -371,9 +410,11 @@ export function evaluatePageDataFinishGuard({ 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]); + const relevantPaths = new Set(resolvePageDataRequestRelativePaths({ + publishDir, + messages, + requestStartedAt, + })); // 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 diff --git a/mindspace-page-data-finish-guard.test.mjs b/mindspace-page-data-finish-guard.test.mjs index 2b1a9f2..ae6188b 100644 --- a/mindspace-page-data-finish-guard.test.mjs +++ b/mindspace-page-data-finish-guard.test.mjs @@ -15,6 +15,7 @@ import { extractRecentPageDataHtmlWrites, inferPageDataBindAccessMode, maybeAutoBindPageDataHtmlPages, + resolvePageDataRequestRelativePaths, rewritePageDataDeliveryLinks, shouldRetryPageDataCollectReply, } from './mindspace-page-data-finish-guard.mjs'; @@ -381,3 +382,62 @@ test('rewritePageDataDeliveryLinks rewrites publication route urls to MindSpace fs.rmSync(publishDir, { recursive: true, force: true }); } }); + +test('resolvePageDataRequestRelativePaths falls back to recent workspace html when tool calls are missing', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-mtime-')); + try { + fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true }); + const requestStartedAt = Date.now(); + const historicalPath = path.join(publishDir, 'public', 'historical-survey.html'); + const freshPath = path.join(publishDir, 'public', 'fresh-survey.html'); + fs.writeFileSync(historicalPath, SURVEY_HTML, 'utf8'); + fs.utimesSync( + historicalPath, + new Date(requestStartedAt - 60_000), + new Date(requestStartedAt - 60_000), + ); + fs.writeFileSync(freshPath, SURVEY_HTML, 'utf8'); + assert.deepEqual( + resolvePageDataRequestRelativePaths({ + publishDir, + messages: [], + requestStartedAt, + }), + ['public/fresh-survey.html'], + ); + } finally { + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); + +test('buildPageDataDeliveryArtifactsFromBindResult scopes already_bound pages to this request', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-scope-')); + const previousBase = process.env.H5_PUBLIC_BASE_URL; + process.env.H5_PUBLIC_BASE_URL = 'https://m.tkmind.cn'; + try { + fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true }); + fs.writeFileSync(path.join(publishDir, 'public', 'current-survey.html'), SURVEY_HTML, 'utf8'); + fs.writeFileSync(path.join(publishDir, 'public', 'historical-survey.html'), SURVEY_HTML, 'utf8'); + const autoBind = { + bound: [], + skipped: [ + { relativePath: 'public/current-survey.html', reason: 'already_bound' }, + { relativePath: 'public/historical-survey.html', reason: 'already_bound' }, + ], + }; + const artifacts = buildPageDataDeliveryArtifactsFromBindResult( + autoBind, + publishDir, + {}, + ['public/current-survey.html'], + ); + assert.deepEqual( + artifacts.map((artifact) => artifact.relativePath), + ['public/current-survey.html'], + ); + } finally { + if (previousBase == null) delete process.env.H5_PUBLIC_BASE_URL; + else process.env.H5_PUBLIC_BASE_URL = previousBase; + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); diff --git a/mindspace-public-finish-service.mjs b/mindspace-public-finish-service.mjs index 0f7407c..045b155 100644 --- a/mindspace-public-finish-service.mjs +++ b/mindspace-public-finish-service.mjs @@ -16,6 +16,7 @@ import { maybeAutoBindPageDataHtmlPages, preparePageDataAfterFinish, resolvePageDataCollectOutcomeAsync, + resolvePageDataRequestRelativePaths, rewritePageDataDeliveryLinks, } from './mindspace-page-data-finish-guard.mjs'; import { @@ -253,6 +254,11 @@ export function createMindSpacePublicFinishService({ intent?.displayText ?? '', ), }; + const scopedRelativePaths = resolvePageDataRequestRelativePaths({ + publishDir, + messages: normalizedReply.messages, + requestStartedAt, + }); let outcome = await resolvePageDataCollectOutcomeAsyncFn({ reply: normalizedReply, @@ -283,9 +289,14 @@ export function createMindSpacePublicFinishService({ pageService.findPageByRelativePath.bind( pageService, ); - const relevantRelativePaths = (outcome?.evaluation?.relevantFiles ?? []) - .map((file) => file?.relativePath) - .filter(Boolean); + const relevantRelativePaths = [ + ...new Set([ + ...scopedRelativePaths, + ...(outcome?.evaluation?.relevantFiles ?? []) + .map((file) => file?.relativePath) + .filter(Boolean), + ]), + ]; autoBind = await maybeAutoBindPageDataHtmlPagesFn({ pool, @@ -315,9 +326,14 @@ export function createMindSpacePublicFinishService({ let deliveryCheck = null; let rewrittenText = normalizedReply.text; if (outcome?.action === 'send') { - const deliveryRelativePaths = (outcome?.evaluation?.relevantFiles ?? []) - .map((file) => file?.relativePath) - .filter(Boolean); + const deliveryRelativePaths = [ + ...new Set([ + ...scopedRelativePaths, + ...(outcome?.evaluation?.relevantFiles ?? []) + .map((file) => file?.relativePath) + .filter(Boolean), + ]), + ]; deliveryArtifacts = buildPageDataDeliveryArtifactsFromBindResultFn( autoBind, diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 503d33e..843349b 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -345,14 +345,16 @@ export async function executeSessionReply( const routingId = event.chat_request_id ?? event.request_id; if (routingId && routingId !== requestId) continue; - if (event.type === 'Message' && event.message?.metadata?.userVisible) { + if (event.type === 'Message' && event.message) { const hasActionRequired = event.message.content?.some((item) => item.type === 'actionRequired'); if (hasActionRequired) { throw new Error('当前回复需要人工确认,公众号通道暂不支持'); } - if (event.message.role === 'assistant') hasScopedAssistantUpdate = true; - messages = pushMessage(messages, event.message); requestMessages = pushMessage(requestMessages, event.message); + if (event.message?.metadata?.userVisible) { + if (event.message.role === 'assistant') hasScopedAssistantUpdate = true; + messages = pushMessage(messages, event.message); + } } else if (event.type === 'UpdateConversation') { // Ignore unscoped snapshots until this request has yielded an assistant update. // Otherwise a stale session snapshot can overwrite the current reply with a @@ -1213,9 +1215,7 @@ async function enforcePageDataCollectDelivery({ userId, reply: { text: String(reply?.text ?? ''), - messages: Array.isArray(reply?.messages) - ? reply.messages - : [], + messages: replyRequestMessages(reply), }, intent: { agentText: String(intent?.agentText ?? ''),