fix(page-data): deliver MindSpace workspace URLs instead of /u/.../pages/ routes.

Page Data questionnaires were returning publication slugs to users; password-mode
pages always fell back to /u/ routes. Prefer MindSpace paths at publish time and
rewrite mistaken delivery links in WeChat and Portal chat.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-12 13:54:24 +08:00
parent 048f25f580
commit 294e331a03
12 changed files with 1224 additions and 24 deletions
+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,
};
}
}