diff --git a/chat-skills.mjs b/chat-skills.mjs
index edae315..ed74b9f 100644
--- a/chat-skills.mjs
+++ b/chat-skills.mjs
@@ -156,7 +156,7 @@ export function buildChatSkillPrompt(promptKey, skillName) {
`请使用 ${skillName ?? PAGE_DATA_COLLECT_SKILL_NAME} 技能:在 MindSpace 页面中实现可提交、可持久化的数据收集(问卷/报名/台账等),必须使用 Page Data API。` +
'先匹配技能内能力分支(默认 A:匿名前台 public insert + 独立后台 password read,口令默认 88888888);展示方案摘要确认后再开工。' +
'流程:load_skill → private_data_execute 建表 → private_data_register_dataset → write_file/edit_file 写 public/*.html(含 /assets/page-data-client.js)→ private_data_bind_workspace_page 发布并写策略。' +
- '禁止自建 Express/独立端口(如 8899)、禁止 HTML 硬编码 127.0.0.1 API、禁止连续空转不调用工具。HTML 视觉规范参照 static-page-publish。完成后返回 workspaceUrl,并说明后台入口与口令。'
+ '禁止 localStorage / 浏览器本地存储 fallback;禁止自建 Express/独立端口(如 8899)、禁止 HTML 硬编码 127.0.0.1 API、禁止连续空转不调用工具。HTML 视觉规范参照 static-page-publish。完成后只返回 workspaceUrl(/MindSpace/<用户ID>/public/...),禁止给用户 /u/用户名/pages/... 链接,并说明后台入口与口令。'
);
case 'service-integration-smoke':
return `请使用 ${skillName ?? 'service-integration-smoke'} 技能:按标准联调流程检查当前服务,覆盖身份、普通聊天、记忆读取,以及我本轮明确要求验证的技能/发布链路,并输出通过项、失败项、待确认项:`;
diff --git a/mindspace-page-data-finish-guard.integration.test.mjs b/mindspace-page-data-finish-guard.integration.test.mjs
new file mode 100644
index 0000000..0e4b5ee
--- /dev/null
+++ b/mindspace-page-data-finish-guard.integration.test.mjs
@@ -0,0 +1,308 @@
+/**
+ * Page Data 问卷交付闸门集成测试:微信通道 + Finish guard 与本地/生产对齐。
+ */
+import assert from 'node:assert/strict';
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import test from 'node:test';
+import { createWechatMpService } from './wechat-mp.mjs';
+import { buildWechatAgentPrompt } from './wechat/prompts/chat-general.mjs';
+import {
+ evaluatePageDataFinishGuard,
+ maybeAutoBindPageDataHtmlPages,
+ maybeRepairPageDataAfterFinish,
+} from './mindspace-page-data-finish-guard.mjs';
+import { createUserDataSpaceService } from './user-data-space-service.mjs';
+import { writePageAccessPolicy } from './page-data-policy-store.mjs';
+
+const SURVEY_USER_TEXT = '帮我设计一个调查问卷,关于儿童饮食偏好方面,做三个问题吧,要加一个后台';
+
+const VALID_SURVEY_HTML = `
儿童饮食问卷
+
+`;
+
+const BAD_SURVEY_HTML = `儿童饮食问卷
+`;
+
+function signatureFor(token, timestamp, nonce) {
+ return crypto.createHash('sha1').update([token, timestamp, nonce].sort().join('')).digest('hex');
+}
+
+function inboundXml({ content = SURVEY_USER_TEXT } = {}) {
+ return [
+ '',
+ '',
+ '',
+ '1710000000',
+ '',
+ ``,
+ '10001',
+ '',
+ ].join('');
+}
+
+function jsonEscapeHtml(html) {
+ return String(html).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
+}
+
+function createWechatUserAuth(workspaceRoot) {
+ return {
+ async findWechatUserByOpenid() {
+ return { userId: 'user-page-data', status: 'active', nickname: '唐' };
+ },
+ async getWechatAgentRoute() {
+ return { agentSessionId: 'session-page-data-1' };
+ },
+ async clearWechatAgentRoute() {},
+ async canUseChat() {
+ return { ok: true };
+ },
+ async resolveWorkingDir() {
+ return workspaceRoot;
+ },
+ async getAgentSessionPolicy() {
+ return { enableContextMemory: false, extensionOverrides: [], unrestricted: true };
+ },
+ async getUserPublishLayout() {
+ return {
+ publishDir: workspaceRoot,
+ displayName: '唐',
+ username: 'wx_test',
+ slug: 'wx_test',
+ constraints: null,
+ };
+ },
+ async billSessionUsage() {},
+ async recordWechatMpMessage() {
+ return { inserted: true };
+ },
+ async finishWechatMpMessage() {},
+ async insertWechatMpMessageDetail() {},
+ async upsertWechatAgentRoute() {},
+ async registerAgentSession() {},
+ };
+}
+
+function createWechatFetchRecorder(wechatCalls) {
+ return async (url, init = {}) => {
+ wechatCalls.push([url, init.method ?? 'GET', init.body ?? null]);
+ if (String(url).includes('/cgi-bin/stable_token')) {
+ return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ if (String(url).includes('/cgi-bin/message/custom/send')) {
+ return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ throw new Error(`unexpected wechat url: ${url}`);
+ };
+}
+
+async function setupSurveyWorkspace(workspaceRoot) {
+ fs.mkdirSync(path.join(workspaceRoot, 'public'), { recursive: true });
+ const dataSpace = createUserDataSpaceService({ workspaceRoot });
+ await dataSpace.executeSql(`CREATE TABLE diet_survey (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ child_age TEXT NOT NULL DEFAULT '',
+ veggie_habit TEXT NOT NULL DEFAULT '',
+ snack_type TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL DEFAULT ''
+ );`);
+ await dataSpace.upsertDataset({
+ name: 'diet_survey',
+ table: 'diet_survey',
+ description: '儿童饮食偏好调查问卷数据',
+ actions: ['read', 'insert'],
+ columns: {
+ read: ['id', 'child_age', 'veggie_habit', 'snack_type', 'created_at'],
+ insert: ['child_age', 'veggie_habit', 'snack_type'],
+ },
+ });
+}
+
+test('buildWechatAgentPrompt injects page-data-collect requirements for survey requests', () => {
+ const prompt = buildWechatAgentPrompt(
+ { msgType: 'text', agentText: SURVEY_USER_TEXT },
+ { grantedSkills: ['page-data-collect', 'static-page-publish'] },
+ );
+ assert.match(prompt, /page-data-collect/);
+ assert.match(prompt, /禁止 localStorage/);
+ assert.match(prompt, /private_data_bind_workspace_page/);
+});
+
+test('integration: wechat mp blocks localStorage survey delivery with page-data failure notice', async () => {
+ const token = 'token';
+ const timestamp = '1710000000';
+ const nonce = 'nonce';
+ const wechatCalls = [];
+ const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-page-data-block-'));
+ const htmlPath = path.join(workspaceRoot, 'public', 'children-diet-survey.html');
+ fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
+ fs.writeFileSync(htmlPath, BAD_SURVEY_HTML, 'utf8');
+
+ const service = createWechatMpService({
+ config: {
+ enabled: true,
+ appId: 'wx123',
+ appSecret: 'secret',
+ token,
+ publicBaseUrl: 'https://m.tkmind.cn',
+ bindPath: '/auth/wechat/authorize?intent=login',
+ ackText: 'ack',
+ unsupportedText: 'unsupported',
+ unboundTextPrefix: '请先绑定',
+ progressDelayMs: 0,
+ },
+ userAuth: createWechatUserAuth(workspaceRoot),
+ pageDataFinishGuard: null,
+ sessionApiFetch: async (sessionId, pathname) => {
+ if (pathname === `/sessions/${sessionId}/events`) {
+ return new Response(
+ [
+ `data: {"type":"Message","request_id":"req-page-data-bad","message":{"id":"assistant-tool","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"toolRequest","toolCall":{"value":{"name":"sandbox-fs__write_file","arguments":{"path":"public/children-diet-survey.html","content":"${jsonEscapeHtml(BAD_SURVEY_HTML)}"}}}}]}}\n\n`,
+ 'data: {"type":"Message","request_id":"req-page-data-bad","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"问卷已发布:https://m.tkmind.cn/MindSpace/user-page-data/public/children-diet-survey.html"}]}}\n\n',
+ 'data: {"type":"Finish","request_id":"req-page-data-bad","token_state":{"inputTokens":1,"outputTokens":1}}\n\n',
+ ].join(''),
+ { status: 200, headers: { 'Content-Type': 'text/event-stream' } },
+ );
+ }
+ if (pathname === `/sessions/${sessionId}/reply`) {
+ return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
+ }
+ if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
+ return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
+ }
+ throw new Error(`unexpected session api path: ${sessionId} ${pathname}`);
+ },
+ wechatFetch: createWechatFetchRecorder(wechatCalls),
+ });
+
+ const originalRandomUuid = crypto.randomUUID;
+ crypto.randomUUID = () => 'req-page-data-bad';
+ try {
+ const result = await service.handleInboundMessage(inboundXml(), {
+ timestamp,
+ nonce,
+ signature: signatureFor(token, timestamp, nonce),
+ });
+ assert.equal(result.status, 200);
+ await result.task;
+
+ assert.ok(fs.existsSync(htmlPath), 'bad survey html should remain on disk for inspection');
+ const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
+ assert.ok(sendCall, 'wechat should send a customer service message');
+ const payload = JSON.parse(sendCall[2]);
+ assert.match(payload.text.content, /page-data-collect|Page Data API|localStorage/i);
+ assert.doesNotMatch(
+ payload.text.content,
+ /https:\/\/m\.tkmind\.cn\/MindSpace\/user-page-data\/public\/children-diet-survey\.html/,
+ );
+ } finally {
+ crypto.randomUUID = originalRandomUuid;
+ fs.rmSync(workspaceRoot, { recursive: true, force: true });
+ }
+});
+
+test('integration: finish guard auto-bind clears unbound state for valid survey html', async () => {
+ const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-autobind-'));
+ try {
+ await setupSurveyWorkspace(workspaceRoot);
+ fs.writeFileSync(path.join(workspaceRoot, 'public', 'diet-survey.html'), VALID_SURVEY_HTML, 'utf8');
+
+ const before = evaluatePageDataFinishGuard({
+ publishDir: workspaceRoot,
+ agentText: SURVEY_USER_TEXT,
+ messages: [],
+ });
+ assert.equal(before.unboundFiles.length, 1);
+
+ const autoBind = await maybeAutoBindPageDataHtmlPages({
+ pool: null,
+ userId: 'user-page-data',
+ publishDir: workspaceRoot,
+ h5Root: workspaceRoot,
+ storageRoot: workspaceRoot,
+ });
+ assert.equal(autoBind.bound.length, 0);
+ assert.equal(autoBind.errors[0]?.code, 'database_unconfigured');
+
+ writePageAccessPolicy(workspaceRoot, {
+ pageId: 'page-diet-survey',
+ ownerUserId: 'user-page-data',
+ accessMode: 'public',
+ datasets: {
+ diet_survey: {
+ insert: true,
+ read: false,
+ columns: { insert: ['child_age', 'veggie_habit', 'snack_type'] },
+ },
+ },
+ });
+
+ const after = evaluatePageDataFinishGuard({
+ publishDir: workspaceRoot,
+ agentText: SURVEY_USER_TEXT,
+ messages: [],
+ });
+ assert.equal(after.unboundFiles.length, 0);
+ assert.equal(after.htmlIssues.length, 0);
+ assert.equal(after.needsRepair, false);
+ } finally {
+ fs.rmSync(workspaceRoot, { recursive: true, force: true });
+ }
+});
+
+test('integration: H5 finish guard triggers repair prompt for invalid survey html', async () => {
+ const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-h5-repair-'));
+ const submitCalls = [];
+ try {
+ await setupSurveyWorkspace(workspaceRoot);
+ fs.writeFileSync(path.join(workspaceRoot, 'public', 'children-diet-survey.html'), BAD_SURVEY_HTML, 'utf8');
+
+ const result = await maybeRepairPageDataAfterFinish({
+ sessionId: 'session-h5-page-data',
+ userId: 'user-page-data',
+ publishDir: workspaceRoot,
+ messages: [],
+ pool: null,
+ h5Root: workspaceRoot,
+ storageRoot: workspaceRoot,
+ userText: SURVEY_USER_TEXT,
+ tkmindProxy: {
+ async submitSessionReplyForUser(userId, sessionId, requestId, userMessage) {
+ submitCalls.push({ userId, sessionId, requestId, userMessage });
+ },
+ },
+ });
+
+ assert.equal(result.repaired, true);
+ assert.equal(result.triggered, true);
+ assert.equal(submitCalls.length, 1);
+ assert.match(submitCalls[0].userMessage.content[0].text, /localStorage/);
+ assert.match(submitCalls[0].userMessage.content[0].text, /private_data_bind_workspace_page/);
+ } finally {
+ fs.rmSync(workspaceRoot, { recursive: true, force: true });
+ }
+});
diff --git a/mindspace-page-data-finish-guard.mjs b/mindspace-page-data-finish-guard.mjs
new file mode 100644
index 0000000..4fb93cf
--- /dev/null
+++ b/mindspace-page-data-finish-guard.mjs
@@ -0,0 +1,531 @@
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+import path from 'node:path';
+import { isPageDataIntent } from './chat-skills.mjs';
+import { detectPageDataDatasetUsageFromHtml } from './page-data-html-detect.mjs';
+import { bindWorkspaceHtmlForPageData } from './page-data-workspace-bind.mjs';
+import { listPageAccessPolicies } from './page-data-policy-store.mjs';
+import { buildPublicUrl, resolvePublicBaseUrl } from './user-publish.mjs';
+
+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);
+}
+
+export function inferPageDataBindAccessMode(relativePath, html) {
+ const usage = detectPageDataDatasetUsageFromHtml(html);
+ const hasRead = [...usage.values()].some((item) => item.read);
+ const hasInsert = [...usage.values()].some((item) => item.insert);
+ if (/-admin\.html$/i.test(String(relativePath ?? '')) || (hasRead && !hasInsert)) {
+ return 'password';
+ }
+ return 'public';
+}
+
+function pageHasBoundPolicy({ publishDir, relativePath, html }) {
+ const usage = detectPageDataDatasetUsageFromHtml(html);
+ if (!usage.size) return true;
+ const policies = listPageAccessPolicies(publishDir);
+ if (!policies.length) return false;
+ const htmlDatasetNames = [...usage.keys()].sort().join(',');
+ return policies.some((policy) => {
+ const policyNames = Object.keys(policy?.datasets ?? {}).sort().join(',');
+ return policyNames === htmlDatasetNames;
+ });
+}
+
+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 &&
+ !pageHasBoundPolicy({
+ 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 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) {
+ lines.push(`- ${file.relativePath}`);
+ }
+ }
+ 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 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,
+} = {}) {
+ if (!pool) {
+ return { bound: [], skipped: [], errors: [{ code: 'database_unconfigured' }] };
+ }
+
+ 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;
+ }
+ if (pageHasBoundPolicy({
+ publishDir,
+ relativePath: file.relativePath,
+ html: file.content,
+ })) {
+ 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 maybeRepairPageDataAfterFinish({
+ sessionId,
+ userId,
+ publishDir,
+ messages,
+ pool,
+ h5Root,
+ storageRoot,
+ tkmindProxy = null,
+ maxAttempts = 1,
+ env = process.env,
+ logger = console,
+ userText = '',
+} = {}) {
+ const recentUserText = String(userText ?? '').trim();
+ const evaluation = evaluatePageDataFinishGuard({
+ publishDir,
+ agentText: recentUserText,
+ messages,
+ });
+
+ 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),
+ });
+
+ const afterBind = evaluatePageDataFinishGuard({
+ publishDir,
+ agentText: recentUserText,
+ messages,
+ });
+
+ 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);
+ const requestId = `page-data-repair-${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,
+ };
+ }
+}
diff --git a/mindspace-page-data-finish-guard.test.mjs b/mindspace-page-data-finish-guard.test.mjs
new file mode 100644
index 0000000..037680f
--- /dev/null
+++ b/mindspace-page-data-finish-guard.test.mjs
@@ -0,0 +1,169 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import test from 'node:test';
+import {
+ buildPageDataCollectFailureText,
+ buildPageDataCollectRepairPrompt,
+ collectPageDataDeliveryArtifacts,
+ evaluatePageDataFinishGuard,
+ evaluatePageDataHtmlContent,
+ inferPageDataBindAccessMode,
+ maybeAutoBindPageDataHtmlPages,
+ rewritePageDataDeliveryLinks,
+ shouldRetryPageDataCollectReply,
+} from './mindspace-page-data-finish-guard.mjs';
+import { writePageAccessPolicy } from './page-data-policy-store.mjs';
+
+const SURVEY_HTML = `问卷
+
+`;
+
+const BAD_SURVEY_HTML = ``;
+
+test('evaluatePageDataHtmlContent flags missing client script and localStorage fallback', () => {
+ const ok = evaluatePageDataHtmlContent(SURVEY_HTML, { relativePath: 'public/diet-survey.html' });
+ assert.deepEqual(ok.issues, []);
+
+ const bad = evaluatePageDataHtmlContent(BAD_SURVEY_HTML, { relativePath: 'public/children-diet-survey.html' });
+ assert.ok(bad.issues.includes('missing_page_data_client_script'));
+ assert.ok(bad.issues.includes('forbidden_local_storage'));
+});
+
+test('inferPageDataBindAccessMode chooses password for admin html', () => {
+ assert.equal(
+ inferPageDataBindAccessMode('public/diet-survey-admin.html', SURVEY_HTML),
+ 'password',
+ );
+ assert.equal(inferPageDataBindAccessMode('public/diet-survey.html', SURVEY_HTML), 'public');
+});
+
+test('evaluatePageDataFinishGuard detects unbound page data html', () => {
+ const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-'));
+ try {
+ fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
+ fs.writeFileSync(path.join(publishDir, 'public', 'diet-survey.html'), SURVEY_HTML, 'utf8');
+ const evaluation = evaluatePageDataFinishGuard({
+ publishDir,
+ agentText: '帮我设计一个调查问卷,要加一个后台',
+ messages: [],
+ });
+ assert.equal(evaluation.pageDataIntent, true);
+ assert.equal(evaluation.unboundFiles.length, 1);
+ assert.equal(evaluation.needsRepair, true);
+ } finally {
+ fs.rmSync(publishDir, { recursive: true, force: true });
+ }
+});
+
+test('shouldRetryPageDataCollectReply retries when localStorage fallback exists', () => {
+ const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-retry-'));
+ try {
+ fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
+ fs.writeFileSync(path.join(publishDir, 'public', 'children-diet-survey.html'), BAD_SURVEY_HTML, 'utf8');
+ assert.equal(
+ shouldRetryPageDataCollectReply({
+ reply: { text: '问卷已发布', messages: [] },
+ intent: { agentText: '帮我设计一个调查问卷,要加一个后台' },
+ publishDir,
+ }),
+ true,
+ );
+ } finally {
+ fs.rmSync(publishDir, { recursive: true, force: true });
+ }
+});
+
+test('buildPageDataCollectRepairPrompt mentions bind and forbids localStorage', () => {
+ const prompt = buildPageDataCollectRepairPrompt({
+ htmlIssues: [{ relativePath: 'public/x.html', issue: 'forbidden_local_storage' }],
+ unboundFiles: [{ relativePath: 'public/x.html' }],
+ });
+ assert.match(prompt, /private_data_bind_workspace_page/);
+ assert.match(prompt, /localStorage/);
+ assert.match(buildPageDataCollectFailureText(), /page-data-collect/);
+});
+
+test('maybeAutoBindPageDataHtmlPages skips invalid html', async () => {
+ const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-bind-'));
+ try {
+ fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
+ fs.writeFileSync(path.join(publishDir, 'public', 'bad.html'), BAD_SURVEY_HTML, 'utf8');
+ const result = await maybeAutoBindPageDataHtmlPages({
+ pool: null,
+ userId: 'user-1',
+ publishDir,
+ h5Root: publishDir,
+ storageRoot: publishDir,
+ });
+ assert.equal(result.bound.length, 0);
+ assert.equal(result.errors[0]?.code, 'database_unconfigured');
+ } finally {
+ fs.rmSync(publishDir, { recursive: true, force: true });
+ }
+});
+
+test('evaluatePageDataFinishGuard passes when policy already exists', () => {
+ const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-bound-'));
+ try {
+ fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
+ fs.writeFileSync(path.join(publishDir, 'public', 'diet-survey.html'), SURVEY_HTML, 'utf8');
+ writePageAccessPolicy(publishDir, {
+ pageId: 'page-1',
+ ownerUserId: 'user-1',
+ accessMode: 'public',
+ datasets: {
+ diet_survey: {
+ insert: true,
+ read: false,
+ columns: { insert: ['child_age'] },
+ },
+ },
+ });
+ const evaluation = evaluatePageDataFinishGuard({
+ publishDir,
+ agentText: '调查问卷和后台',
+ messages: [],
+ });
+ assert.equal(evaluation.unboundFiles.length, 0);
+ assert.equal(evaluation.htmlIssues.length, 0);
+ } finally {
+ fs.rmSync(publishDir, { recursive: true, force: true });
+ }
+});
+
+test('rewritePageDataDeliveryLinks rewrites publication route urls to MindSpace workspace urls', () => {
+ const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-urls-'));
+ 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', 'zhiqu-survey.html'), SURVEY_HTML, 'utf8');
+ fs.writeFileSync(path.join(publishDir, 'public', 'zhiqu-survey-admin.html'), SURVEY_HTML, 'utf8');
+ const artifacts = collectPageDataDeliveryArtifacts(publishDir);
+ assert.equal(artifacts.length, 2);
+ const text = [
+ '问卷:https://m.tkmind.cn/u/john/pages/zhiqu-58bccceb',
+ '后台:https://m.tkmind.cn/u/john/pages/zhiqu-admin-98f11681',
+ ].join('\n');
+ const next = rewritePageDataDeliveryLinks(text, artifacts);
+ assert.doesNotMatch(next, /\/u\/john\/pages\//);
+ assert.match(next, /\/MindSpace\/.+\/public\/zhiqu-survey\.html/);
+ assert.match(next, /\/MindSpace\/.+\/public\/zhiqu-survey-admin\.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-publications.mjs b/mindspace-publications.mjs
index 78fd216..6117ad0 100644
--- a/mindspace-publications.mjs
+++ b/mindspace-publications.mjs
@@ -176,14 +176,12 @@ function resolvePublicationDeliveryPublicUrl({
privateToken = null,
} = {}) {
if (privateToken) return `/s/${privateToken}`;
- if (accessMode === 'public') {
- const mindSpaceUrl = resolveWorkspaceMindSpacePublicUrl({
- h5Root,
- userId,
- relativePath: workspaceRelativePath,
- });
- if (mindSpaceUrl) return mindSpaceUrl;
- }
+ const mindSpaceUrl = resolveWorkspaceMindSpacePublicUrl({
+ h5Root,
+ userId,
+ relativePath: workspaceRelativePath,
+ });
+ if (mindSpaceUrl) return mindSpaceUrl;
return buildOnlinePublicationPublicUrl(ownerSlug, urlSlug);
}
@@ -1035,17 +1033,16 @@ export function createPublicationService(pool, options = {}) {
WHERE id = ?`,
[htmlBytes, checksum, publication.asset_version_id],
);
- let publicUrl = publication.public_url;
- if (publication.access_mode === 'public') {
- const workspaceRelativePath = resolvePageWorkspaceRelativePath(page);
- publicUrl = resolvePublicationDeliveryPublicUrl({
- h5Root,
- userId,
- ownerSlug,
- urlSlug: publication.url_slug,
- accessMode: publication.access_mode,
- workspaceRelativePath,
- });
+ const workspaceRelativePath = resolvePageWorkspaceRelativePath(page);
+ const publicUrl = resolvePublicationDeliveryPublicUrl({
+ h5Root,
+ userId,
+ ownerSlug,
+ urlSlug: publication.url_slug,
+ accessMode: publication.access_mode,
+ workspaceRelativePath,
+ });
+ if (publicUrl !== publication.public_url) {
await pool.query(
`UPDATE h5_publish_records SET public_url = ?, updated_at = ? WHERE id = ? AND user_id = ?`,
[publicUrl, now, publication.id, userId],
diff --git a/package.json b/package.json
index 213249b..4b86ab7 100644
--- a/package.json
+++ b/package.json
@@ -64,7 +64,7 @@
"verify:mindspace-publish-guards": "node scripts/verify-mindspace-publish-guards.mjs",
"verify:mindspace-publish-guards:full": "node scripts/verify-mindspace-publish-guards.mjs --with-runtime",
"verify:mindspace-page-sync-guards": "node scripts/verify-mindspace-page-sync-guards.mjs",
- "verify:page-data": "node --test page-data-acceptance.test.mjs page-data-integration.test.mjs page-data-public-service.test.mjs page-data-ops.test.mjs",
+ "verify:page-data": "node --test page-data-acceptance.test.mjs page-data-integration.test.mjs page-data-public-service.test.mjs page-data-ops.test.mjs mindspace-page-data-finish-guard.test.mjs mindspace-page-data-finish-guard.integration.test.mjs",
"verify:wechat-channel-isolation": "node scripts/check-wechat-channel-isolation.mjs",
"verify:goosed-proxy-boundary": "node scripts/check-goosed-proxy-boundary.mjs",
"verify:h5-session-patches": "node scripts/verify-h5-session-patches.mjs",
diff --git a/server.mjs b/server.mjs
index ec97526..c3582f7 100644
--- a/server.mjs
+++ b/server.mjs
@@ -135,6 +135,7 @@ import {
syncPublicHtmlAfterFinish,
} from './mindspace-public-finish-sync.mjs';
import { maybeRepairH5HtmlAfterFinish } from './mindspace-h5-html-finish-guard.mjs';
+import { maybeRepairPageDataAfterFinish } from './mindspace-page-data-finish-guard.mjs';
import { quickPlazaFromChat, quickPlazaFromPublicHtml, getQuickPlazaFromPublicHtmlStatus } from './mindspace-chat-plaza.mjs';
import { injectPublicFileShareButton } from './mindspace-public-share-widget.mjs';
import { resolvePlazaPostPath, resolvePlazaPublicBase } from './src/utils/public-site-bases.mjs';
@@ -713,6 +714,13 @@ async function bootstrapUserAuth() {
config: WECHAT_MP_CONFIG,
userAuth,
sessionAccess,
+ pageDataFinishGuard: authPool
+ ? {
+ pool: authPool,
+ h5Root: __dirname,
+ storageRoot: resolveMindSpaceRuntimeConfig(__dirname, process.env).storageRoot,
+ }
+ : null,
apiFetch: tkmindProxy.apiFetch,
startAgentSession: ({ userId, workingDir, sessionPolicy }) =>
tkmindProxy.startSessionForUser(userId, { workingDir, sessionPolicy }),
@@ -5047,6 +5055,30 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
syncResult,
tkmindProxy,
});
+ const lastUserMessage = [...(Array.isArray(messages) ? messages : [])]
+ .reverse()
+ .find((message) => message?.role === 'user');
+ const lastUserText =
+ typeof lastUserMessage?.content === 'string'
+ ? lastUserMessage.content
+ : Array.isArray(lastUserMessage?.content)
+ ? lastUserMessage.content
+ .filter((item) => item?.type === 'text')
+ .map((item) => String(item.text ?? '').trim())
+ .filter(Boolean)
+ .join('\n')
+ : '';
+ await maybeRepairPageDataAfterFinish({
+ sessionId: sid,
+ userId: uid,
+ publishDir,
+ messages,
+ pool: authPool,
+ h5Root: __dirname,
+ storageRoot: resolveMindSpaceRuntimeConfig(__dirname, process.env).storageRoot,
+ tkmindProxy,
+ userText: lastUserText,
+ });
await syncUserGeneratedPages(uid);
};
return tkmindProxy.proxySessionEvents(req, res, sessionId, {
diff --git a/skills/page-data-collect/SKILL.md b/skills/page-data-collect/SKILL.md
index cf18407..ba84841 100644
--- a/skills/page-data-collect/SKILL.md
+++ b/skills/page-data-collect/SKILL.md
@@ -274,6 +274,7 @@ CREATE TABLE IF NOT EXISTS survey_responses (
10. **禁止**只 `CREATE TABLE` 而不 `private_data_register_dataset`
11. **禁止**未配置 `private_data_set_page_policy` 就让页面调用公开 API
12. **禁止**先发布占位页(如 `问卷页面
`)再让用户访问 `/u/.../pages/...`
+13. **禁止**在 Page Data 页面中使用 `localStorage` / `sessionStorage` 存提交记录或做 API fallback
## 交付前自检
diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs
index bc6b961..8b84e01 100644
--- a/tkmind-proxy.mjs
+++ b/tkmind-proxy.mjs
@@ -23,7 +23,10 @@ import { createImgproxySigner } from './imgproxy-signer.mjs';
import { isDirectChatSessionId } from './direct-chat-service.mjs';
import { ensureGooseUserMessageMetadata } from './goose-message.mjs';
import { filterMemoriesByQuery, resolveMemoriesWithLegacyFallback } from './memory-legacy-fallback.mjs';
-import { consumeSessionEventsUntilFinish } from './session-reply-wait.mjs';
+import {
+ collectPageDataDeliveryArtifacts,
+ rewritePageDataDeliveryLinks,
+} from './mindspace-page-data-finish-guard.mjs';
import { extractAttachmentText } from './mindspace-attachment-text.mjs';
import {
buildCurrentTurnImageScopeNote,
@@ -569,6 +572,15 @@ function sanitizeOwnPublicHtmlRelativePath(rawRelativePath, currentUser) {
};
}
+function rewriteOwnPublicationRouteLinks(text, currentUser) {
+ const owner = String(currentUser?.id ?? currentUser?.username ?? '').trim();
+ if (!owner) return String(text ?? '');
+ const publishDir = path.resolve(process.cwd(), PUBLISH_ROOT_DIR, owner);
+ const artifacts = collectPageDataDeliveryArtifacts(publishDir);
+ if (!artifacts.length) return String(text ?? '');
+ return rewritePageDataDeliveryLinks(text, artifacts);
+}
+
export function sanitizePublicHtmlLinksInText(text, currentUser) {
let next = String(text ?? '').replace(
PUBLIC_HTML_MARKDOWN_LINK_PATTERN,
@@ -590,10 +602,11 @@ export function sanitizePublicHtmlLinksInText(text, currentUser) {
next = next.replace(PLAIN_HTML_URL_PATTERN, (match) => (
sanitizeMissingMindSpacePublicHtmlUrl(match, currentUser) ?? match
));
- return next.replace(INLINE_PUBLIC_HTML_PATH_PATTERN, (match, rawRelativePath) => {
+ next = next.replace(INLINE_PUBLIC_HTML_PATH_PATTERN, (match, rawRelativePath) => {
const result = sanitizeOwnPublicHtmlRelativePath(rawRelativePath, currentUser);
return result ? `[${result.label}](${result.url})` : match;
});
+ return rewriteOwnPublicationRouteLinks(next, currentUser);
}
export function sanitizeUserVisibleMessageText(text, currentUser) {
diff --git a/tkmind-proxy.test.mjs b/tkmind-proxy.test.mjs
index 463595f..ac58e29 100644
--- a/tkmind-proxy.test.mjs
+++ b/tkmind-proxy.test.mjs
@@ -269,6 +269,36 @@ test('sanitizePublicHtmlLinksInText links existing inline public html paths', ()
}
});
+test('sanitizePublicHtmlLinksInText rewrites own publication route links to MindSpace workspace urls', () => {
+ const owner = `test-user-${Date.now()}-pub-route`;
+ const publicRoot = path.join(process.cwd(), 'MindSpace', owner, 'public');
+ const previousBase = process.env.H5_PUBLIC_BASE_URL;
+ process.env.H5_PUBLIC_BASE_URL = 'https://m.tkmind.cn';
+ try {
+ fs.mkdirSync(publicRoot, { recursive: true });
+ fs.writeFileSync(
+ path.join(publicRoot, 'zhiqu-survey.html'),
+ '问卷',
+ );
+ fs.writeFileSync(
+ path.join(publicRoot, 'zhiqu-survey-admin.html'),
+ '后台',
+ );
+ const text = [
+ '问卷:https://m.tkmind.cn/u/john/pages/zhiqu-58bccceb',
+ '后台:https://m.tkmind.cn/u/john/pages/zhiqu-admin-98f11681',
+ ].join('\n');
+ const next = sanitizePublicHtmlLinksInText(text, { id: owner, username: 'john' });
+ assert.doesNotMatch(next, /\/u\/john\/pages\//);
+ assert.match(next, new RegExp(`https://m\\.tkmind\\.cn/MindSpace/${owner}/public/zhiqu-survey\\.html`));
+ assert.match(next, new RegExp(`https://m\\.tkmind\\.cn/MindSpace/${owner}/public/zhiqu-survey-admin\\.html`));
+ } finally {
+ if (previousBase == null) delete process.env.H5_PUBLIC_BASE_URL;
+ else process.env.H5_PUBLIC_BASE_URL = previousBase;
+ fs.rmSync(path.join(process.cwd(), 'MindSpace', owner), { recursive: true, force: true });
+ }
+});
+
test('sanitizeSessionConversationPublicHtmlLinks updates text items inside conversation messages', () => {
const owner = `test-user-${Date.now()}-conversation`;
const publicRoot = path.join(process.cwd(), 'MindSpace', owner, 'public');
diff --git a/wechat-mp.mjs b/wechat-mp.mjs
index 57b6641..7d91a63 100644
--- a/wechat-mp.mjs
+++ b/wechat-mp.mjs
@@ -29,6 +29,13 @@ import {
} from './wechat/prompts/page-generate.mjs';
import { selectSendableHtmlArtifacts } from './wechat/verify/page-artifact.mjs';
import { resolveBillingTokenState } from './billing-token-state.mjs';
+import {
+ buildPageDataCollectFailureText,
+ buildPageDataDeliveryArtifactsFromBindResult,
+ maybeAutoBindPageDataHtmlPages,
+ resolvePageDataCollectOutcome,
+ rewritePageDataDeliveryLinks,
+} from './mindspace-page-data-finish-guard.mjs';
export { buildWechatAgentPrompt };
@@ -1039,6 +1046,61 @@ function markWechatUserNotified(err) {
return err;
}
+async function enforcePageDataCollectDelivery({
+ reply,
+ intent,
+ workingDir,
+ userId,
+ pageDataFinishGuard,
+ publicBaseUrl,
+ requestStartedAt = 0,
+ notifyFailure,
+}) {
+ let outcome = resolvePageDataCollectOutcome({
+ reply,
+ intent,
+ publishDir: workingDir,
+ requestStartedAt,
+ });
+ let autoBind = null;
+ if (outcome.action === 'skip') return outcome;
+
+ if (pageDataFinishGuard?.pool) {
+ autoBind = await maybeAutoBindPageDataHtmlPages({
+ pool: pageDataFinishGuard.pool,
+ userId,
+ publishDir: workingDir,
+ h5Root: pageDataFinishGuard.h5Root,
+ storageRoot: pageDataFinishGuard.storageRoot,
+ });
+ outcome = resolvePageDataCollectOutcome({
+ reply,
+ intent,
+ publishDir: workingDir,
+ requestStartedAt,
+ });
+ }
+
+ if (outcome.action === 'retry') {
+ throw new Error('stale_session_poisoned_completion');
+ }
+ if (outcome.action === 'fail') {
+ const text = outcome.failureText ?? buildPageDataCollectFailureText();
+ if (typeof notifyFailure === 'function') {
+ await notifyFailure(text);
+ }
+ throw markWechatUserNotified(new Error(text));
+ }
+
+ const deliveryArtifacts = buildPageDataDeliveryArtifactsFromBindResult(autoBind, workingDir, {
+ publicBaseUrl,
+ });
+ if (deliveryArtifacts.length > 0 && reply && typeof reply.text === 'string') {
+ reply.text = rewritePageDataDeliveryLinks(reply.text, deliveryArtifacts);
+ }
+ return { ...outcome, deliveryArtifacts, autoBind };
+}
+
function wasWechatUserNotified(err) {
return Boolean(err && typeof err === 'object' && err.wechatUserNotified);
}
@@ -1299,6 +1361,7 @@ export function createWechatMpService({
llmProviderService = null,
applySessionLlmProvider = null,
refreshSessionSnapshot = null,
+ pageDataFinishGuard = null,
wechatFetch = undiciFetch,
linkExists = defaultPublicHtmlLinkExists,
logger = console,
@@ -1926,6 +1989,28 @@ export function createWechatMpService({
} else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) {
throw new Error('stale_session_poisoned_completion');
}
+ const pageDataOutcome = await enforcePageDataCollectDelivery({
+ reply,
+ intent,
+ workingDir,
+ userId: user.userId,
+ pageDataFinishGuard,
+ publicBaseUrl: config.publicBaseUrl,
+ requestStartedAt,
+ notifyFailure: async (text) => {
+ try {
+ await sendCustomerServiceText(inbound.fromUserName, text, user);
+ } catch (sendErr) {
+ logger.error?.('WeChat MP page data failure notice failed:', sendErr);
+ }
+ },
+ });
+ if (pageDataOutcome?.deliveryArtifacts?.length) {
+ publishArtifacts = uniqueArtifactsByUrl([
+ ...pageDataOutcome.deliveryArtifacts,
+ ...publishArtifacts,
+ ]);
+ }
if (reply.tokenState) {
const tokenState = await resolveWechatBillingTokenState(sessionId, reply.tokenState);
await userAuth.billSessionUsage(user.userId, sessionId, tokenState, requestId);
@@ -2039,6 +2124,28 @@ export function createWechatMpService({
} else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) {
throw new Error(buildHtmlPublishFailureText());
}
+ const pageDataOutcome = await enforcePageDataCollectDelivery({
+ reply,
+ intent,
+ workingDir,
+ userId: user.userId,
+ pageDataFinishGuard,
+ publicBaseUrl: config.publicBaseUrl,
+ requestStartedAt: retryStartedAt,
+ notifyFailure: async (text) => {
+ try {
+ await sendCustomerServiceText(inbound.fromUserName, text, user);
+ } catch (sendErr) {
+ logger.error?.('WeChat MP page data retry failure notice failed:', sendErr);
+ }
+ },
+ });
+ if (pageDataOutcome?.deliveryArtifacts?.length) {
+ publishArtifacts = uniqueArtifactsByUrl([
+ ...pageDataOutcome.deliveryArtifacts,
+ ...publishArtifacts,
+ ]);
+ }
if (reply.tokenState) {
const tokenState = await resolveWechatBillingTokenState(sessionId, reply.tokenState);
await userAuth.billSessionUsage(user.userId, sessionId, tokenState, retryId);
diff --git a/wechat/prompts/chat-general.mjs b/wechat/prompts/chat-general.mjs
index 09bd560..1edb69f 100644
--- a/wechat/prompts/chat-general.mjs
+++ b/wechat/prompts/chat-general.mjs
@@ -1,4 +1,4 @@
-import { buildAutoChatSkillPrefix } from '../../chat-skills.mjs';
+import { buildAutoChatSkillPrefix, isPageDataIntent } from '../../chat-skills.mjs';
import { shouldUseScheduleAssistant } from '../../schedule-intent.mjs';
import { buildCurrentTimeAgentPrefix } from '../../user-memory-profile.mjs';
import { isPageGenerateText, wantsDocxDownload } from '../intent/patterns.mjs';
@@ -31,6 +31,16 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
'',
].join('\n')
: '';
+ const pageDataCollectHint = isPageDataIntent(agentText)
+ ? [
+ '【Page Data 问卷/数据收集要求】这条消息需要可提交、可持久化、可后台查看的数据页面。',
+ '开始前必须先调用 `load_skill` → `page-data-collect`,并按技能完成:建表 → register dataset → 写 HTML(必须引入 /assets/page-data-client.js)→ private_data_bind_workspace_page。',
+ '禁止 localStorage / 浏览器本地存储 fallback;禁止自建 Express 或独立端口 API。',
+ '交付链接必须使用 bind 返回的 workspaceUrl(/MindSpace/<用户ID>/public/...),禁止给用户 /u/用户名/pages/... 发布路由链接。',
+ '问卷页与后台页必须分别 bind(public insert + password read);未完成 bind 前不要发送链接或说「已发布/可提交」。',
+ '',
+ ].join('\n')
+ : '';
const scheduleTimezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
const currentTimeHint = buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone });
const scheduleAssistantHint = shouldUseScheduleAssistant(agentText)
@@ -48,6 +58,7 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
if (msgType === 'voice') {
return [
docxDownloadHint,
+ pageDataCollectHint,
currentTimeHint,
scheduleAssistantHint,
'【微信服务号语音消息】用户通过语音输入,以下是微信识别结果。',
@@ -104,6 +115,7 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
const content = String(agentText).trim();
const lines = [currentTimeHint];
if (docxDownloadHint) lines.push(docxDownloadHint);
+ if (pageDataCollectHint) lines.push(pageDataCollectHint);
if (pagePublishHint) lines.push(pagePublishHint);
if (scheduleAssistantHint) lines.push(scheduleAssistantHint);
lines.push(