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 {