Add page data delivery and publication guards
This commit is contained in:
@@ -112,49 +112,63 @@ function buildTempUser() {
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForSessionFinish(fetchImpl, baseUrl, sessionId, cookie, timeoutMs, runTrigger) {
|
||||
const response = await fetchImpl(`${baseUrl}/api/sessions/${encodeURIComponent(sessionId)}/events`, {
|
||||
async function waitForAgentRunCompletion(fetchImpl, baseUrl, cookie, timeoutMs, createRun) {
|
||||
const { runId, sessionId } = await createRun();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const seen = [];
|
||||
|
||||
const response = await fetchImpl(`${baseUrl}/api/agent/runs/${encodeURIComponent(runId)}/events`, {
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
Cookie: cookie,
|
||||
},
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
const payload = await parseResponseBody(response);
|
||||
throw new Error(`session events failed: ${response.status} ${payload.text}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
const seen = [];
|
||||
const runId = await runTrigger();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const chunks = buffer.split('\n\n');
|
||||
buffer = chunks.pop() ?? '';
|
||||
for (const chunk of chunks) {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed) continue;
|
||||
seen.push(trimmed);
|
||||
if (trimmed.includes('type":"Error"')) {
|
||||
throw new Error(`session stream error: ${trimmed}`);
|
||||
}
|
||||
if (trimmed.includes('type":"Finish"')) {
|
||||
await reader.cancel().catch(() => {});
|
||||
return { runId, seen };
|
||||
if (response.ok && response.body) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (Date.now() < deadline) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const chunks = buffer.split('\n\n');
|
||||
buffer = chunks.pop() ?? '';
|
||||
for (const chunk of chunks) {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed || trimmed.startsWith(':')) continue;
|
||||
seen.push(trimmed);
|
||||
if (trimmed.includes('"status":"failed"')) {
|
||||
await reader.cancel().catch(() => {});
|
||||
return { runId, sessionId, seen, terminalStatus: 'failed' };
|
||||
}
|
||||
if (trimmed.includes('"status":"succeeded"')) {
|
||||
await reader.cancel().catch(() => {});
|
||||
return { runId, sessionId, seen, terminalStatus: 'succeeded' };
|
||||
}
|
||||
}
|
||||
}
|
||||
await reader.cancel().catch(() => {});
|
||||
}
|
||||
|
||||
await reader.cancel().catch(() => {});
|
||||
throw new Error(`session stream timeout after ${timeoutMs}ms`);
|
||||
while (Date.now() < deadline) {
|
||||
const run = await requestJson(fetchImpl, `${baseUrl}/api/agent/runs/${encodeURIComponent(runId)}`, {
|
||||
headers: { Cookie: cookie },
|
||||
timeoutMs: Math.min(10000, timeoutMs),
|
||||
});
|
||||
const status = run.json?.run?.status ?? null;
|
||||
const resolvedSessionId = run.json?.run?.agent_session_id ?? sessionId;
|
||||
if (status === 'succeeded' || status === 'failed') {
|
||||
return { runId, sessionId: resolvedSessionId, seen, terminalStatus: status };
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
}
|
||||
|
||||
throw new Error(`agent run timeout after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
export { waitForAgentRunCompletion };
|
||||
|
||||
export async function runMemoryV2SessionFlowCli({
|
||||
argv = process.argv.slice(2),
|
||||
env = process.env,
|
||||
@@ -219,10 +233,9 @@ export async function runMemoryV2SessionFlowCli({
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID();
|
||||
const { runId, seen } = await waitForSessionFinish(
|
||||
const { runId, sessionId: activeSessionId, seen, terminalStatus } = await waitForAgentRunCompletion(
|
||||
fetchImpl,
|
||||
options.baseUrl,
|
||||
sessionId,
|
||||
cookie,
|
||||
options.timeoutMs,
|
||||
async () => {
|
||||
@@ -248,14 +261,18 @@ export async function runMemoryV2SessionFlowCli({
|
||||
if (!created.ok || created.status !== 202 || !created.json?.run?.id) {
|
||||
throw new Error(`agent run failed: ${created.status} ${created.text}`);
|
||||
}
|
||||
return created.json.run.id;
|
||||
return {
|
||||
runId: created.json.run.id,
|
||||
sessionId: created.json?.run?.agent_session_id ?? sessionId,
|
||||
};
|
||||
},
|
||||
);
|
||||
checks.push(makeCheck('session_finish_seen', seen.some((chunk) => chunk.includes('type":"Finish"')), {
|
||||
checks.push(makeCheck('agent_run_terminal', terminalStatus === 'succeeded', {
|
||||
terminalStatus,
|
||||
eventCount: seen.length,
|
||||
}));
|
||||
|
||||
const detail = await requestJson(fetchImpl, `${options.baseUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
const detail = await requestJson(fetchImpl, `${options.baseUrl}/api/sessions/${encodeURIComponent(activeSessionId)}`, {
|
||||
headers: { Cookie: cookie },
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
@@ -272,17 +289,22 @@ export async function runMemoryV2SessionFlowCli({
|
||||
const remember = await requestJson(fetchImpl, `${options.baseUrl}/api/user-memory/v1/remember-recent`, {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookie },
|
||||
body: { sessionId },
|
||||
body: { sessionId: activeSessionId },
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
checks.push(makeCheck('remember_recent_ok', remember.ok && remember.json?.ok === true, {
|
||||
status: remember.status,
|
||||
}));
|
||||
checks.push(makeCheck('remember_recent_extracted', remember.ok && Number(remember.json?.analyzed ?? 0) > 0, {
|
||||
status: remember.status,
|
||||
analyzed: remember.json?.analyzed ?? 0,
|
||||
memories: remember.json?.memories ?? 0,
|
||||
}));
|
||||
|
||||
const sync = await requestJson(fetchImpl, `${options.baseUrl}/api/user-memory/v1/sync`, {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookie },
|
||||
body: { sessionId },
|
||||
body: { sessionId: activeSessionId },
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
checks.push(makeCheck('sync_ok', sync.ok && sync.json?.ok === true, {
|
||||
@@ -310,7 +332,7 @@ export async function runMemoryV2SessionFlowCli({
|
||||
ok: checks.every((item) => item.ok),
|
||||
baseUrl: options.baseUrl,
|
||||
user: user.username,
|
||||
sessionId,
|
||||
sessionId: activeSessionId,
|
||||
runId,
|
||||
summary: {
|
||||
assistantPreview: assistantMessages.at(-1)?.content?.[0]?.text?.slice?.(0, 200) ?? null,
|
||||
@@ -326,6 +348,7 @@ export async function runMemoryV2SessionFlowCli({
|
||||
}
|
||||
: null,
|
||||
sessionEventsTail: seen.slice(-4),
|
||||
terminalStatus,
|
||||
},
|
||||
checks,
|
||||
};
|
||||
|
||||
@@ -104,10 +104,10 @@ test('runMemoryV2SessionFlowCli passes against a mocked live session flow', asyn
|
||||
if (url.endsWith('/api/agent/start')) {
|
||||
return jsonResponse({ id: 'session-1' });
|
||||
}
|
||||
if (url.endsWith('/api/sessions/session-1/events')) {
|
||||
if (url.endsWith('/api/agent/runs/run-1/events')) {
|
||||
return sseResponse([
|
||||
'id: 1\ndata: {"type":"Message","message":{"role":"assistant","content":[{"type":"text","text":"hi"}],"metadata":{"userVisible":true}}}\n\n',
|
||||
'id: 2\ndata: {"type":"Finish","reason":"stop"}\n\n',
|
||||
'event: run\ndata: {"run":{"id":"run-1","status":"running","agent_session_id":"session-1"}}\n\n',
|
||||
'event: run\ndata: {"run":{"id":"run-1","status":"succeeded","agent_session_id":"session-1"}}\n\n',
|
||||
]);
|
||||
}
|
||||
if (url.endsWith('/api/agent/runs')) {
|
||||
|
||||
@@ -95,13 +95,14 @@ const useConfiguredPublicBase = ['1', 'true', 'yes', 'on'].includes(
|
||||
const mindSpacePublicBase = (
|
||||
useConfiguredPublicBase && process.env.H5_PUBLIC_BASE_URL
|
||||
? process.env.H5_PUBLIC_BASE_URL
|
||||
: localMindSpacePublicBase
|
||||
: portalUrl
|
||||
).replace(/\/$/, '');
|
||||
process.env.H5_PUBLIC_BASE_URL = mindSpacePublicBase;
|
||||
|
||||
const viteEnv = {
|
||||
VITE_PLAZA_BASE: plazaPublicBase,
|
||||
VITE_MINDSPACE_BASE: mindSpacePublicBase,
|
||||
H5_DEV_PORTAL: portalUrl,
|
||||
};
|
||||
|
||||
const opsEnv = {
|
||||
@@ -158,7 +159,7 @@ try {
|
||||
console.log('');
|
||||
console.log('本地服务:');
|
||||
console.log(` MindSpace UI http://127.0.0.1:${vitePort}/?preview=mindspace`);
|
||||
console.log(` MindSpace URL ${mindSpacePublicBase}`);
|
||||
console.log(` MindSpace URL ${mindSpacePublicBase} (Portal 直链,Agent 交付链接)`);
|
||||
console.log(` Ops 审核后台 http://127.0.0.1:${opsPort}/ops/`);
|
||||
console.log(` API / Portal ${portalUrl}`);
|
||||
console.log(` memind_adm ${adminUrl}`);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 修复 john 教育问卷:问卷页 public + 独立后台页 password。
|
||||
* 用法:node scripts/repair-child-education-survey.mjs
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import { bindWorkspaceHtmlForPageData } from '../page-data-workspace-bind.mjs';
|
||||
import { resolveMindSpaceStorageRoot } from '../mindspace-runtime-config.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFile(path.join(root, '.env'));
|
||||
loadEnvFile(path.join(root, '../../.env.local'));
|
||||
|
||||
const JOHN_USER_ID = '1c99b83b-0454-474f-a5d2-129d34506a32';
|
||||
const WORKSPACE_ROOT = path.join(root, 'MindSpace', JOHN_USER_ID);
|
||||
const ADMIN_PASSWORD = '88888888';
|
||||
const DATASET = 'child_edu_survey';
|
||||
|
||||
const pool = createDbPool();
|
||||
const h5Root = root;
|
||||
const storageRoot = resolveMindSpaceStorageRoot(h5Root);
|
||||
|
||||
const pages = [
|
||||
{
|
||||
relativePath: 'public/child-education-analysis.html',
|
||||
accessMode: 'public',
|
||||
password: null,
|
||||
policy: {
|
||||
accessMode: 'public',
|
||||
datasets: {
|
||||
[DATASET]: {
|
||||
insert: true,
|
||||
read: false,
|
||||
columns: { insert: ['q1_pressure', 'q2_concern', 'q3_action'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
relativePath: 'public/child-education-analysis-admin.html',
|
||||
accessMode: 'password',
|
||||
password: ADMIN_PASSWORD,
|
||||
policy: {
|
||||
accessMode: 'password',
|
||||
datasets: {
|
||||
[DATASET]: {
|
||||
insert: false,
|
||||
read: true,
|
||||
columns: {
|
||||
read: ['id', 'q1_pressure', 'q2_concern', 'q3_action', 'created_at'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
console.log('修复教育问卷发布配置…\n');
|
||||
|
||||
const results = [];
|
||||
for (const page of pages) {
|
||||
const result = await bindWorkspaceHtmlForPageData({
|
||||
pool,
|
||||
h5Root,
|
||||
storageRoot,
|
||||
userId: JOHN_USER_ID,
|
||||
workspaceRoot: WORKSPACE_ROOT,
|
||||
relativePath: page.relativePath,
|
||||
accessMode: page.accessMode,
|
||||
password: page.password,
|
||||
pageDataPolicy: page.policy,
|
||||
});
|
||||
results.push(result);
|
||||
console.log(`✓ ${page.relativePath}`);
|
||||
console.log(` pageId: ${result.pageId}`);
|
||||
console.log(` accessMode: ${result.publicationAccessMode}`);
|
||||
console.log(` workspace: ${result.workspaceUrl}\n`);
|
||||
}
|
||||
|
||||
console.log('完成。测试入口:');
|
||||
console.log(` 问卷:http://127.0.0.1:8081/MindSpace/${JOHN_USER_ID}/public/child-education-analysis.html`);
|
||||
console.log(` 后台:http://127.0.0.1:8081/MindSpace/${JOHN_USER_ID}/public/child-education-analysis-admin.html`);
|
||||
console.log(` 后台口令:${ADMIN_PASSWORD}`);
|
||||
|
||||
await pool.end();
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 重新绑定并刷新 john 的体验调研页发布快照与 Page Data 策略。
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import { bindWorkspaceHtmlForPageData } from '../page-data-workspace-bind.mjs';
|
||||
import { resolveMindSpaceStorageRoot } from '../mindspace-runtime-config.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFile(path.join(root, '.env'));
|
||||
loadEnvFile(path.join(root, '../../.env.local'));
|
||||
|
||||
const JOHN_USER_ID = '1c99b83b-0454-474f-a5d2-129d34506a32';
|
||||
const WORKSPACE_ROOT = path.join(root, 'MindSpace', JOHN_USER_ID);
|
||||
const ADMIN_PASSWORD = '66668888';
|
||||
|
||||
const pool = createDbPool();
|
||||
const h5Root = root;
|
||||
const storageRoot = resolveMindSpaceStorageRoot(h5Root);
|
||||
|
||||
const DATASET = {
|
||||
name: 'tkmind_exp_survey',
|
||||
columns: {
|
||||
insert: ['satisfaction', 'best_features', 'improvement'],
|
||||
read: ['id', 'satisfaction', 'best_features', 'improvement', 'created_at'],
|
||||
},
|
||||
};
|
||||
|
||||
const pages = [
|
||||
{
|
||||
relativePath: 'public/tkmind-experience-survey.html',
|
||||
accessMode: 'public',
|
||||
password: null,
|
||||
policy: {
|
||||
accessMode: 'public',
|
||||
datasets: {
|
||||
[DATASET.name]: {
|
||||
insert: true,
|
||||
read: false,
|
||||
columns: { insert: DATASET.columns.insert },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
relativePath: 'public/tkmind-survey-experience-admin.html',
|
||||
accessMode: 'password',
|
||||
password: ADMIN_PASSWORD,
|
||||
policy: {
|
||||
accessMode: 'password',
|
||||
datasets: {
|
||||
[DATASET.name]: {
|
||||
insert: false,
|
||||
read: true,
|
||||
columns: { read: DATASET.columns.read },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const page of pages) {
|
||||
const result = await bindWorkspaceHtmlForPageData({
|
||||
pool,
|
||||
h5Root,
|
||||
storageRoot,
|
||||
userId: JOHN_USER_ID,
|
||||
workspaceRoot: WORKSPACE_ROOT,
|
||||
relativePath: page.relativePath,
|
||||
accessMode: page.accessMode,
|
||||
password: page.password,
|
||||
pageDataPolicy: page.policy,
|
||||
});
|
||||
console.log(`✓ ${page.relativePath}`);
|
||||
console.log(` pageId: ${result.pageId}`);
|
||||
console.log(` workspace: ${result.workspaceUrl}`);
|
||||
console.log(` publication: ${result.publicationUrl}`);
|
||||
console.log(` policy datasets: ${Object.keys(result.policy?.datasets ?? {}).join(', ')}\n`);
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
resolvePortalBase,
|
||||
snapshotPublicHtml,
|
||||
verifyPageAccess,
|
||||
verifySurveyDelivery,
|
||||
waitForAssistantGrowth,
|
||||
waitForRunTerminal,
|
||||
extractAssistantTexts,
|
||||
@@ -112,6 +113,7 @@ async function runScenario(scenario, port) {
|
||||
const run = await createAgentRun(baseUrl, auth.cookie, {
|
||||
message: step.message,
|
||||
sessionId,
|
||||
selectedChatSkill: step.selectedChatSkill ?? null,
|
||||
});
|
||||
reporter.pass('提交消息', `"${step.message}" → run ${run.runId}`);
|
||||
|
||||
@@ -174,6 +176,22 @@ async function runScenario(scenario, port) {
|
||||
});
|
||||
}
|
||||
|
||||
if (step.expect?.survey) {
|
||||
await verifySurveyDelivery({
|
||||
publishKey,
|
||||
replyText: reply.combined,
|
||||
expect: step.expect.survey,
|
||||
reporter,
|
||||
});
|
||||
}
|
||||
|
||||
const forbidReply = step.expect?.forbidReplyPatterns ?? [];
|
||||
for (const pattern of forbidReply) {
|
||||
if (pattern && reply.combined.includes(pattern)) {
|
||||
reporter.fail('回复禁用模式', `命中 ${pattern}`);
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -75,20 +75,24 @@ export async function loginViaApi(baseUrl, { username, password }, reporter) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildUserMessage(text) {
|
||||
function buildUserMessage(text, { selectedChatSkill = null } = {}) {
|
||||
const metadata = { userVisible: true, displayText: text };
|
||||
if (selectedChatSkill) {
|
||||
metadata.memindRun = { selectedChatSkill };
|
||||
}
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text }],
|
||||
metadata: { userVisible: true, displayText: text },
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAgentRun(baseUrl, cookie, { message, sessionId = null }) {
|
||||
export async function createAgentRun(baseUrl, cookie, { message, sessionId = null, selectedChatSkill = null }) {
|
||||
const requestId = crypto.randomUUID();
|
||||
const body = {
|
||||
request_id: requestId,
|
||||
user_message: buildUserMessage(message),
|
||||
user_message: buildUserMessage(message, { selectedChatSkill }),
|
||||
};
|
||||
if (sessionId) body.session_id = sessionId;
|
||||
|
||||
@@ -301,6 +305,102 @@ export async function verifyPageAccess({
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function verifySurveyDelivery({
|
||||
publishKey,
|
||||
replyText = '',
|
||||
expect = {},
|
||||
reporter,
|
||||
}) {
|
||||
const publishDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey);
|
||||
const publicDir = path.join(publishDir, 'public');
|
||||
const policyDir = path.join(publishDir, '.mindspace', 'page-data-policies');
|
||||
const sqlitePath = path.join(publishDir, '.mindspace', 'private-data.sqlite');
|
||||
|
||||
const forbidReply = expect.forbidReplyPatterns ?? [];
|
||||
for (const pattern of forbidReply) {
|
||||
if (pattern && replyText.includes(pattern)) {
|
||||
reporter.fail('回复禁用模式', `命中 ${pattern}`);
|
||||
}
|
||||
}
|
||||
if (forbidReply.length && !forbidReply.some((pattern) => pattern && replyText.includes(pattern))) {
|
||||
reporter.pass('回复禁用模式', '未出现旁路 API / PLACEHOLDER');
|
||||
}
|
||||
|
||||
let htmlFiles = [];
|
||||
try {
|
||||
const entries = await fs.readdir(publicDir, { withFileTypes: true });
|
||||
htmlFiles = entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.html'))
|
||||
.map((entry) => entry.name);
|
||||
} catch {
|
||||
reporter.fail('问卷 HTML', 'public/ 目录不存在');
|
||||
return false;
|
||||
}
|
||||
|
||||
const surveyLike = htmlFiles.filter((name) => /survey|问卷|feature/i.test(name));
|
||||
const adminLike = htmlFiles.filter((name) => /admin|后台|manage/i.test(name));
|
||||
if (surveyLike.length === 0) {
|
||||
reporter.fail('问卷 HTML', `public/ 中未找到问卷页,现有: ${htmlFiles.join(', ') || '(空)'}`);
|
||||
} else {
|
||||
reporter.pass('问卷 HTML', surveyLike.join(', '));
|
||||
}
|
||||
if (adminLike.length === 0) {
|
||||
reporter.fail('后台 HTML', `public/ 中未找到后台页,现有: ${htmlFiles.join(', ') || '(空)'}`);
|
||||
} else {
|
||||
reporter.pass('后台 HTML', adminLike.join(', '));
|
||||
}
|
||||
|
||||
const forbidHtml = expect.forbidHtmlPatterns ?? [];
|
||||
for (const name of [...surveyLike, ...adminLike]) {
|
||||
const html = await fs.readFile(path.join(publicDir, name), 'utf8');
|
||||
if (!html.includes('page-data-client.js')) {
|
||||
reporter.fail(`${name} 脚本`, '未引用 page-data-client.js');
|
||||
}
|
||||
for (const pattern of forbidHtml) {
|
||||
if (pattern && html.includes(pattern)) {
|
||||
reporter.fail(`${name} 禁用模式`, `命中 ${pattern}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (surveyLike.length && adminLike.length) {
|
||||
reporter.pass('Page Data 客户端', '问卷/后台 HTML 已引用 page-data-client.js');
|
||||
}
|
||||
|
||||
if (expect.requirePolicy) {
|
||||
try {
|
||||
const policies = await fs.readdir(policyDir);
|
||||
const jsonPolicies = policies.filter((name) => name.endsWith('.json'));
|
||||
if (jsonPolicies.length === 0) {
|
||||
reporter.fail('Page Data 策略', 'page-data-policies/ 为空');
|
||||
} else {
|
||||
reporter.pass('Page Data 策略', `${jsonPolicies.length} 个 policy 文件`);
|
||||
}
|
||||
} catch {
|
||||
reporter.fail('Page Data 策略', '缺少 .mindspace/page-data-policies/');
|
||||
}
|
||||
}
|
||||
|
||||
if (expect.requireDataset) {
|
||||
try {
|
||||
await fs.stat(sqlitePath);
|
||||
reporter.pass('私有 SQLite', 'private-data.sqlite 存在');
|
||||
} catch {
|
||||
reporter.fail('私有 SQLite', 'private-data.sqlite 不存在');
|
||||
}
|
||||
}
|
||||
|
||||
const links = extractPublicLinks(replyText, 'http://127.0.0.1:8081');
|
||||
if (links.length >= 2) {
|
||||
reporter.pass('交付链接', `${links.length} 个链接`);
|
||||
} else if (links.length === 1) {
|
||||
reporter.fail('交付链接', '仅 1 个链接,期望问卷 + 后台');
|
||||
} else {
|
||||
reporter.fail('交付链接', '回复中未找到 MindSpace 链接');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function loadScenario(scenarioId) {
|
||||
const scenarioPath = path.join(repoRoot, 'scenarios', `${scenarioId}.json`);
|
||||
const raw = await fs.readFile(scenarioPath, 'utf8');
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 绑定并发布 john 的 TKMind 问卷页面(Page Data API 演示)。
|
||||
* 用法:node scripts/setup-page-data-survey-demo.mjs
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import { bindWorkspaceHtmlForPageData } from '../page-data-workspace-bind.mjs';
|
||||
import { resolveMindSpaceStorageRoot } from '../mindspace-runtime-config.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFile(path.join(root, '.env'));
|
||||
loadEnvFile(path.join(root, '../../.env.local'));
|
||||
|
||||
const JOHN_USER_ID = '1c99b83b-0454-474f-a5d2-129d34506a32';
|
||||
const WORKSPACE_ROOT = path.join(root, 'MindSpace', JOHN_USER_ID);
|
||||
const ADMIN_PASSWORD = '88888888';
|
||||
|
||||
const SURVEY_FORM_POLICY = {
|
||||
accessMode: 'public',
|
||||
datasets: {
|
||||
survey_responses: {
|
||||
insert: true,
|
||||
read: false,
|
||||
columns: {
|
||||
insert: ['q1_feature', 'q2_usage', 'q3_suggestion'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const SURVEY_ADMIN_POLICY = {
|
||||
accessMode: 'password',
|
||||
datasets: {
|
||||
survey_responses: {
|
||||
insert: false,
|
||||
read: true,
|
||||
columns: {
|
||||
read: ['id', 'q1_feature', 'q2_usage', 'q3_suggestion', 'created_at'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const pool = createDbPool();
|
||||
const h5Root = root;
|
||||
const storageRoot = resolveMindSpaceStorageRoot(h5Root);
|
||||
|
||||
const pages = [
|
||||
{
|
||||
relativePath: 'public/tkmind-survey.html',
|
||||
accessMode: 'public',
|
||||
password: null,
|
||||
policy: SURVEY_FORM_POLICY,
|
||||
},
|
||||
{
|
||||
relativePath: 'public/tkmind-survey-admin.html',
|
||||
accessMode: 'password',
|
||||
password: ADMIN_PASSWORD,
|
||||
policy: SURVEY_ADMIN_POLICY,
|
||||
},
|
||||
];
|
||||
|
||||
console.log('绑定并发布 Page Data 问卷演示页…\n');
|
||||
|
||||
const results = [];
|
||||
for (const page of pages) {
|
||||
const result = await bindWorkspaceHtmlForPageData({
|
||||
pool,
|
||||
h5Root,
|
||||
storageRoot,
|
||||
userId: JOHN_USER_ID,
|
||||
workspaceRoot: WORKSPACE_ROOT,
|
||||
relativePath: page.relativePath,
|
||||
accessMode: page.accessMode,
|
||||
password: page.password,
|
||||
pageDataPolicy: page.policy,
|
||||
});
|
||||
results.push(result);
|
||||
console.log(`✓ ${page.relativePath}`);
|
||||
console.log(` pageId: ${result.pageId}`);
|
||||
console.log(` workspace: ${result.workspaceUrl}`);
|
||||
console.log(` publication: ${result.publicationUrl}\n`);
|
||||
}
|
||||
|
||||
console.log('完成。测试入口:');
|
||||
console.log(` 问卷:${results[0].workspaceUrl}`);
|
||||
console.log(` 后台:${results[1].workspaceUrl}`);
|
||||
console.log(` 后台密码:${ADMIN_PASSWORD}`);
|
||||
|
||||
await pool.end();
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verify publication delivery chain: file layer vs /u/ publication layer.
|
||||
*/
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const base = process.env.VERIFY_BASE_URL || 'http://127.0.0.1:8081';
|
||||
const username = process.env.VERIFY_USER || 'john';
|
||||
const password = process.env.VERIFY_PASSWORD || '888888';
|
||||
const userId = process.env.VERIFY_USER_ID || '1c99b83b-0454-474f-a5d2-129d34506a32';
|
||||
|
||||
const checks = [];
|
||||
|
||||
function record(name, ok, details = {}) {
|
||||
checks.push({ name, ok: Boolean(ok), ...details });
|
||||
const mark = ok ? 'PASS' : 'FAIL';
|
||||
console.log(`${mark} ${name}${details.detail ? ` — ${details.detail}` : ''}`);
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const res = await fetch(`${base}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const cookie = res.headers.get('set-cookie')?.split(';', 1)[0] ?? '';
|
||||
record('login', res.ok && cookie, { detail: `status=${res.status}` });
|
||||
return cookie;
|
||||
}
|
||||
|
||||
async function headOrGet(url, { cookie } = {}) {
|
||||
const res = await fetch(url, {
|
||||
headers: cookie ? { Cookie: cookie } : {},
|
||||
redirect: 'manual',
|
||||
});
|
||||
const text = res.status >= 400 ? await res.text().catch(() => '') : '';
|
||||
return { status: res.status, text: text.slice(0, 120) };
|
||||
}
|
||||
|
||||
async function waitRun(cookie, sessionId, runId) {
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
const res = await fetch(`${base}/api/agent/runs/${runId}`, { headers: { Cookie: cookie } });
|
||||
const json = await res.json().catch(() => null);
|
||||
const status = json?.run?.status;
|
||||
if (status === 'succeeded' || status === 'failed') return { status, run: json?.run };
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
}
|
||||
return { status: 'timeout' };
|
||||
}
|
||||
|
||||
async function generateTestPage(cookie) {
|
||||
const started = await fetch(`${base}/api/agent/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||||
body: '{}',
|
||||
});
|
||||
const sessionId = (await started.json()).id;
|
||||
const requestId = crypto.randomUUID();
|
||||
const prompt = `请用 write_file 创建 public/verify-deliver-${Date.now()}.html,标题「发布链路验证页」,内容简单即可。完成后只回复「已生成」。`;
|
||||
const created = await fetch(`${base}/api/agent/runs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
request_id: requestId,
|
||||
user_message: {
|
||||
role: 'user',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
content: [{ type: 'text', text: prompt }],
|
||||
metadata: { userVisible: true, agentVisible: true, displayText: prompt },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const runId = (await created.json())?.run?.id;
|
||||
const finished = await waitRun(cookie, sessionId, runId);
|
||||
record('agent_run_generate_page', finished.status === 'succeeded', {
|
||||
detail: `run=${runId} status=${finished.status}`,
|
||||
});
|
||||
return { sessionId, runId, finished };
|
||||
}
|
||||
|
||||
async function findLatestVerifyPage(cookie) {
|
||||
const res = await fetch(`${base}/api/mindspace/v1/pages?limit=20`, {
|
||||
headers: { Cookie: cookie },
|
||||
});
|
||||
const json = await res.json().catch(() => null);
|
||||
const items = json?.data ?? [];
|
||||
const match = items.find((p) => /发布链路验证|verify-deliver/i.test(String(p.title ?? '')))
|
||||
?? items.find((p) => String(p.workspaceRelativePath ?? '').includes('verify-deliver'));
|
||||
return match ?? items[0] ?? null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`\n=== Publication delivery verify @ ${base} ===\n`);
|
||||
|
||||
const cookie = await login();
|
||||
|
||||
// 1. Existing publication route (/u/...)
|
||||
for (const slug of ['page-2b201736', 'page-0360d67a']) {
|
||||
const url = `${base}/u/john/pages/${slug}`;
|
||||
const { status, text } = await headOrGet(url);
|
||||
record(`publication_route /u/john/pages/${slug}`, status === 200, {
|
||||
detail: `HTTP ${status}${status >= 500 ? ` ${text}` : ''}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. File layer (/MindSpace/.../public/...)
|
||||
const fileUrl = `${base}/MindSpace/${userId}/public/supplier-submit.html`;
|
||||
const fileRes = await headOrGet(fileUrl);
|
||||
record('file_layer MindSpace/public/supplier-submit.html', fileRes.status === 200, {
|
||||
detail: `HTTP ${fileRes.status}`,
|
||||
});
|
||||
|
||||
// 3. Runtime public base
|
||||
const runtime = await fetch(`${base}/api/runtime/status`).then((r) => r.json()).catch(() => null);
|
||||
const publicBase = runtime?.publicBaseUrl ?? runtime?.publish?.publicBaseUrl ?? null;
|
||||
record('runtime public base uses portal', String(publicBase ?? '').includes(':8081'), {
|
||||
detail: publicBase ?? 'missing',
|
||||
});
|
||||
|
||||
// 4. Generate new page and check publication closure
|
||||
await generateTestPage(cookie);
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const page = await findLatestVerifyPage(cookie);
|
||||
if (page) {
|
||||
const detail = await fetch(`${base}/api/mindspace/v1/pages/${page.id}`, {
|
||||
headers: { Cookie: cookie },
|
||||
}).then((r) => r.json()).catch(() => null);
|
||||
const pub = detail?.data?.publication;
|
||||
record('new_page_has_publication', Boolean(pub?.id && pub?.status === 'online'), {
|
||||
detail: pub ? `${pub.status} ${pub.publicUrl ?? ''}` : 'no publication',
|
||||
});
|
||||
if (pub?.publicUrl) {
|
||||
const pubPath = pub.publicUrl.replace(/^https?:\/\/[^/]+/, '');
|
||||
const pubCheck = await headOrGet(`${base}${pubPath.startsWith('/') ? pubPath : `/${pubPath}`}`);
|
||||
record('new_page_publication_url_accessible', pubCheck.status === 200, {
|
||||
detail: `HTTP ${pubCheck.status} ${pub.publicUrl}`,
|
||||
});
|
||||
}
|
||||
const rel = page.workspaceRelativePath ?? page.publicationUrl;
|
||||
if (page.workspaceRelativePath) {
|
||||
const wsUrl = `${base}/MindSpace/${userId}/${page.workspaceRelativePath}`;
|
||||
const ws = await headOrGet(wsUrl);
|
||||
record('new_page_file_layer', ws.status === 200, { detail: `HTTP ${ws.status}` });
|
||||
}
|
||||
} else {
|
||||
record('new_page_found_in_list', false, { detail: 'no matching page after generate' });
|
||||
}
|
||||
|
||||
const ok = checks.every((c) => c.ok);
|
||||
console.log(`\n=== ${ok ? 'ALL PASS' : 'SOME FAILED'} (${checks.filter((c) => c.ok).length}/${checks.length}) ===\n`);
|
||||
process.exitCode = ok ? 0 : 1;
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user