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:
+1
-1
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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, {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user