From 294e331a0381c7dfadab8bc564f87f4af1ad2996 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 12 Jul 2026 13:54:24 +0800 Subject: [PATCH] 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 --- chat-skills.mjs | 2 +- ...age-data-finish-guard.integration.test.mjs | 308 ++++++++++ mindspace-page-data-finish-guard.mjs | 531 ++++++++++++++++++ mindspace-page-data-finish-guard.test.mjs | 169 ++++++ mindspace-publications.mjs | 35 +- package.json | 2 +- server.mjs | 32 ++ skills/page-data-collect/SKILL.md | 1 + tkmind-proxy.mjs | 17 +- tkmind-proxy.test.mjs | 30 + wechat-mp.mjs | 107 ++++ wechat/prompts/chat-general.mjs | 14 +- 12 files changed, 1224 insertions(+), 24 deletions(-) create mode 100644 mindspace-page-data-finish-guard.integration.test.mjs create mode 100644 mindspace-page-data-finish-guard.mjs create mode 100644 mindspace-page-data-finish-guard.test.mjs 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(