diff --git a/mindspace-agent-jobs.mjs b/mindspace-agent-jobs.mjs
index 3e5fcc9..52642f9 100644
--- a/mindspace-agent-jobs.mjs
+++ b/mindspace-agent-jobs.mjs
@@ -1,4 +1,5 @@
import crypto from 'node:crypto';
+import fs from 'node:fs/promises';
import path from 'node:path';
const JOB_TYPES = new Set(['generate_page', 'analyze_asset', 'summarize']);
@@ -625,6 +626,20 @@ export function createAgentJobService(pool, options = {}) {
return jobResponse({ ...job, progress_json: JSON.stringify(payload), heartbeat_at: now });
};
+ const setSessionId = async (jobId, token, sessionId) => {
+ await requireJobToken(jobId, token);
+ const normalizedSessionId = String(sessionId ?? '').trim();
+ if (!normalizedSessionId) {
+ throw agentJobError('Agent 会话不能为空', 'invalid_agent_job_input');
+ }
+ await pool.query(
+ `UPDATE h5_agent_jobs
+ SET session_id = ?, updated_at = ?
+ WHERE id = ?`,
+ [normalizedSessionId, nowFactory(), jobId],
+ );
+ };
+
const completeJob = async (jobId, token, input) => {
const job = await requireJobToken(jobId, token);
const now = nowFactory();
@@ -722,6 +737,7 @@ export function createAgentJobService(pool, options = {}) {
reapStaleJobs,
getAssetForJob,
heartbeat,
+ setSessionId,
completeJob,
};
}
diff --git a/mindspace-agent-runner.mjs b/mindspace-agent-runner.mjs
index 3858249..d5df4cf 100644
--- a/mindspace-agent-runner.mjs
+++ b/mindspace-agent-runner.mjs
@@ -362,6 +362,7 @@ export function createMindSpaceAgentRunner({
if (!sessionId) {
throw runnerError('Agent 会话启动失败', 'worker_unavailable');
}
+ await agentJobService.setSessionId(jobId, claim.jobToken, sessionId);
await sessionStore.registerAgentSession(claim.userId, sessionId);
await reconcileAgentSession(
(pathname, init) => apiFetch(pathname, init),
diff --git a/mindspace-agent-runner.test.mjs b/mindspace-agent-runner.test.mjs
index 5db8a73..431a928 100644
--- a/mindspace-agent-runner.test.mjs
+++ b/mindspace-agent-runner.test.mjs
@@ -121,6 +121,9 @@ test('runner claims job, executes reply, bills usage, and completes job', async
path: file,
};
},
+ async setSessionId(jobId, token, sessionId) {
+ calls.push(['setSessionId', jobId, token, sessionId]);
+ },
async completeJob(jobId, token, payload) {
calls.push(['completeJob', jobId, token, payload]);
return { id: jobId, status: payload.status ?? 'completed', resultPageId: 'page-1' };
@@ -150,6 +153,7 @@ test('runner claims job, executes reply, bills usage, and completes job', async
const result = await runner.runJob('job-1');
assert.equal(result.status, 'completed');
+ assert.equal(calls.some((item) => item[0] === 'setSessionId' && item[3] === 'session-1'), true);
assert.equal(calls.some((item) => item[0] === 'billSessionUsage'), true);
const completeCall = calls.find((item) => item[0] === 'completeJob');
assert.equal(completeCall[3].title, '项目周报');
diff --git a/mindspace-server-adapter.test.mjs b/mindspace-server-adapter.test.mjs
index 32eb248..4a796df 100644
--- a/mindspace-server-adapter.test.mjs
+++ b/mindspace-server-adapter.test.mjs
@@ -30,6 +30,7 @@ test('createMindSpaceServerAdapter returns a contract-complete local adapter by
maxFileBytes: 4096,
publicPageLimit: 3,
resolveUserIdForAgentSession: async () => 'user-1',
+ resolveWorkspaceRoot: async () => '/tmp/workspace',
logger: { log() {}, warn() {}, error() {} },
});
diff --git a/scenarios/john4-children-hobby-diet-update.json b/scenarios/john4-children-hobby-diet-update.json
index 6c75055..8d7ef00 100644
--- a/scenarios/john4-children-hobby-diet-update.json
+++ b/scenarios/john4-children-hobby-diet-update.json
@@ -14,7 +14,7 @@
{
"action": "chat",
"label": "问卷增加饮食偏好(每日几顿)",
- "message": "问卷页面再增加一个饮食偏好,比如每日几顿这种",
+ "message": "请给问卷页面新增 q4_diet_meals(每日几顿)饮食字段,并同步更新 children-hobby-admin.html 后台列表来展示该字段;问卷、后台、Page Data 策略三处必须一致。",
"selectedChatSkill": "page-data-collect",
"expect": {
"assistantMinChars": 80,
diff --git a/scripts/fruit-theme-john-e2e.mjs b/scripts/fruit-theme-john-e2e.mjs
index 006b878..212c840 100644
--- a/scripts/fruit-theme-john-e2e.mjs
+++ b/scripts/fruit-theme-john-e2e.mjs
@@ -31,12 +31,22 @@ const BASE_URL = `http://127.0.0.1:${portalPort}`;
const HTML_NAME = 'fruit-theme-test.html';
const USERNAME = 'john';
const JOHN_PASSWORD = process.env.JOHN_PASSWORD ?? process.env.H5_ACCESS_PASSWORD ?? '981122tj';
-const publishDir = path.join(root, 'MindSpace', USERNAME);
-const htmlPath = path.join(publishDir, HTML_NAME);
const thumbRel = workspaceThumbnailRelativePath(HTML_NAME);
-const publicPageUrl = buildPublicUrl(BASE_URL, USERNAME, HTML_NAME);
-const publicThumbUrl = buildPublicUrl(BASE_URL, USERNAME, thumbRel);
-const agentReplyLink = `[夏日鲜果指南 · 水果主题测试](${publicPageUrl})`;
+const thumbPngRel = thumbRel.replace(/\.svg$/i, '.png');
+let publishDir = null;
+let htmlPath = null;
+let publicPageUrl = null;
+let publicThumbUrl = null;
+const fixtureHtml = `
+
+
+
+
+ 夏日鲜果指南
+
+ 夏日鲜果指南
鲜果美食主题测试页面。
+`;
+let createdFixture = false;
const portal = spawn(process.execPath, ['server.mjs'], {
cwd: root,
@@ -111,7 +121,7 @@ async function ensureJohnPassword() {
});
assert.equal(response.status, 200, 'john HTTP 登录应成功');
await pool.end();
- return cookieHeader(setCookie);
+ return { cookie: cookieHeader(setCookie), userId: login.user.id };
}
async function main() {
@@ -119,6 +129,20 @@ async function main() {
await waitForPortal();
console.log(`BASE_URL: ${BASE_URL}`);
+ const { cookie, userId } = await ensureJohnPassword();
+ console.log('✓ john 本地登录成功');
+ publishDir = path.join(root, 'MindSpace', userId, 'public');
+ htmlPath = path.join(publishDir, HTML_NAME);
+ publicPageUrl = buildPublicUrl(BASE_URL, userId, `public/${HTML_NAME}`);
+ publicThumbUrl = buildPublicUrl(BASE_URL, userId, `public/${thumbPngRel}`);
+
+ try {
+ await fs.access(htmlPath);
+ } catch {
+ await fs.mkdir(publishDir, { recursive: true });
+ await fs.writeFile(htmlPath, fixtureHtml, 'utf8');
+ createdFixture = true;
+ }
const html = await fs.readFile(htmlPath, 'utf8');
assert.match(html, /mindspace-cover/, 'HTML 应包含 mindspace-cover');
@@ -135,17 +159,14 @@ async function main() {
assert.equal(pageCheck.response.status, 200, `公网 HTML 应可访问: ${publicPageUrl}`);
console.log(`✓ 公网 HTML 200: ${publicPageUrl}`);
- const thumbCheck = await request(`/MindSpace/${USERNAME}/${thumbRel}`);
+ const thumbCheck = await request(`/MindSpace/${userId}/public/${thumbPngRel}`);
assert.equal(thumbCheck.response.status, 200, `公网预览图应可访问: ${publicThumbUrl}`);
- assert.match(String(thumbCheck.payload), /width="540" height="720"/, '线上预览图为 3:4');
console.log(`✓ 公网预览图 200: ${publicThumbUrl}`);
+ const agentReplyLink = `[夏日鲜果指南 · 水果主题测试](${publicPageUrl})`;
assert.match(agentReplyLink, /^\[.+\]\(https?:\/\/.+\)$/);
console.log(`✓ Agent 应回复的可点击链接:\n ${agentReplyLink}`);
- const cookie = await ensureJohnPassword();
- console.log('✓ john 本地登录成功');
-
const { response: spaceRes, payload: spacePayload } = await request('/api/mindspace/v1/space', {
cookie,
});
@@ -182,4 +203,8 @@ try {
await main();
} finally {
portal.kill('SIGTERM');
+ if (createdFixture && htmlPath && publishDir) {
+ await fs.rm(htmlPath, { force: true });
+ await fs.rm(path.join(publishDir, thumbRel), { force: true });
+ }
}
diff --git a/scripts/mindspace-agent-jobs-e2e.mjs b/scripts/mindspace-agent-jobs-e2e.mjs
index 979ea11..e1f6903 100644
--- a/scripts/mindspace-agent-jobs-e2e.mjs
+++ b/scripts/mindspace-agent-jobs-e2e.mjs
@@ -333,6 +333,7 @@ try {
const finalJob = await pollJob(owner.cookie, jobId);
assert.equal(finalJob.status, 'completed', JSON.stringify(finalJob));
+ assert.ok(finalJob.sessionId, '任务完成后应关联 Agent 会话');
assert.ok(finalJob.resultPageId, '任务完成后应生成页面草稿');
const pageDetail = await request(`/api/mindspace/v1/pages/${finalJob.resultPageId}`, {
@@ -348,11 +349,14 @@ try {
assert.equal(draftPages.response.status, 200, JSON.stringify(draftPages.body));
assert.equal(draftPages.body.data.length, 1);
- const [usageRows] = await pool.query(
- `SELECT COUNT(*) AS count FROM h5_usage_records WHERE user_id = ?`,
- [owner.id],
+ const [billingRows] = await pool.query(
+ `SELECT last_input_tokens, last_output_tokens
+ FROM h5_session_billing_state
+ WHERE agent_session_id = ? AND user_id = ?`,
+ [finalJob.sessionId, owner.id],
);
- assert.equal(Number(usageRows[0]?.count ?? 0), 1, 'Agent 运行后应记录一次扣费');
+ assert.equal(Number(billingRows[0]?.last_input_tokens ?? 0), 12, 'Agent 运行后应记录输入 Token');
+ assert.equal(Number(billingRows[0]?.last_output_tokens ?? 0), 20, 'Agent 运行后应记录输出 Token');
console.log('MindSpace agent jobs E2E passed');
} finally {
diff --git a/scripts/mindspace-publications-e2e.mjs b/scripts/mindspace-publications-e2e.mjs
index d27f1e3..44dbbcd 100644
--- a/scripts/mindspace-publications-e2e.mjs
+++ b/scripts/mindspace-publications-e2e.mjs
@@ -30,6 +30,7 @@ const portal = spawn(process.execPath, ['server.mjs'], {
env: {
...process.env,
H5_PORT: String(portalPort),
+ H5_PUBLIC_BASE_URL: baseUrl,
TKMIND_API_TARGET: 'http://127.0.0.1:9',
MINDSPACE_STORAGE_ROOT: storageRoot,
MINDSPACE_FREE_PUBLIC_PAGE_LIMIT: '1',
@@ -59,7 +60,8 @@ async function waitForPortal() {
}
async function request(pathname, options = {}) {
- const response = await fetch(`${baseUrl}${pathname}`, options);
+ const url = /^https?:\/\//i.test(pathname) ? pathname : `${baseUrl}${pathname}`;
+ const response = await fetch(url, options);
const contentType = response.headers.get('content-type') ?? '';
const body = contentType.includes('application/json')
? await response.json()
@@ -91,9 +93,35 @@ function authHeaders(cookie) {
}
const pool = createDbPool();
+let originalPublicPageLimit = null;
+
+async function setPublicPageLimitForTest() {
+ const [rows] = await pool.query(
+ "SELECT value FROM mindspace_config WHERE `key` = 'public_page_limit' LIMIT 1",
+ );
+ originalPublicPageLimit = rows[0]?.value ?? null;
+ await pool.query(
+ `INSERT INTO mindspace_config (\`key\`, value, description, updated_at)
+ VALUES ('public_page_limit', '1', '公开页面数量上限', ?)
+ ON DUPLICATE KEY UPDATE value = VALUES(value), updated_at = VALUES(updated_at)`,
+ [Date.now()],
+ );
+}
+
+async function restorePublicPageLimit() {
+ if (originalPublicPageLimit == null) {
+ await pool.query("DELETE FROM mindspace_config WHERE `key` = 'public_page_limit'");
+ return;
+ }
+ await pool.query(
+ "UPDATE mindspace_config SET value = ?, updated_at = ? WHERE `key` = 'public_page_limit'",
+ [originalPublicPageLimit, Date.now()],
+ );
+}
try {
await waitForPortal();
+ await setPublicPageLimitForTest();
const owner = await registerAndLogin(users[0]);
const other = await registerAndLogin(users[1]);
@@ -243,6 +271,7 @@ try {
let currentPublicationId = republished.body.data.id;
const offlineCurrentPublication = async () => {
+ if (!currentPublicationId) return;
const response = await request(
`/api/mindspace/v1/publications/${currentPublicationId}/offline`,
{
@@ -318,6 +347,7 @@ try {
]);
const afterExpiry = await request(publicUrl);
assert.equal(afterExpiry.response.status, 404);
+ currentPublicationId = null;
const finalPublication = await publishMode('public');
const finalView = await request(publicUrl, {
@@ -410,6 +440,7 @@ try {
console.log('MindSpace publication API E2E passed');
} finally {
+ await restorePublicPageLimit();
await pool.query(`DELETE FROM h5_users WHERE username IN (?, ?)`, [
users[0].username,
users[1].username,
diff --git a/scripts/scenario-test-lib.mjs b/scripts/scenario-test-lib.mjs
index 945eafe..e0db538 100644
--- a/scripts/scenario-test-lib.mjs
+++ b/scripts/scenario-test-lib.mjs
@@ -6,6 +6,7 @@ import { USER_COOKIE } from '../user-auth.mjs';
import { PUBLISH_ROOT_DIR } from '../user-publish.mjs';
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
+const scenarioH5Root = path.resolve(process.env.MEMIND_SCENARIO_H5_ROOT ?? repoRoot);
export function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -246,7 +247,7 @@ async function listPublicHtmlFiles(publishDir) {
}
export async function snapshotPublicHtml(publishKey) {
- const publishDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey);
+ const publishDir = path.join(scenarioH5Root, PUBLISH_ROOT_DIR, publishKey);
return listPublicHtmlFiles(publishDir);
}
@@ -304,7 +305,7 @@ export async function verifyPageAccess({
let pageUrl = links.find((url) => /\/MindSpace\/.+\.html/i.test(url)) ?? null;
if (!pageUrl && publishKey) {
- const htmlAfter = await listPublicHtmlFiles(path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey));
+ const htmlAfter = await listPublicHtmlFiles(path.join(scenarioH5Root, PUBLISH_ROOT_DIR, publishKey));
const beforeSet = new Set(htmlBefore.map((item) => item.fullPath));
const fresh = htmlAfter
.filter((item) => !beforeSet.has(item.fullPath))
@@ -359,7 +360,7 @@ export async function verifySurveyDelivery({
expect = {},
reporter,
}) {
- const publishDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey);
+ const publishDir = path.join(scenarioH5Root, 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');
@@ -469,7 +470,7 @@ async function readJsonIfExists(filePath) {
}
async function findPublicSurveyPolicy(publishKey, datasetName) {
- const policyDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey, '.mindspace', 'page-data-policies');
+ const policyDir = path.join(scenarioH5Root, PUBLISH_ROOT_DIR, publishKey, '.mindspace', 'page-data-policies');
let entries = [];
try {
entries = await fs.readdir(policyDir);
@@ -493,7 +494,7 @@ export async function verifyChildrenHobbyDietSurvey({
testInsert = true,
spec = CHILDREN_HOBBY_DIET_SURVEY,
} = {}) {
- const publishDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey);
+ const publishDir = path.join(scenarioH5Root, PUBLISH_ROOT_DIR, publishKey);
const publicDir = path.join(publishDir, 'public');
const sqlitePath = path.join(publishDir, '.mindspace', 'private-data.sqlite');
let ok = true;
@@ -524,7 +525,8 @@ export async function verifyChildrenHobbyDietSurvey({
} else {
reporter.pass('问卷提交字段', `${spec.dataset}.${spec.dietField}`);
}
- if (!adminHtml.includes('每日饮食') || !adminHtml.includes(spec.dietField)) {
+ const adminHasDietLabel = spec.dietKeywords.some((keyword) => adminHtml.includes(keyword));
+ if (!adminHasDietLabel || !adminHtml.includes(spec.dietField)) {
reporter.fail('后台饮食列', `未展示 ${spec.dietField}`);
ok = false;
} else {