Add TKMind platform extensions, H5/MindSpace stack, and deployment tooling.
Deploy Documentation / deploy (push) Has been cancelled
Canary / Prepare Version (push) Has been cancelled
Canary / build-cli (push) Has been cancelled
Canary / Upload Install Script (push) Has been cancelled
Canary / bundle-desktop (push) Has been cancelled
Canary / bundle-desktop-intel (push) Has been cancelled
Canary / bundle-desktop-linux (push) Has been cancelled
Canary / bundle-desktop-windows (push) Has been cancelled
Canary / bundle-desktop-windows-cuda (push) Has been cancelled
Canary / Release (push) Has been cancelled
Unused Dependencies / machete (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / Check Rust Code Format (push) Has been cancelled
CI / Build and Test Rust Project (push) Has been cancelled
CI / Build Rust Project on Windows (push) Has been cancelled
CI / Check MSRV (push) Has been cancelled
CI / Lint Rust Code (push) Has been cancelled
CI / Check Generated Schemas are Up-to-Date (push) Has been cancelled
CI / Test and Lint Electron Desktop App (push) Has been cancelled
CI / H5 Plaza Tests and Build (push) Has been cancelled
Live Provider Tests / check-fork (push) Has been cancelled
Live Provider Tests / changes (push) Has been cancelled
Live Provider Tests / Build Binary (push) Has been cancelled
Live Provider Tests / Smoke Tests (push) Has been cancelled
Live Provider Tests / Smoke Tests (Code Execution) (push) Has been cancelled
Live Provider Tests / Compaction Tests (push) Has been cancelled
Live Provider Tests / goose server HTTP integration tests (push) Has been cancelled
Publish Ask AI Bot Docker Image / docker (push) Has been cancelled
Publish Docker Image / docker (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled

Fork goose with custom MCP widgets, platform extensions (aider, git, web, search),
MindSpace H5 backend/frontend, Plaza/Ops UIs, and deploy scripts for tkmind.cn.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-14 21:30:20 +08:00
parent e5fd568e01
commit 4e21ca937a
359 changed files with 70658 additions and 56 deletions
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const portalPort = Number(process.env.H5_PORT ?? 8081);
const portalUrl = `http://127.0.0.1:${portalPort}`;
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.local'));
loadEnvFile(path.join(root, '.env'));
function spawnChild(command, args, label) {
const child = spawn(command, args, {
cwd: root,
env: process.env,
stdio: 'inherit',
});
child.on('exit', (code, signal) => {
if (signal) return;
if (code && code !== 0) {
console.error(`[${label}] exited with code ${code}`);
shutdown(code ?? 1);
}
});
return child;
}
let server;
let vite;
let stopping = false;
function shutdown(code = 0) {
if (stopping) return;
stopping = true;
server?.kill('SIGTERM');
vite?.kill('SIGTERM');
setTimeout(() => process.exit(code), 300);
}
process.on('SIGINT', () => shutdown(0));
process.on('SIGTERM', () => shutdown(0));
async function waitForPortal(retries = 40) {
for (let i = 0; i < retries; i += 1) {
try {
const res = await fetch(`${portalUrl}/auth/status`);
if (res.ok) return;
} catch {
// portal still starting
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Portal 未在 ${portalUrl} 启动,请检查 MySQL 配置与 ${portalPort} 端口`);
}
console.log('==> 启动 portal (server.mjs)...');
server = spawnChild('node', ['server.mjs'], 'portal');
try {
await waitForPortal();
console.log(`==> Portal 就绪: ${portalUrl}`);
console.log('==> 启动 Vite @ http://127.0.0.1:5173');
console.log('==> 我的空间 UI 预览: http://127.0.0.1:5173/?preview=mindspace');
vite = spawnChild('npx', ['vite'], 'vite');
} catch (err) {
console.error(err instanceof Error ? err.message : err);
shutdown(1);
}
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env node
/**
* john 账号水果主题 E2E(仅本地):工作区 HTML → 预览图 → 保存到空间 → 可点击链接
* 用法:node scripts/fruit-theme-john-e2e.mjs
* 环境:自动启动本地 server(MINDSPACE_E2E_PORT);勿对生产 URL 跑本脚本
*/
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadH5Environment } from './load-env.mjs';
import { createDbPool } from '../db.mjs';
import { createUserAuth } from '../user-auth.mjs';
import { buildPublicUrl } from '../user-publish.mjs';
import {
ensureWorkspaceHtmlThumbnail,
workspaceThumbnailRelativePath,
} from '../mindspace-workspace-thumbnails.mjs';
import { isModernFeedThumbnail } from '../mindspace-thumbnails.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
loadH5Environment(import.meta.dirname);
if (/go\.tkmind\.cn|goo\.tkmind\.cn|120\.26\.184\.105/i.test(process.env.BASE_URL ?? '')) {
throw new Error('本脚本仅用于本地验证,请勿设置生产 BASE_URL');
}
const portalPort = Number(process.env.MINDSPACE_E2E_PORT ?? 18140);
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 portal = spawn(process.execPath, ['server.mjs'], {
cwd: root,
env: {
...process.env,
H5_PORT: String(portalPort),
H5_PUBLIC_BASE_URL: BASE_URL,
TKMIND_API_TARGET: 'http://127.0.0.1:9',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let portalLogs = '';
portal.stdout.on('data', (chunk) => {
portalLogs += chunk.toString();
});
portal.stderr.on('data', (chunk) => {
portalLogs += chunk.toString();
});
async function waitForPortal() {
for (let attempt = 0; attempt < 80; attempt += 1) {
if (portal.exitCode != null) throw new Error(`本地 server 提前退出\n${portalLogs}`);
try {
const response = await fetch(`${BASE_URL}/auth/status`);
if (response.ok) return;
} catch {
// still starting
}
await new Promise((resolve) => setTimeout(resolve, 150));
}
throw new Error(`本地 server 启动超时\n${portalLogs}`);
}
function cookieHeader(setCookie) {
if (!setCookie) return '';
const parts = Array.isArray(setCookie) ? setCookie : [setCookie];
return parts.map((c) => c.split(';')[0]).join('; ');
}
async function request(pathname, { method = 'GET', body, cookie } = {}) {
const headers = {};
if (body) headers['Content-Type'] = 'application/json';
if (cookie) headers.Cookie = cookie;
const response = await fetch(`${BASE_URL}${pathname}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
const type = response.headers.get('content-type') ?? '';
const payload = type.includes('json') ? await response.json() : await response.text();
return { response, payload, setCookie: response.headers.getSetCookie?.() ?? response.headers.get('set-cookie') };
}
async function ensureJohnPassword() {
const pool = createDbPool();
const auth = createUserAuth(pool, { h5Root: root });
let login = await auth.login({ username: USERNAME, password: JOHN_PASSWORD, ip: '127.0.0.1' });
if (!login.ok) {
const [rows] = await pool.query(`SELECT id, email FROM h5_users WHERE username = ? LIMIT 1`, [
USERNAME,
]);
const user = rows[0];
if (!user) throw new Error('本地数据库无 john 用户,请先注册或导入数据');
const email = user.email ?? `${USERNAME}@local.test`;
if (!user.email) {
await pool.query(`UPDATE h5_users SET email = ? WHERE id = ?`, [email, user.id]);
}
const reset = await auth.resetPassword({ username: USERNAME, email, password: JOHN_PASSWORD });
assert.equal(reset.ok, true, reset.message ?? '重置 john 本地密码失败');
login = await auth.login({ username: USERNAME, password: JOHN_PASSWORD, ip: '127.0.0.1' });
}
assert.equal(login.ok, true, login.error ?? 'john 本地登录失败');
const { response, setCookie } = await request('/auth/login', {
method: 'POST',
body: { username: USERNAME, password: JOHN_PASSWORD },
});
assert.equal(response.status, 200, 'john HTTP 登录应成功');
await pool.end();
return cookieHeader(setCookie);
}
async function main() {
console.log('=== john 水果主题 E2E(本地) ===');
await waitForPortal();
console.log(`BASE_URL: ${BASE_URL}`);
const html = await fs.readFile(htmlPath, 'utf8');
assert.match(html, /mindspace-cover/, 'HTML 应包含 mindspace-cover');
const svg = await ensureWorkspaceHtmlThumbnail(publishDir, HTML_NAME, html, {
title: '夏日鲜果指南',
subtitle: '水果主题测试',
});
assert.equal(isModernFeedThumbnail(svg), true, '工作区预览图应为新版 3:4');
assert.match(svg, /美食|鲜果|FRUIT/i, '预览图应体现水果/美食主题');
const hasPhoto = /preserveAspectRatio="xMidYMid slice"/.test(svg);
console.log(`✓ 工作区预览图已生成: ${thumbRel}(含主图: ${hasPhoto ? '是' : '否'})`);
const pageCheck = await request(`/MindSpace/${USERNAME}/${HTML_NAME}`);
assert.equal(pageCheck.response.status, 200, `公网 HTML 应可访问: ${publicPageUrl}`);
console.log(`✓ 公网 HTML 200: ${publicPageUrl}`);
const thumbCheck = await request(`/MindSpace/${USERNAME}/${thumbRel}`);
assert.equal(thumbCheck.response.status, 200, `公网预览图应可访问: ${publicThumbUrl}`);
assert.match(String(thumbCheck.payload), /width="540" height="720"/, '线上预览图为 3:4');
console.log(`✓ 公网预览图 200: ${publicThumbUrl}`);
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,
});
assert.equal(spaceRes.status, 200, JSON.stringify(spacePayload));
const saveBody = {
title: '夏日鲜果指南 · 水果主题测试',
summary: 'TKMind 水果主题预览图 E2E',
content: html,
content_format: 'html',
page_type: 'html',
category_code: 'draft',
};
const created = await request('/api/mindspace/v1/pages', {
method: 'POST',
body: saveBody,
cookie,
});
assert.equal(created.response.status, 201, JSON.stringify(created.payload));
const pageId = created.payload?.data?.id ?? created.payload?.data?.page?.id;
assert.ok(pageId, '应创建页面记录');
await new Promise((r) => setTimeout(r, 800));
const thumbApi = await request(`/api/mindspace/v1/pages/${pageId}/thumbnail`, { cookie });
assert.equal(thumbApi.response.status, 200, '页面缩略图 API 应返回 200');
assert.match(String(thumbApi.payload), /width="540" height="720"/);
console.log(`✓ 我的空间缩略图 API 200: /api/mindspace/v1/pages/${pageId}/thumbnail`);
console.log('\n=== 全部通过 ===');
}
try {
await main();
} finally {
portal.kill('SIGTERM');
}
+975
View File
@@ -0,0 +1,975 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import { plazaOpenApiExtensions } from './plaza-openapi.mjs';
const OUTPUT_PATH = path.join(import.meta.dirname, '..', 'openapi.json');
const plazaOpenApi = plazaOpenApiExtensions();
const jsonContent = {
openapi: '3.0.3',
info: {
title: 'MindSpace H5 API',
version: '0.1.0',
description: 'Generated OpenAPI contract for MindSpace H5, Plaza, and public publication routes.',
},
servers: [
{ url: '/api', description: 'Authenticated H5 API' },
{ url: '/', description: 'Public publication and homepage routes' },
],
tags: [
{ name: 'Auth' },
{ name: 'Space' },
{ name: 'Uploads' },
{ name: 'Assets' },
{ name: 'Pages' },
{ name: 'Publications' },
{ name: 'Agent Jobs' },
{ name: 'Public Web' },
...plazaOpenApi.tags,
],
paths: {
'/auth/register': {
post: {
tags: ['Auth'],
summary: 'Register a user and initialize a MindSpace workspace',
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/RegisterRequest' },
},
},
},
responses: {
200: {
description: 'Registration succeeded',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/RegisterResponseEnvelope' },
},
},
},
},
},
},
'/auth/login': {
post: {
tags: ['Auth'],
summary: 'Login with username and password',
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/LoginRequest' },
},
},
},
responses: {
200: {
description: 'Authenticated session',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/AuthStatus' },
},
},
},
},
},
},
'/auth/logout': {
post: {
tags: ['Auth'],
summary: 'Logout current user session',
responses: {
200: {
description: 'Logged out',
content: { 'application/json': { schema: { $ref: '#/components/schemas/SuccessEnvelope' } } },
},
},
},
},
'/auth/status': {
get: {
tags: ['Auth'],
summary: 'Inspect current auth/session status',
responses: {
200: {
description: 'Current auth status',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/AuthStatus' },
},
},
},
},
},
},
'/mindspace/v1/space': {
get: {
tags: ['Space'],
summary: 'Get current MindSpace summary, quota, and categories',
responses: {
200: {
description: 'Current space',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/DataEnvelope' },
},
},
},
},
},
},
'/mindspace/v1/space/quota': {
get: {
tags: ['Space'],
summary: 'Get current quota state',
responses: {
200: {
description: 'Quota state',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/mindspace/v1/space/categories': {
get: {
tags: ['Space'],
summary: 'List current space categories',
responses: {
200: {
description: 'Category list',
content: { 'application/json': { schema: { $ref: '#/components/schemas/ListEnvelope' } } },
},
},
},
},
'/mindspace/v1/space/cleanup': {
get: {
tags: ['Space'],
summary: 'Preview cleanup candidates',
responses: {
200: {
description: 'Cleanup candidates',
content: { 'application/json': { schema: { $ref: '#/components/schemas/ListEnvelope' } } },
},
},
},
post: {
tags: ['Space'],
summary: 'Delete selected cleanup candidates',
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/CleanupRequest' },
},
},
},
responses: {
200: {
description: 'Cleanup result',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/mindspace/v1/uploads': {
post: {
tags: ['Uploads'],
summary: 'Create an upload session',
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/CreateUploadRequest' },
},
},
},
responses: {
201: {
description: 'Upload session created',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/mindspace/v1/uploads/{uploadId}/content': {
put: {
tags: ['Uploads'],
summary: 'Upload raw file bytes',
parameters: [{ $ref: '#/components/parameters/UploadId' }],
requestBody: {
required: true,
content: {
'application/octet-stream': {
schema: { type: 'string', format: 'binary' },
},
},
},
responses: {
200: {
description: 'Bytes written',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/mindspace/v1/uploads/{uploadId}/complete': {
post: {
tags: ['Uploads'],
summary: 'Complete an upload and materialize the asset',
parameters: [{ $ref: '#/components/parameters/UploadId' }],
responses: {
201: {
description: 'Asset created',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/mindspace/v1/uploads/{uploadId}': {
delete: {
tags: ['Uploads'],
summary: 'Cancel upload session',
parameters: [{ $ref: '#/components/parameters/UploadId' }],
responses: {
200: {
description: 'Upload cancelled',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/mindspace/v1/assets': {
get: {
tags: ['Assets'],
summary: 'List assets',
parameters: [
{ name: 'category_id', in: 'query', schema: { type: 'string' } },
{ name: 'category_code', in: 'query', schema: { type: 'string' } },
],
responses: {
200: {
description: 'Asset list',
content: { 'application/json': { schema: { $ref: '#/components/schemas/ListEnvelope' } } },
},
},
},
},
'/mindspace/v1/assets/{assetId}/download': {
get: {
tags: ['Assets'],
summary: 'Download asset bytes',
parameters: [{ $ref: '#/components/parameters/AssetId' }],
responses: {
200: {
description: 'Asset content',
content: { '*/*': { schema: { type: 'string', format: 'binary' } } },
},
},
},
},
'/mindspace/v1/assets/{assetId}/thumbnail': {
get: {
tags: ['Assets'],
summary: 'Render asset thumbnail',
parameters: [{ $ref: '#/components/parameters/AssetId' }],
responses: {
200: {
description: 'SVG thumbnail',
content: { 'image/svg+xml': { schema: { type: 'string' } } },
},
},
},
},
'/mindspace/v1/assets/{assetId}/preview': {
get: {
tags: ['Assets'],
summary: 'Render asset preview HTML',
parameters: [{ $ref: '#/components/parameters/AssetId' }],
responses: {
200: {
description: 'Preview HTML',
content: { 'text/html': { schema: { type: 'string' } } },
},
},
},
},
'/mindspace/v1/assets/{assetId}': {
delete: {
tags: ['Assets'],
summary: 'Delete asset',
parameters: [{ $ref: '#/components/parameters/AssetId' }],
responses: {
200: {
description: 'Delete result',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/mindspace/v1/pages/analyze-chat-save': {
post: {
tags: ['Pages'],
summary: 'Analyze whether an assistant message should be saved as page or asset',
requestBody: {
required: true,
content: { 'application/json': { schema: { $ref: '#/components/schemas/AnalyzeChatSaveRequest' } } },
},
responses: {
200: {
description: 'Chat save analysis',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/mindspace/v1/pages/save-from-chat': {
post: {
tags: ['Pages'],
summary: 'Save assistant message into MindSpace',
requestBody: {
required: true,
content: { 'application/json': { schema: { $ref: '#/components/schemas/SaveFromChatRequest' } } },
},
responses: {
201: {
description: 'Saved page or asset',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/mindspace/v1/pages/from-asset': {
post: {
tags: ['Pages'],
summary: 'Convert an owned asset into a draft page',
requestBody: {
required: true,
content: { 'application/json': { schema: { $ref: '#/components/schemas/PageFromAssetRequest' } } },
},
responses: {
201: {
description: 'Draft page created',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/mindspace/v1/pages': {
get: {
tags: ['Pages'],
summary: 'List pages',
parameters: [{ name: 'status', in: 'query', schema: { type: 'string' } }],
responses: {
200: {
description: 'Page list',
content: { 'application/json': { schema: { $ref: '#/components/schemas/ListEnvelope' } } },
},
},
},
post: {
tags: ['Pages'],
summary: 'Create a draft page',
requestBody: {
required: true,
content: { 'application/json': { schema: { $ref: '#/components/schemas/CreatePageRequest' } } },
},
responses: {
201: {
description: 'Page created',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/mindspace/v1/pages/{pageId}': {
get: {
tags: ['Pages'],
summary: 'Get page detail and versions',
parameters: [{ $ref: '#/components/parameters/PageId' }],
responses: {
200: {
description: 'Page detail',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
put: {
tags: ['Pages'],
summary: 'Create a new page version',
parameters: [{ $ref: '#/components/parameters/PageId' }],
requestBody: {
required: true,
content: { 'application/json': { schema: { $ref: '#/components/schemas/UpdatePageRequest' } } },
},
responses: {
200: {
description: 'Updated page',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/mindspace/v1/pages/{pageId}/thumbnail': {
get: {
tags: ['Pages'],
summary: 'Render page thumbnail',
parameters: [{ $ref: '#/components/parameters/PageId' }],
responses: {
200: {
description: 'SVG thumbnail',
content: { 'image/svg+xml': { schema: { type: 'string' } } },
},
},
},
},
'/mindspace/v1/pages/{pageId}/preview': {
get: {
tags: ['Pages'],
summary: 'Render safe page preview HTML',
parameters: [{ $ref: '#/components/parameters/PageId' }],
responses: {
200: {
description: 'Preview HTML',
content: { 'text/html': { schema: { type: 'string' } } },
},
},
},
},
'/mindspace/v1/pages/{pageId}/publish-check': {
post: {
tags: ['Publications'],
summary: 'Check whether page can be published',
parameters: [{ $ref: '#/components/parameters/PageId' }],
requestBody: {
required: true,
content: { 'application/json': { schema: { $ref: '#/components/schemas/PublishCheckRequest' } } },
},
responses: {
200: {
description: 'Publish check result',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/mindspace/v1/pages/{pageId}/redacted-copy': {
post: {
tags: ['Pages'],
summary: 'Create a redacted copy of the current page version',
parameters: [{ $ref: '#/components/parameters/PageId' }],
requestBody: {
required: true,
content: { 'application/json': { schema: { $ref: '#/components/schemas/RedactedCopyRequest' } } },
},
responses: {
201: {
description: 'Redacted page created',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/mindspace/v1/pages/{pageId}/publish': {
post: {
tags: ['Publications'],
summary: 'Publish the current page version',
parameters: [{ $ref: '#/components/parameters/PageId' }],
requestBody: {
required: true,
content: { 'application/json': { schema: { $ref: '#/components/schemas/PublishPageRequest' } } },
},
responses: {
201: {
description: 'Publication created',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/mindspace/v1/publications/{publicationId}/offline': {
post: {
tags: ['Publications'],
summary: 'Offline an online publication',
parameters: [{ $ref: '#/components/parameters/PublicationId' }],
responses: {
200: {
description: 'Publication offlined',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/mindspace/v1/publications/{publicationId}/stats': {
get: {
tags: ['Publications'],
summary: 'Get publication statistics',
parameters: [{ $ref: '#/components/parameters/PublicationId' }],
responses: {
200: {
description: 'Publication stats',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/mindspace/v1/agent/jobs': {
get: {
tags: ['Agent Jobs'],
summary: 'List current user agent jobs',
parameters: [{ name: 'limit', in: 'query', schema: { type: 'integer' } }],
responses: {
200: {
description: 'Job list',
content: { 'application/json': { schema: { $ref: '#/components/schemas/ListEnvelope' } } },
},
},
},
post: {
tags: ['Agent Jobs'],
summary: 'Create an agent job bound to owned assets',
requestBody: {
required: true,
content: { 'application/json': { schema: { $ref: '#/components/schemas/CreateAgentJobRequest' } } },
},
responses: {
201: {
description: 'Job created',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/mindspace/v1/agent/jobs/{jobId}': {
get: {
tags: ['Agent Jobs'],
summary: 'Get agent job detail',
parameters: [{ $ref: '#/components/parameters/JobId' }],
responses: {
200: {
description: 'Job detail',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/mindspace/v1/agent/jobs/{jobId}/cancel': {
post: {
tags: ['Agent Jobs'],
summary: 'Cancel agent job',
parameters: [{ $ref: '#/components/parameters/JobId' }],
responses: {
200: {
description: 'Cancelled job',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/mindspace/v1/agent/jobs/{jobId}/retry': {
post: {
tags: ['Agent Jobs'],
summary: 'Retry agent job',
parameters: [{ $ref: '#/components/parameters/JobId' }],
responses: {
200: {
description: 'Retried job',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/mindspace/v1/agent/jobs/{jobId}/run': {
post: {
tags: ['Agent Jobs'],
summary: 'Start queued agent job execution',
parameters: [{ $ref: '#/components/parameters/JobId' }],
responses: {
202: {
description: 'Execution started',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/internal/agent/jobs/{jobId}/claim': {
post: {
tags: ['Agent Jobs'],
summary: 'Internal worker claims a queued job',
parameters: [{ $ref: '#/components/parameters/JobId' }],
security: [{ bearerAuth: [] }],
responses: {
200: {
description: 'Claimed job payload',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/internal/agent/jobs/{jobId}/assets/{assetId}': {
get: {
tags: ['Agent Jobs'],
summary: 'Internal worker downloads an authorized job asset',
parameters: [{ $ref: '#/components/parameters/JobId' }, { $ref: '#/components/parameters/AssetId' }],
security: [{ bearerAuth: [] }],
responses: {
200: {
description: 'Asset content',
content: { '*/*': { schema: { type: 'string', format: 'binary' } } },
},
},
},
},
'/internal/agent/jobs/{jobId}/heartbeat': {
post: {
tags: ['Agent Jobs'],
summary: 'Internal worker posts heartbeat/progress updates',
parameters: [{ $ref: '#/components/parameters/JobId' }],
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: { 'application/json': { schema: { $ref: '#/components/schemas/HeartbeatRequest' } } },
},
responses: {
200: {
description: 'Progress updated',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/internal/agent/jobs/{jobId}/complete': {
post: {
tags: ['Agent Jobs'],
summary: 'Internal worker completes or fails a job',
parameters: [{ $ref: '#/components/parameters/JobId' }],
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: { 'application/json': { schema: { $ref: '#/components/schemas/CompleteJobRequest' } } },
},
responses: {
200: {
description: 'Final job state',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataEnvelope' } } },
},
},
},
},
'/u/{ownerSlug}': {
get: {
tags: ['Public Web'],
summary: 'Render public homepage for a user slug',
parameters: [{ $ref: '#/components/parameters/OwnerSlug' }],
responses: {
200: {
description: 'Public homepage HTML',
content: { 'text/html': { schema: { type: 'string' } } },
},
},
},
},
'/u/{ownerSlug}/pages/{urlSlug}': {
get: {
tags: ['Public Web'],
summary: 'Render a published page via long public route',
parameters: [{ $ref: '#/components/parameters/OwnerSlug' }, { $ref: '#/components/parameters/UrlSlug' }],
responses: {
200: {
description: 'Published page HTML',
content: { 'text/html': { schema: { type: 'string' } } },
},
},
},
post: {
tags: ['Public Web'],
summary: 'Submit password gate for a protected page',
parameters: [{ $ref: '#/components/parameters/OwnerSlug' }, { $ref: '#/components/parameters/UrlSlug' }],
requestBody: {
required: true,
content: {
'application/x-www-form-urlencoded': {
schema: {
type: 'object',
required: ['password'],
properties: { password: { type: 'string' } },
},
},
},
},
responses: {
200: {
description: 'Published page HTML or password gate',
content: { 'text/html': { schema: { type: 'string' } } },
},
},
},
},
'/s/{token}': {
get: {
tags: ['Public Web'],
summary: 'Render a private-link publication',
parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
200: {
description: 'Published page HTML',
content: { 'text/html': { schema: { type: 'string' } } },
},
},
},
},
...plazaOpenApi.paths,
},
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
},
},
parameters: {
UploadId: { name: 'uploadId', in: 'path', required: true, schema: { type: 'string' } },
AssetId: { name: 'assetId', in: 'path', required: true, schema: { type: 'string' } },
PageId: { name: 'pageId', in: 'path', required: true, schema: { type: 'string' } },
PublicationId: { name: 'publicationId', in: 'path', required: true, schema: { type: 'string' } },
JobId: { name: 'jobId', in: 'path', required: true, schema: { type: 'string' } },
OwnerSlug: { name: 'ownerSlug', in: 'path', required: true, schema: { type: 'string' } },
UrlSlug: { name: 'urlSlug', in: 'path', required: true, schema: { type: 'string' } },
...plazaOpenApi.parameters,
},
schemas: {
SuccessEnvelope: {
type: 'object',
properties: {
ok: { type: 'boolean' },
request_id: { type: 'string' },
},
},
DataEnvelope: {
type: 'object',
required: ['data', 'request_id'],
properties: {
data: { type: 'object', additionalProperties: true },
request_id: { type: 'string' },
},
},
ListEnvelope: {
type: 'object',
required: ['data', 'request_id'],
properties: {
data: { type: 'array', items: { type: 'object', additionalProperties: true } },
page: {
type: 'object',
properties: {
next_cursor: { type: ['string', 'null'] },
has_more: { type: 'boolean' },
},
},
request_id: { type: 'string' },
},
},
AuthStatus: {
type: 'object',
additionalProperties: true,
},
RegisterRequest: {
type: 'object',
required: ['username', 'password', 'displayName', 'email'],
properties: {
username: { type: 'string' },
password: { type: 'string' },
displayName: { type: 'string' },
email: { type: 'string' },
},
},
RegisterResponseEnvelope: {
type: 'object',
properties: {
ok: { type: 'boolean' },
user: { type: 'object', additionalProperties: true },
},
},
LoginRequest: {
type: 'object',
required: ['username', 'password'],
properties: {
username: { type: 'string' },
password: { type: 'string' },
},
},
CleanupRequest: {
type: 'object',
properties: {
item_ids: { type: 'array', items: { type: 'string' } },
},
},
CreateUploadRequest: {
type: 'object',
required: ['category_id', 'filename', 'size_bytes'],
properties: {
category_id: { type: 'string' },
filename: { type: 'string' },
size_bytes: { type: 'integer' },
declared_mime_type: { type: 'string' },
},
},
AnalyzeChatSaveRequest: {
type: 'object',
required: ['session_id', 'message_id'],
properties: {
session_id: { type: 'string' },
message_id: { type: 'string' },
selected_link_index: { type: 'integer' },
},
},
SaveFromChatRequest: {
type: 'object',
required: ['session_id', 'message_id'],
properties: {
session_id: { type: 'string' },
message_id: { type: 'string' },
title: { type: 'string' },
summary: { type: 'string' },
template_id: { type: 'string' },
category_code: { type: 'string' },
page_type: { type: 'string' },
selected_link_index: { type: 'integer' },
acknowledged_finding_ids: { type: 'array', items: { type: 'string' } },
},
},
PageFromAssetRequest: {
type: 'object',
required: ['asset_id'],
properties: {
asset_id: { type: 'string' },
title: { type: 'string' },
summary: { type: 'string' },
template_id: { type: 'string' },
},
},
CreatePageRequest: {
type: 'object',
required: ['title', 'content'],
properties: {
title: { type: 'string' },
summary: { type: 'string' },
content: { type: 'string' },
content_format: { type: 'string', enum: ['markdown', 'html'] },
template_id: { type: 'string' },
page_type: { type: 'string' },
category_code: { type: 'string' },
},
},
UpdatePageRequest: {
type: 'object',
required: ['expected_version', 'title', 'content'],
properties: {
expected_version: { type: 'integer' },
title: { type: 'string' },
summary: { type: 'string' },
content: { type: 'string' },
template_id: { type: 'string' },
page_type: { type: 'string' },
change_note: { type: 'string' },
},
},
PublishCheckRequest: {
type: 'object',
required: ['page_version_id', 'access_mode', 'url_slug'],
properties: {
page_version_id: { type: 'string' },
access_mode: { type: 'string' },
url_slug: { type: 'string' },
password: { type: 'string' },
expires_at: { type: 'integer' },
},
},
RedactedCopyRequest: {
type: 'object',
properties: {
page_version_id: { type: 'string' },
},
},
PublishPageRequest: {
type: 'object',
required: ['page_version_id', 'access_mode', 'url_slug'],
properties: {
page_version_id: { type: 'string' },
access_mode: { type: 'string' },
url_slug: { type: 'string' },
password: { type: 'string' },
expires_at: { type: 'integer' },
acknowledged_finding_ids: { type: 'array', items: { type: 'string' } },
},
},
CreateAgentJobRequest: {
type: 'object',
required: ['job_type', 'instruction', 'allowed_asset_ids'],
properties: {
job_type: { type: 'string' },
instruction: { type: 'string' },
allowed_asset_ids: { type: 'array', items: { type: 'string' } },
output_category_id: { type: 'string' },
output_type: { type: 'string' },
idempotency_key: { type: 'string' },
locale: { type: 'string' },
timezone: { type: 'string' },
capabilities: { type: 'object', additionalProperties: { type: 'boolean' } },
},
},
HeartbeatRequest: {
type: 'object',
properties: {
stage: { type: 'string' },
message: { type: 'string' },
},
},
CompleteJobRequest: {
type: 'object',
properties: {
status: { type: 'string' },
error_code: { type: 'string' },
error_message: { type: 'string' },
output_type: { type: 'string' },
title: { type: 'string' },
summary: { type: 'string' },
content: { type: 'string' },
content_format: { type: 'string' },
page_type: { type: 'string' },
template_id: { type: 'string' },
source_asset_ids: { type: 'array', items: { type: 'string' } },
},
},
ErrorResponse: {
type: 'object',
properties: {
error: {
type: 'object',
properties: {
code: { type: 'string' },
message: { type: 'string' },
details: { type: 'object', additionalProperties: true },
},
},
request_id: { type: 'string' },
},
},
...plazaOpenApi.schemas,
},
},
};
async function main() {
const rendered = `${JSON.stringify(jsonContent, null, 2)}\n`;
if (process.argv.includes('--print')) {
process.stdout.write(rendered);
return;
}
await fs.writeFile(OUTPUT_PATH, rendered);
process.stdout.write(`Generated ${OUTPUT_PATH}\n`);
}
await main();
+20
View File
@@ -0,0 +1,20 @@
import fs from 'node:fs';
import path from 'node:path';
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 separator = trimmed.indexOf('=');
if (separator < 0) continue;
const key = trimmed.slice(0, separator).trim();
const value = trimmed.slice(separator + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
}
export function loadH5Environment(scriptDirectory) {
loadEnvFile(path.join(scriptDirectory, '../../../.env.local'));
loadEnvFile(path.join(scriptDirectory, '../.env'));
}
+365
View File
@@ -0,0 +1,365 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import http from 'node:http';
import path from 'node:path';
import { createDbPool } from '../db.mjs';
import { loadH5Environment } from './load-env.mjs';
loadH5Environment(import.meta.dirname);
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
const fakePort = Number(process.env.MINDSPACE_AGENT_FAKE_PORT ?? 18161);
const portalPort = Number(process.env.MINDSPACE_AGENT_PORT ?? 18162);
const baseUrl = `http://127.0.0.1:${portalPort}`;
const storageRoot = path.join('/tmp', `mindspace-agent-jobs-${suffix}`);
const users = [
{
username: `msaj_owner_${suffix}`,
email: `msaj-owner-${suffix}@example.test`,
password: 'MindSpace-Agent-Owner-2026',
},
];
const eventStreams = new Map();
const fakeSessions = new Map();
function sseWrite(res, data) {
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
const fakeUpstream = http.createServer(async (req, res) => {
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
const bodyChunks = [];
for await (const chunk of req) {
bodyChunks.push(chunk);
}
const raw = bodyChunks.length ? Buffer.concat(bodyChunks).toString('utf8') : '';
const body = raw ? JSON.parse(raw) : null;
if (req.method === 'POST' && url.pathname === '/agent/start') {
const sessionId = `agent-job-session-${suffix}`;
fakeSessions.set(sessionId, {
id: sessionId,
working_dir: body?.working_dir ?? '/tmp',
extensions: body?.extension_overrides ?? [],
});
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ id: sessionId, name: 'Agent Job E2E Session' }));
return;
}
const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
if (req.method === 'GET' && sessionMatch) {
const session = fakeSessions.get(sessionMatch[1]);
if (!session) {
res.statusCode = 404;
res.end(JSON.stringify({ message: 'session not found' }));
return;
}
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({
id: session.id,
working_dir: session.working_dir,
}),
);
return;
}
const extensionMatch = url.pathname.match(/^\/sessions\/([^/]+)\/extensions$/);
if (req.method === 'GET' && extensionMatch) {
const session = fakeSessions.get(extensionMatch[1]);
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ extensions: session?.extensions ?? [] }));
return;
}
if (
req.method === 'POST' &&
[
'/agent/update_working_dir',
'/agent/update_session',
'/agent/remove_extension',
'/agent/add_extension',
'/agent/restart',
'/agent/harness_remember',
'/agent/harness_bootstrap',
].includes(url.pathname)
) {
res.setHeader('Content-Type', 'application/json');
res.end('{}');
return;
}
const eventMatch = url.pathname.match(/^\/sessions\/([^/]+)\/events$/);
if (req.method === 'GET' && eventMatch) {
const sessionId = eventMatch[1];
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.flushHeaders?.();
sseWrite(res, { type: 'Ping' });
eventStreams.set(sessionId, res);
req.on('close', () => {
if (eventStreams.get(sessionId) === res) {
eventStreams.delete(sessionId);
}
});
return;
}
const replyMatch = url.pathname.match(/^\/sessions\/([^/]+)\/reply$/);
if (req.method === 'POST' && replyMatch) {
const sessionId = replyMatch[1];
const stream = eventStreams.get(sessionId);
if (!stream) {
res.statusCode = 409;
res.end(JSON.stringify({ message: 'events stream not connected' }));
return;
}
const requestId = body?.request_id;
const prompt = String(body?.user_message?.content?.[0]?.text ?? '');
const content = {
title: prompt.includes('weekly.md') ? 'Agent 生成周报' : 'Agent 页面',
summary: '根据授权资料生成的草稿',
content: '# Agent 周报\n\n本周已完成资产分析,并生成了草稿页面。',
content_format: 'markdown',
};
setTimeout(() => {
sseWrite(stream, {
type: 'Message',
request_id: requestId,
message: {
id: `assistant-${suffix}`,
role: 'assistant',
created: Math.floor(Date.now() / 1000),
metadata: { userVisible: true, agentVisible: true },
content: [{ type: 'text', text: JSON.stringify(content) }],
},
token_state: {
inputTokens: 12,
outputTokens: 20,
totalTokens: 32,
accumulatedInputTokens: 12,
accumulatedOutputTokens: 20,
accumulatedTotalTokens: 32,
},
});
sseWrite(stream, {
type: 'Finish',
request_id: requestId,
reason: 'completed',
token_state: {
inputTokens: 12,
outputTokens: 20,
totalTokens: 32,
accumulatedInputTokens: 12,
accumulatedOutputTokens: 20,
accumulatedTotalTokens: 32,
},
});
stream.end();
eventStreams.delete(sessionId);
}, 50);
res.setHeader('Content-Type', 'application/json');
res.end('{}');
return;
}
res.statusCode = 404;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ message: 'not found' }));
});
await new Promise((resolve) => fakeUpstream.listen(fakePort, '127.0.0.1', resolve));
const portal = spawn(process.execPath, ['server.mjs'], {
cwd: path.join(import.meta.dirname, '..'),
env: {
...process.env,
H5_PORT: String(portalPort),
TKMIND_API_TARGET: `http://127.0.0.1:${fakePort}`,
MINDSPACE_STORAGE_ROOT: storageRoot,
MINDSPACE_AGENT_JOBS_ENABLED: 'true',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let portalLogs = '';
portal.stdout.on('data', (chunk) => {
portalLogs += chunk.toString();
});
portal.stderr.on('data', (chunk) => {
portalLogs += chunk.toString();
});
async function waitForPortal() {
for (let attempt = 0; attempt < 60; attempt += 1) {
if (portal.exitCode != null) throw new Error(`Portal 提前退出\n${portalLogs}`);
try {
const response = await fetch(`${baseUrl}/auth/status`);
if (response.ok) return;
} catch {
// waiting
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`Portal 启动超时\n${portalLogs}`);
}
async function request(pathname, options = {}) {
const response = await fetch(`${baseUrl}${pathname}`, options);
const contentType = response.headers.get('content-type') ?? '';
const body = contentType.includes('application/json')
? await response.json()
: await response.text();
return { response, body };
}
async function registerAndLogin(user) {
const registration = await request('/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...user, displayName: user.username }),
});
assert.equal(registration.response.status, 200, JSON.stringify(registration.body));
const login = await request('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user.username, password: user.password }),
});
assert.equal(login.response.status, 200, JSON.stringify(login.body));
return {
id: registration.body.user.id,
cookie: login.response.headers.get('set-cookie')?.split(';', 1)[0],
};
}
function authHeaders(cookie) {
return { Cookie: cookie, 'Content-Type': 'application/json' };
}
async function pollJob(cookie, jobId) {
let lastJob = null;
for (let attempt = 0; attempt < 60; attempt += 1) {
const detail = await request(`/api/mindspace/v1/agent/jobs/${jobId}`, {
headers: { Cookie: cookie },
});
assert.equal(detail.response.status, 200, JSON.stringify(detail.body));
const job = detail.body.data;
lastJob = job;
if (['completed', 'failed', 'cancelled', 'timed_out'].includes(job.status)) {
return job;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`等待 Agent job 完成超时: ${JSON.stringify(lastJob)}`);
}
const pool = createDbPool();
try {
await waitForPortal();
const owner = await registerAndLogin(users[0]);
assert.ok(owner.cookie);
const space = await request('/api/mindspace/v1/space', {
headers: { Cookie: owner.cookie },
});
assert.equal(space.response.status, 200, JSON.stringify(space.body));
const oaCategory = space.body.data.categories.find((category) => category.code === 'oa');
assert.ok(oaCategory);
const source = Buffer.from('# Weekly Notes\n\n- Revenue up\n- Risk controlled\n', 'utf8');
const createdUpload = await request('/api/mindspace/v1/uploads', {
method: 'POST',
headers: authHeaders(owner.cookie),
body: JSON.stringify({
category_id: oaCategory.id,
filename: 'weekly.md',
size_bytes: source.length,
declared_mime_type: 'text/markdown',
}),
});
assert.equal(createdUpload.response.status, 201, JSON.stringify(createdUpload.body));
const uploadedContent = await request(createdUpload.body.data.uploadUrl, {
method: 'PUT',
headers: {
Cookie: owner.cookie,
'Content-Type': 'application/octet-stream',
},
body: source,
});
assert.equal(uploadedContent.response.status, 200, JSON.stringify(uploadedContent.body));
const completedUpload = await request(
`/api/mindspace/v1/uploads/${createdUpload.body.data.id}/complete`,
{
method: 'POST',
headers: authHeaders(owner.cookie),
body: '{}',
},
);
assert.equal(completedUpload.response.status, 201, JSON.stringify(completedUpload.body));
const assetId = completedUpload.body.data.id;
const createdJob = await request('/api/mindspace/v1/agent/jobs', {
method: 'POST',
headers: authHeaders(owner.cookie),
body: JSON.stringify({
job_type: 'generate_page',
instruction: '请根据周报资料生成一个中文周报草稿页面',
allowed_asset_ids: [assetId],
output_type: 'page_draft',
}),
});
assert.equal(createdJob.response.status, 201, JSON.stringify(createdJob.body));
const jobId = createdJob.body.data.id;
assert.equal(createdJob.body.data.status, 'queued');
const started = await request(`/api/mindspace/v1/agent/jobs/${jobId}/run`, {
method: 'POST',
headers: authHeaders(owner.cookie),
body: '{}',
});
assert.equal(started.response.status, 202, JSON.stringify(started.body));
const finalJob = await pollJob(owner.cookie, jobId);
assert.equal(finalJob.status, 'completed', JSON.stringify(finalJob));
assert.ok(finalJob.resultPageId, '任务完成后应生成页面草稿');
const pageDetail = await request(`/api/mindspace/v1/pages/${finalJob.resultPageId}`, {
headers: { Cookie: owner.cookie },
});
assert.equal(pageDetail.response.status, 200, JSON.stringify(pageDetail.body));
assert.equal(pageDetail.body.data.title, 'Agent 生成周报');
assert.match(pageDetail.body.data.content, /Agent 周报/);
const draftPages = await request('/api/mindspace/v1/pages?status=draft', {
headers: { Cookie: owner.cookie },
});
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],
);
assert.equal(Number(usageRows[0]?.count ?? 0), 1, 'Agent 运行后应记录一次扣费');
console.log('MindSpace agent jobs E2E passed');
} finally {
await pool.query(`DELETE FROM h5_users WHERE username IN (?)`, [[users[0].username]]);
await pool.end();
fakeUpstream.close();
portal.kill('SIGTERM');
await new Promise((resolve) => portal.once('exit', resolve));
await fs.rm(storageRoot, { recursive: true, force: true });
}
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import { createDbPool } from '../db.mjs';
import { loadH5Environment } from './load-env.mjs';
loadH5Environment(import.meta.dirname);
const portalPort = Number(process.env.MINDSPACE_E2E_PORT ?? 18090);
const baseUrl = process.env.MINDSPACE_E2E_URL ?? `http://127.0.0.1:${portalPort}`;
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
const storageRoot = path.join('/tmp', `mindspace-assets-${suffix}`);
const portal = process.env.MINDSPACE_E2E_URL
? null
: spawn(process.execPath, ['server.mjs'], {
cwd: path.join(import.meta.dirname, '..'),
env: {
...process.env,
H5_PORT: String(portalPort),
TKMIND_API_TARGET: 'http://127.0.0.1:9',
MINDSPACE_STORAGE_ROOT: storageRoot,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let portalLogs = '';
portal?.stdout.on('data', (chunk) => {
portalLogs += chunk.toString();
});
portal?.stderr.on('data', (chunk) => {
portalLogs += chunk.toString();
});
const users = [
{
username: `ms_owner_${suffix}`,
email: `ms-owner-${suffix}@example.test`,
password: 'MindSpace-E2E-Owner-2026',
},
{
username: `ms_other_${suffix}`,
email: `ms-other-${suffix}@example.test`,
password: 'MindSpace-E2E-Other-2026',
},
];
async function request(path, options = {}) {
const response = await fetch(`${baseUrl}${path}`, options);
const contentType = response.headers.get('content-type') ?? '';
const body = contentType.includes('application/json')
? await response.json()
: Buffer.from(await response.arrayBuffer());
return { response, body };
}
async function waitForPortal() {
if (!portal) return;
for (let attempt = 0; attempt < 60; attempt += 1) {
if (portal.exitCode != null) throw new Error(`Portal 提前退出\n${portalLogs}`);
try {
const response = await fetch(`${baseUrl}/auth/status`);
if (response.ok) return;
} catch {
// Portal is still starting.
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`Portal 启动超时\n${portalLogs}`);
}
async function registerAndLogin(user) {
const registration = await request('/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: user.username,
email: user.email,
password: user.password,
displayName: user.username,
}),
});
assert.equal(registration.response.status, 200, JSON.stringify(registration.body));
const login = await request('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user.username, password: user.password }),
});
assert.equal(login.response.status, 200, JSON.stringify(login.body));
const cookie = login.response.headers.get('set-cookie')?.split(';', 1)[0];
assert.ok(cookie, '登录响应应设置会话 Cookie');
return { id: registration.body.user.id, cookie };
}
const pool = createDbPool();
let owner;
let other;
try {
await waitForPortal();
owner = await registerAndLogin(users[0]);
other = await registerAndLogin(users[1]);
const initialSpace = await request('/api/mindspace/v1/space', {
headers: { Cookie: owner.cookie },
});
assert.equal(initialSpace.response.status, 200, JSON.stringify(initialSpace.body));
const oaCategory = initialSpace.body.data.categories.find((category) => category.code === 'oa');
assert.ok(oaCategory, '默认空间应包含 OA 工作区');
const content = Buffer.from('MindSpace upload E2E\n', 'utf8');
const created = await request('/api/mindspace/v1/uploads', {
method: 'POST',
headers: {
Cookie: owner.cookie,
'Content-Type': 'application/json',
},
body: JSON.stringify({
category_id: oaCategory.id,
filename: 'e2e-notes.txt',
size_bytes: content.length,
declared_mime_type: 'text/plain',
}),
});
assert.equal(created.response.status, 201, JSON.stringify(created.body));
const contentUpload = await request(created.body.data.uploadUrl, {
method: 'PUT',
headers: {
Cookie: owner.cookie,
'Content-Type': 'application/octet-stream',
},
body: content,
});
assert.equal(contentUpload.response.status, 200, JSON.stringify(contentUpload.body));
const completed = await request(
`/api/mindspace/v1/uploads/${created.body.data.id}/complete`,
{
method: 'POST',
headers: {
Cookie: owner.cookie,
'Content-Type': 'application/json',
},
body: '{}',
},
);
assert.equal(completed.response.status, 201, JSON.stringify(completed.body));
const assetId = completed.body.data.id;
const assets = await request('/api/mindspace/v1/assets?category_code=oa', {
headers: { Cookie: owner.cookie },
});
assert.equal(assets.response.status, 200, JSON.stringify(assets.body));
assert.equal(assets.body.data.length, 1);
assert.equal(assets.body.data[0].id, assetId);
const downloaded = await request(`/api/mindspace/v1/assets/${assetId}/download`, {
headers: { Cookie: owner.cookie },
});
assert.equal(downloaded.response.status, 200);
assert.deepEqual(downloaded.body, content);
const ownerSpaceAfterUpload = await request('/api/mindspace/v1/space', {
headers: { Cookie: owner.cookie },
});
assert.equal(ownerSpaceAfterUpload.body.data.quota.usedBytes, content.length);
assert.equal(ownerSpaceAfterUpload.body.data.quota.reservedBytes, 0);
assert.equal(
ownerSpaceAfterUpload.body.data.categories.find((category) => category.code === 'oa')
.itemCount,
1,
);
const foreignAssets = await request('/api/mindspace/v1/assets?category_code=oa', {
headers: { Cookie: other.cookie },
});
assert.equal(foreignAssets.response.status, 200);
assert.equal(foreignAssets.body.data.length, 0);
const foreignDownload = await request(`/api/mindspace/v1/assets/${assetId}/download`, {
headers: { Cookie: other.cookie },
});
assert.equal(foreignDownload.response.status, 404);
const foreignDelete = await request(`/api/mindspace/v1/assets/${assetId}`, {
method: 'DELETE',
headers: { Cookie: other.cookie },
});
assert.equal(foreignDelete.response.status, 404);
const deleted = await request(`/api/mindspace/v1/assets/${assetId}`, {
method: 'DELETE',
headers: { Cookie: owner.cookie },
});
assert.equal(deleted.response.status, 200, JSON.stringify(deleted.body));
const ownerSpaceAfterDelete = await request('/api/mindspace/v1/space', {
headers: { Cookie: owner.cookie },
});
assert.equal(ownerSpaceAfterDelete.body.data.quota.usedBytes, 0);
assert.equal(
ownerSpaceAfterDelete.body.data.categories.find((category) => category.code === 'oa')
.itemCount,
0,
);
console.log('MindSpace API E2E passed');
} finally {
await pool.query(`DELETE FROM h5_users WHERE username IN (?, ?)`, [
users[0].username,
users[1].username,
]);
await pool.end();
if (portal) {
portal.kill('SIGTERM');
await new Promise((resolve) => portal.once('exit', resolve));
await fs.rm(storageRoot, { recursive: true, force: true });
}
}
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import http from 'node:http';
import path from 'node:path';
import { createDbPool } from '../db.mjs';
import { loadH5Environment } from './load-env.mjs';
loadH5Environment(import.meta.dirname);
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
const fakePort = Number(process.env.MINDSPACE_PAGE_FAKE_PORT ?? 18121);
const portalPort = Number(process.env.MINDSPACE_PAGE_PORT ?? 18122);
const baseUrl = `http://127.0.0.1:${portalPort}`;
const storageRoot = path.join('/tmp', `mindspace-pages-${suffix}`);
const sessionId = `page-e2e-session-${suffix}`;
const messageId = `assistant-message-${suffix}`;
const sourceContent = '# 安全页面\n\n这是来自 AI 会话的正文。\n\n<script>alert("blocked")</script>';
const users = [
{
username: `msp_owner_${suffix}`,
email: `msp-owner-${suffix}@example.test`,
password: 'MindSpace-Page-Owner-2026',
},
{
username: `msp_other_${suffix}`,
email: `msp-other-${suffix}@example.test`,
password: 'MindSpace-Page-Other-2026',
},
];
const fakeUpstream = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === `/sessions/${sessionId}`) {
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({
id: sessionId,
name: '页面 E2E 会话',
conversation: [
{
id: messageId,
role: 'assistant',
created: Math.floor(Date.now() / 1000),
metadata: { userVisible: true, agentVisible: true },
content: [{ type: 'text', text: sourceContent }],
},
],
}),
);
return;
}
res.statusCode = 404;
res.end(JSON.stringify({ message: 'not found' }));
});
await new Promise((resolve) => fakeUpstream.listen(fakePort, '127.0.0.1', resolve));
const portal = spawn(process.execPath, ['server.mjs'], {
cwd: path.join(import.meta.dirname, '..'),
env: {
...process.env,
H5_PORT: String(portalPort),
TKMIND_API_TARGET: `http://127.0.0.1:${fakePort}`,
MINDSPACE_STORAGE_ROOT: storageRoot,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let portalLogs = '';
portal.stdout.on('data', (chunk) => {
portalLogs += chunk.toString();
});
portal.stderr.on('data', (chunk) => {
portalLogs += chunk.toString();
});
async function waitForPortal() {
for (let attempt = 0; attempt < 60; attempt += 1) {
if (portal.exitCode != null) throw new Error(`Portal 提前退出\n${portalLogs}`);
try {
const response = await fetch(`${baseUrl}/auth/status`);
if (response.ok) return;
} catch {
// Portal is still starting.
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`Portal 启动超时\n${portalLogs}`);
}
async function request(pathname, options = {}) {
const response = await fetch(`${baseUrl}${pathname}`, options);
const contentType = response.headers.get('content-type') ?? '';
const body = contentType.includes('application/json')
? await response.json()
: await response.text();
return { response, body };
}
async function registerAndLogin(user) {
const registration = await request('/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...user, displayName: user.username }),
});
assert.equal(registration.response.status, 200, JSON.stringify(registration.body));
const login = await request('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user.username, password: user.password }),
});
assert.equal(login.response.status, 200, JSON.stringify(login.body));
return {
id: registration.body.user.id,
cookie: login.response.headers.get('set-cookie')?.split(';', 1)[0],
};
}
const pool = createDbPool();
try {
await waitForPortal();
const owner = await registerAndLogin(users[0]);
const other = await registerAndLogin(users[1]);
assert.ok(owner.cookie && other.cookie);
await pool.query(
`INSERT INTO h5_user_sessions (agent_session_id, user_id, created_at) VALUES (?, ?, ?)`,
[sessionId, owner.id, Date.now()],
);
const created = await request('/api/mindspace/v1/pages/save-from-chat', {
method: 'POST',
headers: {
Cookie: owner.cookie,
'Content-Type': 'application/json',
},
body: JSON.stringify({
session_id: sessionId,
message_id: messageId,
title: 'AI 会话页面',
summary: '由会话生成的安全页面',
template_id: 'report',
}),
});
assert.equal(created.response.status, 201, JSON.stringify(created.body));
const pageId = created.body.data.page.id;
assert.equal(created.body.data.kind, 'page');
assert.equal(created.body.data.page.versionNo, 1);
assert.equal(created.body.data.page.content, sourceContent);
const detail = await request(`/api/mindspace/v1/pages/${pageId}`, {
headers: { Cookie: owner.cookie },
});
assert.equal(detail.response.status, 200);
assert.equal(detail.body.data.sourceSessionId, sessionId);
assert.equal(detail.body.data.versions.length, 1);
const preview = await request(`/api/mindspace/v1/pages/${pageId}/preview`, {
headers: { Cookie: owner.cookie },
});
assert.equal(preview.response.status, 200);
assert.match(preview.body, /default-src 'none'/);
assert.doesNotMatch(preview.body, /<script>alert/);
assert.match(preview.body, /&lt;script&gt;/);
const updatedContent = `${sourceContent}\n\n第二版补充内容。`;
const updated = await request(`/api/mindspace/v1/pages/${pageId}`, {
method: 'PUT',
headers: {
Cookie: owner.cookie,
'Content-Type': 'application/json',
},
body: JSON.stringify({
expected_version: 1,
title: 'AI 会话页面 v2',
summary: '第二版摘要',
content: updatedContent,
template_id: 'knowledge-card',
change_note: 'E2E 第二版',
}),
});
assert.equal(updated.response.status, 200, JSON.stringify(updated.body));
assert.equal(updated.body.data.versionNo, 2);
const staleUpdate = await request(`/api/mindspace/v1/pages/${pageId}`, {
method: 'PUT',
headers: {
Cookie: owner.cookie,
'Content-Type': 'application/json',
},
body: JSON.stringify({
expected_version: 1,
title: '过期修改',
content: '不应写入',
template_id: 'editorial',
}),
});
assert.equal(staleUpdate.response.status, 409);
assert.equal(staleUpdate.body.error.code, 'version_conflict');
for (const pathname of [
`/api/mindspace/v1/pages/${pageId}`,
`/api/mindspace/v1/pages/${pageId}/preview`,
]) {
const foreignRead = await request(pathname, { headers: { Cookie: other.cookie } });
assert.equal(foreignRead.response.status, 404);
}
const foreignUpdate = await request(`/api/mindspace/v1/pages/${pageId}`, {
method: 'PUT',
headers: {
Cookie: other.cookie,
'Content-Type': 'application/json',
},
body: JSON.stringify({
expected_version: 2,
title: '越权修改',
content: '禁止',
template_id: 'editorial',
}),
});
assert.equal(foreignUpdate.response.status, 404);
const space = await request('/api/mindspace/v1/space', {
headers: { Cookie: owner.cookie },
});
assert.equal(space.response.status, 200);
assert.equal(
space.body.data.categories.find((category) => category.code === 'draft').itemCount,
1,
);
assert.equal(
space.body.data.quota.usedBytes,
Buffer.byteLength(sourceContent) + Buffer.byteLength(updatedContent),
);
console.log('MindSpace page API E2E passed');
} finally {
await pool.query(`DELETE FROM h5_users WHERE username IN (?, ?)`, [
users[0].username,
users[1].username,
]);
await pool.end();
portal.kill('SIGTERM');
await new Promise((resolve) => portal.once('exit', resolve));
await new Promise((resolve) => fakeUpstream.close(resolve));
await fs.rm(storageRoot, { recursive: true, force: true });
}
@@ -0,0 +1,384 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import { createDbPool } from '../db.mjs';
import { loadH5Environment } from './load-env.mjs';
loadH5Environment(import.meta.dirname);
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
const portalPort = Number(process.env.MINDSPACE_PUBLICATION_PORT ?? 18142);
const baseUrl = `http://127.0.0.1:${portalPort}`;
const storageRoot = path.join('/tmp', `mindspace-publications-${suffix}`);
const users = [
{
username: `msp_pub_owner_${suffix}`,
email: `msp-pub-owner-${suffix}@example.test`,
password: 'MindSpace-Publication-Owner-2026',
},
{
username: `msp_pub_other_${suffix}`,
email: `msp-pub-other-${suffix}@example.test`,
password: 'MindSpace-Publication-Other-2026',
},
];
const portal = spawn(process.execPath, ['server.mjs'], {
cwd: path.join(import.meta.dirname, '..'),
env: {
...process.env,
H5_PORT: String(portalPort),
TKMIND_API_TARGET: 'http://127.0.0.1:9',
MINDSPACE_STORAGE_ROOT: storageRoot,
MINDSPACE_FREE_PUBLIC_PAGE_LIMIT: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let portalLogs = '';
portal.stdout.on('data', (chunk) => {
portalLogs += chunk.toString();
});
portal.stderr.on('data', (chunk) => {
portalLogs += chunk.toString();
});
async function waitForPortal() {
for (let attempt = 0; attempt < 60; attempt += 1) {
if (portal.exitCode != null) throw new Error(`Portal 提前退出\n${portalLogs}`);
try {
const response = await fetch(`${baseUrl}/auth/status`);
if (response.ok) return;
} catch {
// Portal is still starting.
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`Portal 启动超时\n${portalLogs}`);
}
async function request(pathname, options = {}) {
const response = await fetch(`${baseUrl}${pathname}`, options);
const contentType = response.headers.get('content-type') ?? '';
const body = contentType.includes('application/json')
? await response.json()
: await response.text();
return { response, body };
}
async function registerAndLogin(user) {
const registration = await request('/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...user, displayName: user.username }),
});
assert.equal(registration.response.status, 200, JSON.stringify(registration.body));
const login = await request('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user.username, password: user.password }),
});
assert.equal(login.response.status, 200, JSON.stringify(login.body));
return {
id: registration.body.user.id,
cookie: login.response.headers.get('set-cookie')?.split(';', 1)[0],
};
}
function authHeaders(cookie) {
return { Cookie: cookie, 'Content-Type': 'application/json' };
}
const pool = createDbPool();
try {
await waitForPortal();
const owner = await registerAndLogin(users[0]);
const other = await registerAndLogin(users[1]);
const created = await request('/api/mindspace/v1/pages', {
method: 'POST',
headers: authHeaders(owner.cookie),
body: JSON.stringify({
title: '发布隔离测试',
summary: '第一版摘要',
content: '# 第一版\n\n这是线上应该保持的 V1 内容。',
template_id: 'editorial',
}),
});
assert.equal(created.response.status, 201, JSON.stringify(created.body));
const page = created.body.data;
const checked = await request(`/api/mindspace/v1/pages/${page.id}/publish-check`, {
method: 'POST',
headers: authHeaders(owner.cookie),
body: JSON.stringify({
page_version_id: page.currentVersionId,
access_mode: 'public',
url_slug: 'immutable-page',
}),
});
assert.equal(checked.response.status, 200, JSON.stringify(checked.body));
assert.equal(checked.body.data.allowed, true);
const published = await request(`/api/mindspace/v1/pages/${page.id}/publish`, {
method: 'POST',
headers: authHeaders(owner.cookie),
body: JSON.stringify({
page_version_id: page.currentVersionId,
access_mode: 'public',
url_slug: 'immutable-page',
acknowledged_finding_ids: [],
}),
});
assert.equal(published.response.status, 201, JSON.stringify(published.body));
const publicUrl = published.body.data.publicUrl;
const publicV1 = await request(publicUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Mobile',
Referer: 'https://example.com/campaign/private-path',
},
});
assert.equal(publicV1.response.status, 200);
assert.match(publicV1.body, /V1 内容/);
const usageAfterPublish = await request('/api/mindspace/v1/space', {
headers: { Cookie: owner.cookie },
});
assert.equal(usageAfterPublish.body.data.quota.publicPageUsed, 1);
assert.equal(usageAfterPublish.body.data.quota.monthlyViewUsed, 1);
assert.equal(
usageAfterPublish.body.data.categories.find((category) => category.code === 'public')
.itemCount,
1,
);
const overLimitPage = await request('/api/mindspace/v1/pages', {
method: 'POST',
headers: authHeaders(owner.cookie),
body: JSON.stringify({
title: '第二个公开页面',
content: '这个页面应被套餐数量限制阻止。',
template_id: 'editorial',
}),
});
const overLimit = await request(
`/api/mindspace/v1/pages/${overLimitPage.body.data.id}/publish`,
{
method: 'POST',
headers: authHeaders(owner.cookie),
body: JSON.stringify({
page_version_id: overLimitPage.body.data.currentVersionId,
access_mode: 'public',
url_slug: 'over-limit-page',
acknowledged_finding_ids: [],
}),
},
);
assert.equal(overLimit.response.status, 429);
assert.equal(overLimit.body.error.code, 'public_page_limit_exceeded');
const updated = await request(`/api/mindspace/v1/pages/${page.id}`, {
method: 'PUT',
headers: authHeaders(owner.cookie),
body: JSON.stringify({
expected_version: 1,
title: '发布隔离测试 v2',
summary: '第二版摘要',
content: '# 第二版\n\n这是尚未重新发布的 V2 内容。',
template_id: 'report',
}),
});
assert.equal(updated.response.status, 200, JSON.stringify(updated.body));
const stillV1 = await request(publicUrl);
assert.equal(stillV1.response.status, 200);
assert.match(stillV1.body, /V1 内容/);
assert.doesNotMatch(stillV1.body, /V2 内容/);
const republished = await request(`/api/mindspace/v1/pages/${page.id}/publish`, {
method: 'POST',
headers: authHeaders(owner.cookie),
body: JSON.stringify({
page_version_id: updated.body.data.currentVersionId,
access_mode: 'owner_only',
url_slug: 'immutable-page',
acknowledged_finding_ids: [],
}),
});
assert.equal(republished.response.status, 201, JSON.stringify(republished.body));
const anonymousProtected = await request(publicUrl);
assert.equal(anonymousProtected.response.status, 404);
const foreignProtected = await request(publicUrl, { headers: { Cookie: other.cookie } });
assert.equal(foreignProtected.response.status, 404);
const ownerProtected = await request(publicUrl, { headers: { Cookie: owner.cookie } });
assert.equal(ownerProtected.response.status, 200);
assert.match(ownerProtected.body, /V2 内容/);
const publishMode = async (accessMode, extra = {}) => {
const response = await request(`/api/mindspace/v1/pages/${page.id}/publish`, {
method: 'POST',
headers: authHeaders(owner.cookie),
body: JSON.stringify({
page_version_id: updated.body.data.currentVersionId,
access_mode: accessMode,
url_slug: 'immutable-page',
acknowledged_finding_ids: [],
...extra,
}),
});
assert.equal(response.response.status, 201, JSON.stringify(response.body));
return response.body.data;
};
const loginRequired = await publishMode('login_required');
const anonymousLoginRequired = await request(publicUrl);
assert.equal(anonymousLoginRequired.response.status, 401);
const loggedInVisitor = await request(publicUrl, { headers: { Cookie: other.cookie } });
assert.equal(loggedInVisitor.response.status, 200);
assert.match(loggedInVisitor.body, /V2 内容/);
const accessPassword = 'Protected-Page-2026';
const passwordProtected = await publishMode('password', { password: accessPassword });
const passwordGate = await request(publicUrl);
assert.equal(passwordGate.response.status, 200);
assert.match(passwordGate.body, /此页面受密码保护/);
assert.doesNotMatch(passwordGate.body, /V2 内容/);
const wrongPassword = await request(publicUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ password: 'wrong-password' }),
});
assert.equal(wrongPassword.response.status, 403);
const correctPassword = await request(publicUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ password: accessPassword }),
});
assert.equal(correctPassword.response.status, 200);
assert.match(correctPassword.body, /V2 内容/);
const privateLink = await publishMode('private_link');
assert.match(privateLink.publicUrl, /^\/s\/[A-Za-z0-9_-]+$/);
const privateLinkPage = await request(privateLink.publicUrl);
assert.equal(privateLinkPage.response.status, 200);
assert.match(privateLinkPage.body, /V2 内容/);
const hiddenLongRoute = await request(publicUrl);
assert.equal(hiddenLongRoute.response.status, 404);
const timeLimited = await publishMode('time_limited', {
expires_at: Date.now() + 60_000,
});
const beforeExpiry = await request(publicUrl);
assert.equal(beforeExpiry.response.status, 200);
await pool.query(`UPDATE h5_publish_records SET expires_at = ? WHERE id = ?`, [
Date.now() - 1,
timeLimited.id,
]);
const afterExpiry = await request(publicUrl);
assert.equal(afterExpiry.response.status, 404);
const finalPublication = await publishMode('public');
const finalView = await request(publicUrl, {
headers: {
Cookie: owner.cookie,
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
},
});
assert.equal(finalView.response.status, 200);
const stats = await request(
`/api/mindspace/v1/publications/${finalPublication.id}/stats`,
{ headers: { Cookie: owner.cookie } },
);
assert.equal(stats.response.status, 200);
assert.equal(stats.body.data.totalViews, 1);
assert.deepEqual(stats.body.data.devices, [{ deviceType: 'desktop', views: 1 }]);
assert.deepEqual(stats.body.data.sources, [{ source: 'direct', views: 1 }]);
const publicHomepage = await request(`/u/${users[0].username}`);
assert.equal(publicHomepage.response.status, 200);
assert.match(publicHomepage.body, /当前公开发布且在线的 MindSpace 页面/);
assert.match(publicHomepage.body, /发布隔离测试 v2/);
assert.match(publicHomepage.body, /1 个公开页面/);
const foreignStats = await request(
`/api/mindspace/v1/publications/${finalPublication.id}/stats`,
{ headers: { Cookie: other.cookie } },
);
assert.equal(foreignStats.response.status, 404);
const risky = await request('/api/mindspace/v1/pages', {
method: 'POST',
headers: authHeaders(owner.cookie),
body: JSON.stringify({
title: '高风险页面',
content: '身份证号码 310101199001011234',
template_id: 'editorial',
}),
});
const blocked = await request(`/api/mindspace/v1/pages/${risky.body.data.id}/publish`, {
method: 'POST',
headers: authHeaders(owner.cookie),
body: JSON.stringify({
page_version_id: risky.body.data.currentVersionId,
access_mode: 'public',
url_slug: 'blocked-page',
acknowledged_finding_ids: [],
}),
});
assert.equal(blocked.response.status, 422);
assert.equal(blocked.body.error.code, 'security_risk_blocked');
const foreignOffline = await request(
`/api/mindspace/v1/publications/${finalPublication.id}/offline`,
{
method: 'POST',
headers: authHeaders(other.cookie),
body: '{}',
},
);
assert.equal(foreignOffline.response.status, 404);
const offlined = await request(
`/api/mindspace/v1/publications/${finalPublication.id}/offline`,
{
method: 'POST',
headers: authHeaders(owner.cookie),
body: '{}',
},
);
assert.equal(offlined.response.status, 200);
const afterOffline = await request(publicUrl, { headers: { Cookie: owner.cookie } });
assert.equal(afterOffline.response.status, 404);
const usageAfterOffline = await request('/api/mindspace/v1/space', {
headers: { Cookie: owner.cookie },
});
assert.equal(usageAfterOffline.body.data.quota.publicPageUsed, 0);
const [events] = await pool.query(
`SELECT event_type FROM h5_publication_events
WHERE publish_id IN (
SELECT id FROM h5_publish_records WHERE page_id = ?
) ORDER BY created_at`,
[page.id],
);
assert.equal(events[0].event_type, 'published');
assert.equal(events.at(-1).event_type, 'offlined');
assert.equal(
events.filter((event) => event.event_type === 'republished').length,
6,
);
console.log('MindSpace publication API E2E passed');
} finally {
await pool.query(`DELETE FROM h5_users WHERE username IN (?, ?)`, [
users[0].username,
users[1].username,
]);
await pool.end();
portal.kill('SIGTERM');
await new Promise((resolve) => portal.once('exit', resolve));
await new Promise((resolve) => portalLogs.includes('TKMind H5') ? resolve() : setTimeout(resolve, 10));
await fs.rm(storageRoot, { recursive: true, force: true });
}
@@ -0,0 +1,70 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadH5Environment } from './load-env.mjs';
import { createDbPool } from '../db.mjs';
import {
ensureHtmlThumbnail,
pageThumbnailKey,
assetThumbnailKey,
} from '../mindspace-thumbnails.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
loadH5Environment(path.dirname(fileURLToPath(import.meta.url)));
const storageRoot = process.env.MINDSPACE_STORAGE_ROOT
? path.resolve(process.env.MINDSPACE_STORAGE_ROOT)
: path.join(root, 'data', 'mindspace');
async function rebuildPageThumbnails(pool) {
const [rows] = await pool.query(
`SELECT p.id AS page_id, p.user_id, p.title, p.summary, p.page_type, pv.version_no, av.storage_key
FROM h5_page_records p
JOIN h5_page_versions pv ON pv.id = p.current_version_id
JOIN h5_asset_versions av ON av.asset_id = pv.content_asset_id AND av.version_no = 1
WHERE p.status <> 'deleted' AND p.page_type = 'html'`,
);
let rebuilt = 0;
for (const row of rows) {
const content = await fs.readFile(path.join(storageRoot, row.storage_key), 'utf8');
await ensureHtmlThumbnail(storageRoot, pageThumbnailKey(row.user_id, row.page_id), content, {
title: row.title,
subtitle: row.summary || `草稿 v${row.version_no}`,
tag: '网页',
contentStorageKey: row.storage_key,
});
rebuilt += 1;
}
return rebuilt;
}
async function rebuildAssetThumbnails(pool) {
const [rows] = await pool.query(
`SELECT a.id AS asset_id, a.user_id, a.display_name, c.category_code, av.storage_key
FROM h5_assets a
JOIN h5_space_categories c ON c.id = a.category_id
JOIN h5_asset_versions av ON av.asset_id = a.id AND av.version_no = 1
WHERE a.status <> 'deleted' AND a.mime_type = 'text/html'`,
);
let rebuilt = 0;
for (const row of rows) {
const content = await fs.readFile(path.join(storageRoot, row.storage_key), 'utf8');
await ensureHtmlThumbnail(storageRoot, assetThumbnailKey(row.user_id, row.asset_id), content, {
title: row.display_name,
subtitle: String(row.category_code ?? 'HTML').toUpperCase(),
contentStorageKey: row.storage_key,
});
rebuilt += 1;
}
return rebuilt;
}
const pool = createDbPool();
try {
const pages = await rebuildPageThumbnails(pool);
const assets = await rebuildAssetThumbnails(pool);
console.log(`已重建缩略图:${pages} 个页面,${assets} 个 HTML 资产`);
} finally {
await pool.end();
}
+502
View File
@@ -0,0 +1,502 @@
export function plazaOpenApiExtensions() {
const dataResponse = (description) => ({
description,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/DataEnvelope' },
},
},
});
const errorResponse = (description) => ({
description,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/ErrorResponse' },
},
},
});
return {
tags: [{ name: 'Plaza' }, { name: 'Plaza Ops' }],
paths: {
'/plaza/v1/feed': {
get: {
tags: ['Plaza'],
summary: 'Get plaza feed (hot or new)',
parameters: [
{ name: 'sort', in: 'query', schema: { type: 'string', enum: ['hot', 'new'] } },
{ name: 'category', in: 'query', schema: { type: 'string' } },
{ name: 'cursor', in: 'query', schema: { type: 'string' } },
{ name: 'limit', in: 'query', schema: { type: 'integer', maximum: 50 } },
],
responses: { 200: dataResponse('Feed page') },
},
},
'/plaza/v1/categories': {
get: {
tags: ['Plaza'],
summary: 'List plaza categories',
responses: { 200: dataResponse('Category list') },
},
},
'/plaza/v1/posts/{postId}': {
get: {
tags: ['Plaza'],
summary: 'Get published plaza post',
parameters: [{ $ref: '#/components/parameters/PostId' }],
responses: {
200: dataResponse('Post detail'),
404: errorResponse('Post not found or hidden'),
},
},
patch: {
tags: ['Plaza'],
summary: 'Update own plaza post metadata',
parameters: [{ $ref: '#/components/parameters/PostId' }],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/UpdatePlazaPostRequest' },
},
},
},
responses: { 200: dataResponse('Updated post') },
},
delete: {
tags: ['Plaza'],
summary: 'Hide own plaza post',
parameters: [{ $ref: '#/components/parameters/PostId' }],
responses: { 200: dataResponse('Hidden post') },
},
},
'/plaza/v1/posts': {
post: {
tags: ['Plaza'],
summary: 'Publish an online MindSpace publication to plaza',
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/CreatePlazaPostRequest' },
},
},
},
responses: {
201: dataResponse('Created plaza post'),
409: errorResponse('Already published to plaza'),
},
},
},
'/plaza/v1/posts/{postId}/reactions': {
post: {
tags: ['Plaza'],
summary: 'Add like, collect, or share reaction',
parameters: [{ $ref: '#/components/parameters/PostId' }],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/PlazaReactionRequest' },
},
},
},
responses: { 200: dataResponse('Reaction applied') },
},
},
'/plaza/v1/posts/{postId}/reactions/{type}': {
delete: {
tags: ['Plaza'],
summary: 'Remove like or collect reaction',
parameters: [
{ $ref: '#/components/parameters/PostId' },
{ $ref: '#/components/parameters/ReactionType' },
],
responses: { 200: dataResponse('Reaction removed') },
},
},
'/plaza/v1/posts/{postId}/comments': {
get: {
tags: ['Plaza'],
summary: 'List post comments',
parameters: [
{ $ref: '#/components/parameters/PostId' },
{ name: 'parent_id', in: 'query', schema: { type: 'string' } },
{ name: 'cursor', in: 'query', schema: { type: 'string' } },
],
responses: { 200: dataResponse('Comment page') },
},
post: {
tags: ['Plaza'],
summary: 'Create comment or reply',
parameters: [{ $ref: '#/components/parameters/PostId' }],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/CreatePlazaCommentRequest' },
},
},
},
responses: { 201: dataResponse('Created comment') },
},
},
'/plaza/v1/comments/{commentId}': {
delete: {
tags: ['Plaza'],
summary: 'Soft-delete own comment',
parameters: [{ $ref: '#/components/parameters/CommentId' }],
responses: { 200: dataResponse('Deleted comment') },
},
},
'/plaza/v1/comments/{commentId}/reactions': {
post: {
tags: ['Plaza'],
summary: 'Like or unlike a comment',
parameters: [{ $ref: '#/components/parameters/CommentId' }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['liked'],
properties: { liked: { type: 'boolean' } },
},
},
},
},
responses: { 200: dataResponse('Comment reaction') },
},
},
'/plaza/v1/posts/{postId}/reports': {
post: {
tags: ['Plaza'],
summary: 'Report a plaza post',
parameters: [{ $ref: '#/components/parameters/PostId' }],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/PlazaReportRequest' },
},
},
},
responses: { 201: dataResponse('Report created') },
},
},
'/plaza/v1/comments/{commentId}/reports': {
post: {
tags: ['Plaza'],
summary: 'Report a plaza comment',
parameters: [{ $ref: '#/components/parameters/CommentId' }],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/PlazaReportRequest' },
},
},
},
responses: { 201: dataResponse('Report created') },
},
},
'/plaza/v1/users/{slug}': {
get: {
tags: ['Plaza'],
summary: 'Get plaza user profile',
parameters: [{ $ref: '#/components/parameters/UserSlug' }],
responses: { 200: dataResponse('User profile') },
},
},
'/plaza/v1/users/{slug}/posts': {
get: {
tags: ['Plaza'],
summary: 'List user published plaza posts',
parameters: [
{ $ref: '#/components/parameters/UserSlug' },
{ name: 'cursor', in: 'query', schema: { type: 'string' } },
],
responses: { 200: dataResponse('User posts') },
},
},
'/plaza/v1/users/{slug}/follow': {
post: {
tags: ['Plaza'],
summary: 'Follow user',
parameters: [{ $ref: '#/components/parameters/UserSlug' }],
responses: { 200: dataResponse('Following') },
},
delete: {
tags: ['Plaza'],
summary: 'Unfollow user',
parameters: [{ $ref: '#/components/parameters/UserSlug' }],
responses: { 200: dataResponse('Unfollowed') },
},
},
'/plaza/v1/seo/sitemap': {
get: {
tags: ['Plaza'],
summary: 'Sitemap data for Next.js',
responses: { 200: dataResponse('Sitemap entries') },
},
},
'/plaza/v1/attribution/events': {
post: {
tags: ['Plaza'],
summary: 'Record UTM attribution event',
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/PlazaAttributionRequest' },
},
},
},
responses: { 201: dataResponse('Attribution recorded') },
},
},
'/ops/v1/review/queue': {
get: {
tags: ['Plaza Ops'],
summary: 'List posts in review queue',
parameters: [
{ name: 'status', in: 'query', schema: { type: 'string' } },
{ name: 'cursor', in: 'query', schema: { type: 'string' } },
],
responses: { 200: dataResponse('Review queue') },
},
},
'/ops/v1/review/posts/{postId}': {
post: {
tags: ['Plaza Ops'],
summary: 'Review a single plaza post',
parameters: [{ $ref: '#/components/parameters/PostId' }],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/OpsReviewRequest' },
},
},
},
responses: { 200: dataResponse('Review result') },
},
},
'/ops/v1/review/batch': {
post: {
tags: ['Plaza Ops'],
summary: 'Batch review plaza posts (editor+)',
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/OpsBatchReviewRequest' },
},
},
},
responses: { 200: dataResponse('Batch review result') },
},
},
'/ops/v1/reports': {
get: {
tags: ['Plaza Ops'],
summary: 'List pending reports',
responses: { 200: dataResponse('Reports') },
},
},
'/ops/v1/reports/{reportId}/process': {
post: {
tags: ['Plaza Ops'],
summary: 'Process a report',
parameters: [{ $ref: '#/components/parameters/ReportId' }],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/OpsProcessReportRequest' },
},
},
},
responses: { 200: dataResponse('Processed report') },
},
},
'/ops/v1/featured': {
get: {
tags: ['Plaza Ops'],
summary: 'List featured placements',
responses: { 200: dataResponse('Featured list') },
},
post: {
tags: ['Plaza Ops'],
summary: 'Set featured placement',
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/OpsFeaturedRequest' },
},
},
},
responses: { 201: dataResponse('Featured created') },
},
},
'/ops/v1/featured/{featuredId}': {
delete: {
tags: ['Plaza Ops'],
summary: 'Remove featured placement',
parameters: [{ $ref: '#/components/parameters/FeaturedId' }],
responses: { 200: dataResponse('Featured removed') },
},
},
'/ops/v1/analytics/overview': {
get: {
tags: ['Plaza Ops'],
summary: 'Plaza analytics overview',
responses: { 200: dataResponse('Analytics overview') },
},
},
'/ops/v1/creators': {
get: {
tags: ['Plaza Ops'],
summary: 'List plaza creators',
parameters: [{ name: 'keyword', in: 'query', schema: { type: 'string' } }],
responses: { 200: dataResponse('Creators') },
},
},
'/ops/v1/creators/{userId}': {
patch: {
tags: ['Plaza Ops'],
summary: 'Update creator moderation flags',
parameters: [{ $ref: '#/components/parameters/OpsUserId' }],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/OpsCreatorPatchRequest' },
},
},
},
responses: { 200: dataResponse('Creator updated') },
},
},
},
parameters: {
PostId: { name: 'postId', in: 'path', required: true, schema: { type: 'string' } },
CommentId: { name: 'commentId', in: 'path', required: true, schema: { type: 'string' } },
UserSlug: { name: 'slug', in: 'path', required: true, schema: { type: 'string' } },
ReactionType: {
name: 'type',
in: 'path',
required: true,
schema: { type: 'string', enum: ['like', 'collect'] },
},
ReportId: { name: 'reportId', in: 'path', required: true, schema: { type: 'string' } },
FeaturedId: { name: 'featuredId', in: 'path', required: true, schema: { type: 'string' } },
OpsUserId: { name: 'userId', in: 'path', required: true, schema: { type: 'string' } },
},
schemas: {
CreatePlazaPostRequest: {
type: 'object',
required: ['publication_id', 'category_id'],
properties: {
publication_id: { type: 'string' },
category_id: { type: 'string' },
category_slug: { type: 'string' },
tags: { type: 'array', items: { type: 'string' } },
cover_url: { type: 'string' },
allow_comment: { type: 'boolean' },
},
},
UpdatePlazaPostRequest: {
type: 'object',
properties: {
tags: { type: 'array', items: { type: 'string' } },
cover_url: { type: 'string' },
allow_comment: { type: 'boolean' },
},
},
PlazaReactionRequest: {
type: 'object',
required: ['type'],
properties: {
type: { type: 'string', enum: ['like', 'collect', 'share'] },
},
},
CreatePlazaCommentRequest: {
type: 'object',
required: ['content'],
properties: {
content: { type: 'string', maxLength: 500 },
parent_id: { type: ['string', 'null'] },
},
},
PlazaReportRequest: {
type: 'object',
required: ['reason'],
properties: {
reason: {
type: 'string',
enum: ['spam', 'violence', 'porn', 'political', 'privacy', 'other'],
},
detail: { type: 'string', maxLength: 500 },
},
},
PlazaAttributionRequest: {
type: 'object',
required: ['event_type', 'utm_source'],
properties: {
event_type: { type: 'string', enum: ['landing', 'signup'] },
utm_source: { type: 'string' },
utm_medium: { type: 'string' },
utm_campaign: { type: 'string' },
ref_id: { type: 'string' },
},
},
OpsReviewRequest: {
type: 'object',
required: ['action'],
properties: {
action: { type: 'string', enum: ['approve', 'reject', 'hide'] },
reason: { type: 'string' },
},
},
OpsBatchReviewRequest: {
type: 'object',
required: ['post_ids', 'action'],
properties: {
post_ids: { type: 'array', items: { type: 'string' }, maxItems: 50 },
action: { type: 'string', enum: ['approve', 'reject', 'hide'] },
reason: { type: 'string' },
},
},
OpsProcessReportRequest: {
type: 'object',
required: ['action'],
properties: {
action: { type: 'string', enum: ['dismiss', 'hide_post'] },
action_taken: { type: 'string' },
},
},
OpsFeaturedRequest: {
type: 'object',
required: ['post_id', 'position'],
properties: {
post_id: { type: 'string' },
position: { type: 'string', enum: ['homepage_banner', 'category_top', 'trending'] },
sort_order: { type: 'integer' },
expires_at: { type: ['string', 'null'], format: 'date-time' },
},
},
OpsCreatorPatchRequest: {
type: 'object',
properties: {
verified: { type: 'boolean' },
post_banned: { type: 'boolean' },
comment_banned: { type: 'boolean' },
},
},
},
};
}
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
buildFeedThumbnailSvg,
buildThumbnailSvg,
extractCoverSignals,
generateHtmlThumbnail,
resolveCoverDataUri,
} from '../mindspace-thumbnails.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const outputDir = path.join(root, 'public', 'thumbnail-demo');
const samples = [
{
id: 'malaysia-travel',
file: path.join(root, 'MindSpace', 'john', 'malaysia-travel-guide.html'),
meta: {
tag: '旅行攻略',
subtitle: '马来西亚深度游',
cover: 'https://images.unsplash.com/photo-1596422846543-75c6fc197f07?auto=format&fit=crop&w=1200&q=80',
},
},
{
id: 'mapo-tofu',
file: path.join(root, 'MindSpace', 'john', 'mapo-tofu.html'),
meta: {
tag: '美食专题',
cover: 'https://images.unsplash.com/photo-1525755662778-989d0529657e?auto=format&fit=crop&w=1200&q=80',
},
},
];
function galleryCard({ id, title, subtitle }) {
return `
<article class="feed-card">
<div class="feed-cover"><img src="./${id}-feed.svg" alt="${title} 预览图" /></div>
<div class="feed-body">
<h3>${title}</h3>
<p>${subtitle}</p>
</div>
</article>`;
}
async function main() {
await fs.mkdir(outputDir, { recursive: true });
const cards = [];
for (const sample of samples) {
let html = '';
try {
html = await fs.readFile(sample.file, 'utf8');
} catch {
console.warn(`跳过缺失样本: ${sample.file}`);
continue;
}
const signals = extractCoverSignals(html, sample.meta);
const legacySvg = buildThumbnailSvg({
title: signals.title,
subtitle: sample.meta.subtitle ?? signals.subtitle,
accent: signals.accent,
});
const coverDataUri = signals.image
? await resolveCoverDataUri({
storageRoot: outputDir,
contentBaseDir: path.dirname(sample.file),
imageUrl: signals.image,
})
: null;
const feedSvg = buildFeedThumbnailSvg(signals, { coverDataUri });
await fs.writeFile(path.join(outputDir, `${sample.id}-legacy.svg`), legacySvg, 'utf8');
await fs.writeFile(path.join(outputDir, `${sample.id}-feed.svg`), feedSvg, 'utf8');
await generateHtmlThumbnail(outputDir, `${sample.id}-generated.svg`, html, {
...sample.meta,
contentBaseDir: path.dirname(sample.file),
});
cards.push(
galleryCard({
id: sample.id,
title: signals.title,
subtitle: `${signals.tag} · 新版 3:4 封面`,
}),
);
}
const html = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MindSpace 预览图 Demo</title>
<style>
:root { color-scheme: light; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f4f0e7; color: #18211d; }
body { margin: 0; padding: 24px; }
h1 { margin: 0 0 8px; font-size: 28px; }
.lead { margin: 0 0 24px; color: #5f6963; line-height: 1.6; }
.compare { display: grid; gap: 28px; }
.sample { padding: 20px; border-radius: 20px; background: #fff; box-shadow: 0 10px 30px rgba(24,33,29,0.08); }
.sample h2 { margin: 0 0 16px; font-size: 20px; }
.pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 18px; }
.thumb { display: grid; gap: 8px; }
.thumb img { width: 100%; border-radius: 12px; box-shadow: 0 8px 24px rgba(24,33,29,0.12); }
.thumb span { font-size: 13px; color: #6d7771; }
.feed-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; max-width: 520px; }
.feed-card { border: none; border-radius: 12px; overflow: hidden; background: #fff; box-shadow: 0 2px 10px rgba(24,33,29,0.07); }
.feed-cover { aspect-ratio: 3 / 4; background: #ece7df; }
.feed-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.feed-body { padding: 10px 12px 12px; }
.feed-body h3 { margin: 0; font-size: 14px; line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.feed-body p { margin: 6px 0 0; font-size: 12px; color: #939c97; }
</style>
</head>
<body>
<h1>MindSpace 预览图升级 Demo</h1>
<p class="lead">左侧为旧版横版缩略图,中间为新版 3:4 信息流封面(自动从 HTML 提取标题、配色、emoji),右侧为保存页面时实际写入的 thumbnail.svg。下方是小红书风格卡片效果。</p>
<div class="compare">
${cards
.map(
(card, index) => `
<section class="sample">
<h2>样本 ${index + 1}</h2>
<div class="pair">
<div class="thumb"><img src="./${samples[index].id}-legacy.svg" alt="旧版" /><span>旧版(横版字卡)</span></div>
<div class="thumb"><img src="./${samples[index].id}-feed.svg" alt="新版" /><span>新版(3:4 精美封面)</span></div>
<div class="thumb"><img src="./${samples[index].id}-generated.svg" alt="生成结果" /><span>generateHtmlThumbnail 输出</span></div>
</div>
<div class="feed-grid" style="margin-top:18px">${card}</div>
</section>`,
)
.join('')}
</div>
</body>
</html>`;
await fs.writeFile(path.join(outputDir, 'index.html'), html, 'utf8');
console.log(`已生成预览页: ${path.join(outputDir, 'index.html')}`);
console.log('本地查看: http://localhost:5173/thumbnail-demo/');
}
await main();