Merge pull request 'fix(page-data): deliver MindSpace workspace URLs for questionnaires' (#9) from release/0712-portal-prep into main

Merge pull request #9: fix(page-data) deliver MindSpace workspace URLs for questionnaires
This commit was merged in pull request #9.
This commit is contained in:
2026-07-12 06:03:42 +00:00
12 changed files with 1224 additions and 24 deletions
+1 -1
View File
@@ -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'} 技能:按标准联调流程检查当前服务,覆盖身份、普通聊天、记忆读取,以及我本轮明确要求验证的技能/发布链路,并输出通过项、失败项、待确认项:`;
@@ -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 = `<!doctype html><html><head><title>儿童饮食问卷</title></head><body>
<script src="/assets/page-data-client.js"></script>
<script>
MindSpacePageData.createClient({ apiBase: '/api' }).insertRow('diet_survey', {
child_age: '6-8',
veggie_habit: '一般',
snack_type: '水果',
});
</script></body></html>`;
const BAD_SURVEY_HTML = `<!doctype html><html><head><title>儿童饮食问卷</title></head><body>
<script>
async function save(data) {
try {
if (typeof MindSpacePageData !== 'undefined') {
await MindSpacePageData.createClient({ apiBase: '/api' }).insertRow('diet_survey', data);
return;
}
} catch (e) {}
localStorage.setItem('diet_survey_data', JSON.stringify([data]));
}
</script></body></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 [
'<xml>',
'<ToUserName><![CDATA[gh_test]]></ToUserName>',
'<FromUserName><![CDATA[openid-page-data]]></FromUserName>',
'<CreateTime>1710000000</CreateTime>',
'<MsgType><![CDATA[text]]></MsgType>',
`<Content><![CDATA[${content}]]></Content>`,
'<MsgId>10001</MsgId>',
'</xml>',
].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 });
}
});
+531
View File
@@ -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,
};
}
}
+169
View File
@@ -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 = `<!doctype html><html><head><title>问卷</title></head><body>
<script src="/assets/page-data-client.js"></script>
<script>
MindSpacePageData.createClient({ apiBase: '/api' }).insertRow('diet_survey', {});
</script></body></html>`;
const BAD_SURVEY_HTML = `<!doctype html><html><body><script>
async function save(data) {
if (typeof MindSpacePageData !== 'undefined') {
await MindSpacePageData.createClient({ apiBase: '/api' }).insertRow('diet_survey', data);
return;
}
localStorage.setItem('diet_survey_data', JSON.stringify([data]));
}
</script></body></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 });
}
});
+16 -19
View File
@@ -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],
+1 -1
View File
@@ -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",
+32
View File
@@ -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, {
+1
View File
@@ -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. **禁止**先发布占位页(如 `<p>问卷页面</p>`)再让用户访问 `/u/.../pages/...`
13. **禁止**在 Page Data 页面中使用 `localStorage` / `sessionStorage` 存提交记录或做 API fallback
## 交付前自检
+15 -2
View File
@@ -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) {
+30
View File
@@ -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'),
'<!doctype html><title>问卷</title><script src="/assets/page-data-client.js"></script>',
);
fs.writeFileSync(
path.join(publicRoot, 'zhiqu-survey-admin.html'),
'<!doctype html><title>后台</title><script src="/assets/page-data-client.js"></script>',
);
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');
+107
View File
@@ -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);
+13 -1
View File
@@ -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/... 发布路由链接。',
'问卷页与后台页必须分别 bindpublic 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(