feat: add Playwright long image downloads

This commit is contained in:
john
2026-07-02 18:10:33 +08:00
parent 8260ff1e0e
commit f9eb3d1440
16 changed files with 993 additions and 64 deletions
+1 -1
View File
@@ -233,7 +233,7 @@ export function sandboxDeveloperTools(capabilities) {
export function sandboxMcpTools(capabilities) {
const tools = [];
if (capabilities.static_publish) {
tools.push('read_file', 'write_file', 'edit_file', 'create_dir');
tools.push('read_file', 'write_file', 'edit_file', 'create_dir', 'generate_long_image');
if (capabilities.shell || capabilities.code_browse) tools.push('list_dir');
}
if (capabilities.private_data_space) {
+2
View File
@@ -178,6 +178,7 @@ test('sandboxMcpTools returns correct tool list based on capabilities', () => {
'write_file',
'edit_file',
'create_dir',
'generate_long_image',
'private_data_info',
'private_data_schema',
'private_data_query',
@@ -240,6 +241,7 @@ test('static_publish with sandboxMcp uses stdio sandbox-fs extension instead of
assert.equal(sandboxExt.args[1], '/opt/h5/MindSpace/abc123'); // also passed as argv[2]
assert.ok(sandboxExt.available_tools.includes('write_file'));
assert.ok(sandboxExt.available_tools.includes('read_file'));
assert.ok(sandboxExt.available_tools.includes('generate_long_image'));
// built-in developer extension should only remain for read_image (image_read: true by default)
const developer = policy.extensionOverrides.find((ext) => ext.name === 'developer');
+2 -1
View File
@@ -113,7 +113,8 @@ export function buildChatSkillPrompt(promptKey, skillName) {
`请使用 ${skillName ?? PUBLISH_SKILL_NAME} 技能:在我的专属 MindSpace 发布目录生成静态 HTML 页面,并给出可公网访问的完整链接。` +
'必须先 load_skill,再用 write_file/edit_file 写入 public/页面.html;完成后直接返回 Markdown 可点击公网链接 `[页面标题](URL)`。' +
'禁止只给本地路径(如 hello/index.html),禁止询问是否还要发布,除非写文件工具实际失败。' +
'如果页面里还需要提供 Word / docx 下载,必须先 `load_skill` → `docx-generate`,生成 `public/*.docx` 并确认文件已存在,再写 HTML 并用同目录相对路径链接该文档。'
'如果页面里还需要提供 Word / docx 下载,必须先 `load_skill` → `docx-generate`,生成 `public/*.docx` 并确认文件已存在,再写 HTML 并用同目录相对路径链接该文档。' +
'如果用户要求长图下载,必须先 `load_skill` → `long-image-download`,调用 `generate_long_image` 生成 `public/*.long.png` 并确认文件已存在,再给长图预览链接和 `?download=long-image` 下载链接。'
);
default:
return '';
+2
View File
@@ -44,6 +44,8 @@ test('buildChatSkillPrompt includes skill name for platform skills', () => {
assert.match(buildChatSkillPrompt('generate-page'), /static-page-publish/);
assert.match(buildChatSkillPrompt('generate-page'), /docx-generate/);
assert.match(buildChatSkillPrompt('generate-page'), /public\/\*\.docx/);
assert.match(buildChatSkillPrompt('generate-page'), /long-image-download/);
assert.match(buildChatSkillPrompt('generate-page'), /generate_long_image/);
});
test('prefillOnly is set for open-ended chat skills', () => {
+106
View File
@@ -0,0 +1,106 @@
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const DEFAULT_VIEWPORT_WIDTH = 1280;
const DEFAULT_VIEWPORT_HEIGHT = 720;
const MAX_LONG_IMAGE_HEIGHT = 20000;
export function isLongImageDownloadRequest(query) {
const value = String(query?.download ?? query?.export ?? '').trim().toLowerCase();
return value === 'long-image' || value === 'long_image' || value === 'png';
}
export function longImagePathForHtml(htmlPath, outputPath = null) {
return outputPath || String(htmlPath).replace(/\.html$/i, '.long.png');
}
function clampDimension(value, fallback, max) {
const number = Math.ceil(Number(value) || fallback);
return Math.max(320, Math.min(number, max));
}
async function launchChromium(chromium) {
const base = {
headless: true,
args: ['--disable-dev-shm-usage', '--hide-scrollbars'],
};
if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH) {
return chromium.launch({
...base,
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
});
}
if (process.env.PLAYWRIGHT_CHROMIUM_CHANNEL) {
return chromium.launch({ ...base, channel: process.env.PLAYWRIGHT_CHROMIUM_CHANNEL });
}
try {
return await chromium.launch(base);
} catch (error) {
if (process.platform === 'darwin') {
return chromium.launch({ ...base, channel: 'chrome' });
}
throw error;
}
}
export async function renderLongImage({
htmlPath = null,
url = null,
outputPath = null,
viewportWidth = DEFAULT_VIEWPORT_WIDTH,
viewportHeight = DEFAULT_VIEWPORT_HEIGHT,
} = {}) {
if (!htmlPath && !url) throw new Error('缺少 htmlPath 或 url');
const targetUrl = url || pathToFileURL(path.resolve(htmlPath)).toString();
const destination = outputPath ? path.resolve(outputPath) : longImagePathForHtml(path.resolve(htmlPath));
const { chromium } = await import('playwright');
let browser = null;
try {
browser = await launchChromium(chromium);
const page = await browser.newPage({
viewport: {
width: clampDimension(viewportWidth, DEFAULT_VIEWPORT_WIDTH, 2400),
height: clampDimension(viewportHeight, DEFAULT_VIEWPORT_HEIGHT, MAX_LONG_IMAGE_HEIGHT),
},
deviceScaleFactor: 2,
});
await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 30000 });
await page.evaluate(() => document.fonts?.ready).catch(() => null);
const size = await page.evaluate(() => ({
width: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, 320),
height: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, 320),
}));
await page.setViewportSize({
width: clampDimension(size.width, DEFAULT_VIEWPORT_WIDTH, 2400),
height: Math.min(clampDimension(size.height, DEFAULT_VIEWPORT_HEIGHT, MAX_LONG_IMAGE_HEIGHT), 2400),
});
await fsPromises.mkdir(path.dirname(destination), { recursive: true });
await page.screenshot({
path: destination,
fullPage: true,
type: 'png',
animations: 'disabled',
caret: 'hide',
});
return {
outputPath: destination,
bytes: fs.statSync(destination).size,
};
} finally {
await browser?.close().catch(() => {});
}
}
export async function renderLongImageBuffer({ url }) {
const tempDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'memind-long-image-'));
const outputPath = path.join(tempDir, 'page.long.png');
try {
await renderLongImage({ url, outputPath });
return await fsPromises.readFile(outputPath);
} finally {
await fsPromises.rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}
+33
View File
@@ -15,6 +15,7 @@ import { execFileSync } from 'node:child_process';
import mysql from 'mysql2/promise';
import { createScheduleService } from './schedule-service.mjs';
import { resolveScheduleTimestamp } from './schedule-time.mjs';
import { renderLongImage } from './mindspace-long-image.mjs';
const SANDBOX_ROOT = process.argv[2]?.trim() || process.env.SANDBOX_ROOT?.trim();
if (!SANDBOX_ROOT) {
@@ -108,6 +109,19 @@ const ALL_TOOLS = [
required: ['path'],
},
},
{
name: 'generate_long_image',
description:
'用 Playwright 将工作区内的 HTML 页面渲染为整页 PNG 长图。输出文件通常为 public/<页面名>.long.png,必须在工作区内。',
inputSchema: {
type: 'object',
properties: {
html_path: { type: 'string', description: 'HTML 文件路径,如 public/report.html' },
output_path: { type: 'string', description: '输出 PNG 路径,如 public/report.long.png;可选' },
},
required: ['html_path'],
},
},
{
name: 'private_data_info',
description:
@@ -458,6 +472,25 @@ async function callTool(name, args) {
fs.mkdirSync(abs, { recursive: true });
return [{ type: 'text', text: `已创建目录 ${args.path}` }];
}
case 'generate_long_image': {
const htmlPath = String(args.html_path ?? args.path ?? '').trim();
if (!htmlPath.toLowerCase().endsWith('.html')) {
throw new Error('generate_long_image: html_path 必须是 .html 文件');
}
const htmlAbs = resolveSandboxed(htmlPath);
const outputPath = String(args.output_path ?? '').trim() || htmlPath.replace(/\.html$/i, '.long.png');
if (!outputPath.toLowerCase().endsWith('.png')) {
throw new Error('generate_long_image: output_path 必须是 .png 文件');
}
const outputAbs = resolveSandboxed(outputPath);
const result = await renderLongImage({ htmlPath: htmlAbs, outputPath: outputAbs });
return [
{
type: 'text',
text: `已生成长图 ${outputPath}${result.bytes} 字节)`,
},
];
}
case 'private_data_info': {
ensurePrivateDataDb();
const size = privateDataSize();
+664
View File
@@ -18,11 +18,13 @@
"lucide-react": "^1.21.0",
"mysql2": "^3.22.5",
"pg": "^8.22.0",
"playwright": "^1.61.1",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.13.1",
"redis": "^4.7.1",
"sharp": "^0.35.2",
"undici": "^6.26.0"
},
"devDependencies": {
@@ -789,6 +791,554 @@
"node": ">=18"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.2"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.2"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.2"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.1"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2248,6 +2798,15 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
@@ -3358,6 +3917,50 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
@@ -3783,6 +4386,67 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.3",
"@img/sharp-darwin-x64": "0.35.3",
"@img/sharp-freebsd-wasm32": "0.35.3",
"@img/sharp-libvips-darwin-arm64": "1.3.2",
"@img/sharp-libvips-darwin-x64": "1.3.2",
"@img/sharp-libvips-linux-arm": "1.3.2",
"@img/sharp-libvips-linux-arm64": "1.3.2",
"@img/sharp-libvips-linux-ppc64": "1.3.2",
"@img/sharp-libvips-linux-riscv64": "1.3.2",
"@img/sharp-libvips-linux-s390x": "1.3.2",
"@img/sharp-libvips-linux-x64": "1.3.2",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
"@img/sharp-linux-arm": "0.35.3",
"@img/sharp-linux-arm64": "0.35.3",
"@img/sharp-linux-ppc64": "0.35.3",
"@img/sharp-linux-riscv64": "0.35.3",
"@img/sharp-linux-s390x": "0.35.3",
"@img/sharp-linux-x64": "0.35.3",
"@img/sharp-linuxmusl-arm64": "0.35.3",
"@img/sharp-linuxmusl-x64": "0.35.3",
"@img/sharp-webcontainers-wasm32": "0.35.3",
"@img/sharp-win32-arm64": "0.35.3",
"@img/sharp-win32-ia32": "0.35.3",
"@img/sharp-win32-x64": "0.35.3"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/sharp/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+1
View File
@@ -68,6 +68,7 @@
"lucide-react": "^1.21.0",
"mysql2": "^3.22.5",
"pg": "^8.22.0",
"playwright": "^1.61.1",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+29
View File
@@ -38,6 +38,9 @@ importers:
pg:
specifier: ^8.22.0
version: 8.22.0
playwright:
specifier: ^1.61.1
version: 1.61.1
qrcode:
specifier: ^1.5.4
version: 1.5.4
@@ -1142,6 +1145,11 @@ packages:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1418,6 +1426,16 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
playwright-core@1.61.1:
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.61.1:
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
engines: {node: '>=18'}
hasBin: true
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
@@ -2595,6 +2613,9 @@ snapshots:
fresh@0.5.2: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -2842,6 +2863,14 @@ snapshots:
picomatch@4.0.4: {}
playwright-core@1.61.1: {}
playwright@1.61.1:
dependencies:
playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2
pngjs@5.0.0: {}
postcss@8.5.15:
+73 -60
View File
@@ -102,6 +102,12 @@ import {
rasterizeThumbnailSvgToPng,
thumbnailPngPathForSvg,
} from './mindspace-thumbnail-png.mjs';
import {
isLongImageDownloadRequest,
longImagePathForHtml,
renderLongImage,
renderLongImageBuffer,
} from './mindspace-long-image.mjs';
import { scanContent } from './mindspace-content-scan.mjs';
import { renderImageAssetViewerHtml, wantsInlineImageViewer } from './mindspace-asset-preview.mjs';
import { createRechargeService } from './billing-recharge.mjs';
@@ -4390,7 +4396,7 @@ function publishedPageCsp(html, { embed = false, raw = false, wechatShare = fals
return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'";
}
const PUBLIC_FILE_SHARE_SCRIPT = `(function(){var root=document.querySelector('[data-mindspace-public-share]');if(!root)return;var button=root.querySelector('button');var status=root.querySelector('small');var timer=null;function setStatus(text,err){if(!status)return;status.textContent=text||'';status.classList.toggle('is-error',!!err);if(timer)clearTimeout(timer);if(text)timer=setTimeout(function(){status.textContent='';status.classList.remove('is-error');},2200);}function fallbackCopy(text){var ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';ta.style.opacity='0';document.body.appendChild(ta);ta.focus();ta.select();var ok=document.execCommand('copy');ta.remove();if(!ok)throw new Error('复制失败');}async function copyText(text){if(navigator.clipboard&&navigator.clipboard.writeText){try{await navigator.clipboard.writeText(text);return;}catch(e){}}fallbackCopy(text);}button&&button.addEventListener('click',async function(){var url=location.href.split('#')[0];var title=document.title||'MindSpace';try{if(navigator.share){await navigator.share({title:title,url:url});setStatus('已打开分享');return;}await copyText(url);setStatus('链接已复制');}catch(e){setStatus(e&&e.message?e.message:'分享失败',true);}});})();`;
const PUBLIC_FILE_SHARE_SCRIPT = `(function(){var root=document.querySelector('[data-mindspace-public-share]');if(!root)return;var shareButton=root.querySelector('[data-action="share"]');var captureButton=root.querySelector('[data-action="capture"]');var status=root.querySelector('small');var timer=null;function setStatus(text,err){if(!status)return;status.textContent=text||'';status.classList.toggle('is-error',!!err);if(timer)clearTimeout(timer);if(text)timer=setTimeout(function(){status.textContent='';status.classList.remove('is-error');},2200);}function cleanUrl(){var url=new URL(location.href);url.hash='';url.searchParams.delete('download');url.searchParams.delete('export');return url.toString();}function withParam(url,key,value){var next=new URL(url);next.searchParams.set(key,value);return next.toString();}function fallbackCopy(text){var ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';ta.style.opacity='0';document.body.appendChild(ta);ta.focus();ta.select();var ok=document.execCommand('copy');ta.remove();if(!ok)throw new Error('复制失败');}async function copyText(text){if(navigator.clipboard&&navigator.clipboard.writeText){try{await navigator.clipboard.writeText(text);return;}catch(e){}}fallbackCopy(text);}shareButton&&shareButton.addEventListener('click',async function(){var url=cleanUrl();var title=document.title||'MindSpace';try{if(navigator.share){await navigator.share({title:title,url:url});setStatus('已打开分享');return;}await copyText(url);setStatus('链接已复制');}catch(e){setStatus(e&&e.message?e.message:'分享失败',true);}});captureButton&&captureButton.addEventListener('click',function(){setStatus('正在生成长图,请稍候');var link=document.createElement('a');link.href=withParam(cleanUrl(),'download','long-image');link.download='';document.body.appendChild(link);link.click();link.remove();});})();`;
const PUBLIC_FILE_SHARE_SCRIPT_HASH = crypto
.createHash('sha256')
.update(PUBLIC_FILE_SHARE_SCRIPT)
@@ -4404,14 +4410,16 @@ function injectPublicFileShareButton(html) {
const markup = `
<style id="mindspace-public-share-style">
[data-mindspace-public-share]{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom,0px));z-index:2147483000;display:flex;flex-direction:column;align-items:flex-end;gap:6px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
[data-mindspace-public-share] div{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap}
[data-mindspace-public-share] button{border:0;border-radius:999px;padding:10px 15px;background:#2f6f57;color:#fff;font-size:14px;font-weight:700;box-shadow:0 8px 24px rgba(0,0,0,.18);cursor:pointer}
[data-mindspace-public-share] button[data-action="capture"]{background:#8f6b2f}
[data-mindspace-public-share] button:active{transform:translateY(1px)}
[data-mindspace-public-share] small{min-height:18px;max-width:180px;border-radius:999px;padding:4px 9px;background:rgba(255,255,255,.92);color:#245845;font-size:12px;text-align:right;box-shadow:0 4px 14px rgba(0,0,0,.1)}
[data-mindspace-public-share] small:empty{display:none}
[data-mindspace-public-share] small.is-error{color:#8b2d20}
@media(max-width:640px){[data-mindspace-public-share]{right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px))}[data-mindspace-public-share] button{padding:9px 13px;font-size:13px}}
</style>
<div data-mindspace-public-share><small aria-live="polite"></small><button type="button">公开分享</button></div>
<div data-mindspace-public-share><small aria-live="polite"></small><div><button type="button" data-action="capture">保存长图</button><button type="button" data-action="share">公开分享</button></div></div>
<script>${PUBLIC_FILE_SHARE_SCRIPT}</script>`;
if (/<\/body>/i.test(source)) {
return {
@@ -4446,6 +4454,16 @@ function appendQueryParam(url, key, value) {
return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
}
function removeQueryParam(url, key) {
if (!url || !url.includes('?')) return url;
const [base, queryAndHash] = url.split('?', 2);
const [query, hash = ''] = queryAndHash.split('#', 2);
const params = new URLSearchParams(query);
params.delete(key);
const nextQuery = params.toString();
return `${base}${nextQuery ? `?${nextQuery}` : ''}${hash ? `#${hash}` : ''}`;
}
function resolveRequestOrigin(req) {
const host = (req.headers['x-forwarded-host'] || req.headers.host || '').toString().split(',')[0].trim();
if (!host) return '';
@@ -4460,18 +4478,19 @@ function detectPublishedPageTitle(html) {
return match?.[1]?.replace(/\s+/g, ' ').trim() || 'MindSpace 页面';
}
function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
function publishedPageShellHtml({ iframeUrl, shareUrl, title, longImageUrl }) {
const iframeSrc = escapePublicHtml(iframeUrl);
const safeShareUrl = escapePublicHtml(shareUrl);
const safeTitle = escapePublicHtml(title);
const serializedShareUrl = JSON.stringify(shareUrl).replace(/</g, '\\u003c');
const serializedTitle = JSON.stringify(title).replace(/</g, '\\u003c');
const serializedLongImageUrl = JSON.stringify(longImageUrl).replace(/</g, '\\u003c');
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net; frame-src 'self'; base-uri 'none'; form-action 'none'">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self'; script-src 'unsafe-inline'; frame-src 'self'; base-uri 'none'; form-action 'none'">
<title>${safeTitle}</title>
<style>
* { box-sizing: border-box; }
@@ -4655,13 +4674,13 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
(function () {
var shareUrl = ${serializedShareUrl};
var shareTitle = ${serializedTitle};
var longImageUrl = ${serializedLongImageUrl};
var fab = document.getElementById('publication-share-fab');
var sheet = document.getElementById('publication-share-sheet');
var close = document.getElementById('publication-share-close');
var message = document.getElementById('publication-share-message');
var frame = document.querySelector('.publication-frame');
var actionButtons = sheet ? Array.prototype.slice.call(sheet.querySelectorAll('.publication-share-option')) : [];
var html2canvasLoader = null;
function setBusy(busy) {
actionButtons.forEach(function (button) {
button.disabled = !!busy;
@@ -4703,54 +4722,10 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
textarea.remove();
if (!copied) throw new Error('复制失败,请手动复制链接');
}
function loadHtml2Canvas() {
if (window.html2canvas) return Promise.resolve(window.html2canvas);
if (html2canvasLoader) return html2canvasLoader;
html2canvasLoader = new Promise(function (resolve, reject) {
var script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js';
script.async = true;
script.onload = function () {
if (!window.html2canvas) {
reject(new Error('无法加载页面截图能力'));
return;
}
resolve(window.html2canvas);
};
script.onerror = function () { reject(new Error('无法加载页面截图能力')); };
document.body.appendChild(script);
}).catch(function (error) {
html2canvasLoader = null;
throw error;
});
return html2canvasLoader;
}
async function saveLongImage() {
if (!frame || !frame.contentDocument || !frame.contentDocument.body) {
throw new Error('页面内容暂时不可访问');
}
var renderer = await loadHtml2Canvas();
var doc = frame.contentDocument;
var body = doc.body;
var width = Math.max(doc.documentElement ? doc.documentElement.scrollWidth : 0, body.scrollWidth, 320);
var height = Math.max(doc.documentElement ? doc.documentElement.scrollHeight : 0, body.scrollHeight, 320);
var canvas = await renderer(body, {
useCORS: true,
scale: 2,
backgroundColor: '#ffffff',
width: width,
height: height,
windowWidth: width,
windowHeight: height,
x: 0,
y: 0,
scrollX: 0,
scrollY: 0
});
var link = document.createElement('a');
var timestamp = new Date().toISOString().replace(/[T:]/g, '-').replace(/\..+/, '');
link.href = canvas.toDataURL('image/png');
link.download = 'mindspace-public-page-' + timestamp + '.png';
link.href = longImageUrl;
link.download = '';
document.body.appendChild(link);
link.click();
link.remove();
@@ -4824,10 +4799,12 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
</html>`;
}
function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}) {
async function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}) {
let html = result.html;
const origin = resolveRequestOrigin(req);
const pageUrl = req.originalUrl ? new URL(req.originalUrl, origin || 'http://localhost').toString().split('#')[0] : '';
const originalPath = req.originalUrl || req.url || '';
const sharePath = removeQueryParam(removeQueryParam(originalPath, 'download'), 'export');
const pageUrl = originalPath ? new URL(sharePath, origin || 'http://localhost').toString().split('#')[0] : '';
const pageDirUrl = pageUrl ? `${pageUrl.slice(0, pageUrl.lastIndexOf('/') + 1)}` : '';
const wechatShare = !embed && isWechatUserAgent(req.get('user-agent') || '');
if (embed) {
@@ -4845,14 +4822,31 @@ function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}
}
}
const isFullHtml = /^\s*<!doctype html/i.test(html) || /^\s*<html[\s>]/i.test(html);
if (!embed && !raw && isFullHtml && isLongImageDownloadRequest(req.query)) {
try {
const rawUrl = new URL(appendQueryParam(sharePath || originalPath, 'view', 'raw'), origin || 'http://localhost');
const image = await renderLongImageBuffer({ url: rawUrl.toString() });
res.set('Content-Type', 'image/png');
res.set('Content-Disposition', 'attachment; filename="mindspace-public-page.long.png"');
res.set('Cache-Control', 'no-store');
return res.send(image);
} catch (error) {
return res
.status(500)
.type('text/plain; charset=utf-8')
.send(`长图生成失败:${error?.message || '未知错误'}`);
}
}
const canWrapWithShell = !embed && !raw && isFullHtml && result.publication?.accessMode !== 'password';
if (canWrapWithShell) {
const title = detectPublishedPageTitle(html);
const rawUrl = appendQueryParam(req.originalUrl || req.url || '', 'view', 'raw');
const rawUrl = appendQueryParam(sharePath || originalPath, 'view', 'raw');
const longImageUrl = appendQueryParam(sharePath || originalPath, 'download', 'long-image');
let shellHtml = publishedPageShellHtml({
iframeUrl: rawUrl,
shareUrl: pageUrl,
title,
longImageUrl,
});
try {
shellHtml = injectOgTags(shellHtml, {
@@ -5019,7 +5013,7 @@ async function resolvePublishedRoute(req, res, password = null) {
referrer: req.get('referer'),
},
);
return sendPublishedPage(req, res, result, {
return await sendPublishedPage(req, res, result, {
embed: isPlazaEmbedRequest(req.query),
raw: String(req.query.view ?? '').toLowerCase() === 'raw',
});
@@ -5098,7 +5092,7 @@ app.get('/s/:token', async (req, res) => {
if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用');
try {
const viewer = req.userSession && userAuth ? await userAuth.getMe(req.userToken) : null;
return sendPublishedPage(
return await sendPublishedPage(
req,
res,
await mindSpacePublications.resolvePrivateLink(req.params.token, viewer?.id, {
@@ -5136,13 +5130,32 @@ async function resolvePublishDirKey(segment) {
* Send a file, injecting Open Graph tags for .html so forwarded links unfurl with a cover.
* Non-HTML files (assets, etc.) are streamed unchanged via res.sendFile.
*/
function sendPublishFile(req, res, filePath) {
async function sendLongImageDownloadIfRequested(req, res, filePath) {
if (!isLongImageDownloadRequest(req.query)) return false;
const longImagePath = longImagePathForHtml(filePath);
try {
await renderLongImage({ htmlPath: filePath, outputPath: longImagePath });
res.set('Cache-Control', 'no-store');
res.download(longImagePath, path.basename(longImagePath), (err) => {
if (err && !res.headersSent) res.status(404).json({ message: '长图文件不存在' });
});
} catch (error) {
res
.status(500)
.type('text/plain; charset=utf-8')
.send(`长图生成失败:${error?.message || '未知错误'}`);
}
return true;
}
async function sendPublishFile(req, res, filePath) {
if (!filePath.toLowerCase().endsWith('.html')) {
res.sendFile(filePath, (err) => {
if (err && !res.headersSent) res.status(404).json({ message: '文件不存在' });
});
return;
}
if (await sendLongImageDownloadIfRequested(req, res, filePath)) return;
let html;
try {
html = fs.readFileSync(filePath, 'utf8');
@@ -5320,7 +5333,7 @@ async function serveUserPublishFile(req, res, next) {
}
const recoveredPublicHtml = await recoverMisplacedPublicHtml(targetDir, resolvedRoot, rest);
if (recoveredPublicHtml) {
sendPublishFile(req, res, recoveredPublicHtml);
await sendPublishFile(req, res, recoveredPublicHtml);
return;
}
res.status(404).json({ message: '文件不存在' });
@@ -5330,14 +5343,14 @@ async function serveUserPublishFile(req, res, next) {
if (fs.statSync(resolvedPath).isDirectory()) {
const indexPath = path.join(resolvedPath, 'index.html');
if (fs.existsSync(indexPath)) {
sendPublishFile(req, res, indexPath);
await sendPublishFile(req, res, indexPath);
return;
}
res.status(404).json({ message: '目录中没有 index.html' });
return;
}
sendPublishFile(req, res, resolvedPath);
await sendPublishFile(req, res, resolvedPath);
}
app.use(`/${PUBLISH_ROOT_DIR}`, async (req, res, next) => {
+1
View File
@@ -18,6 +18,7 @@ export const DEFAULT_USER_SKILLS = {
'table-viewer': true,
'product-campaign-page': true,
'docx-generate': true,
'long-image-download': true,
[PUBLISH_SKILL_NAME]: false,
};
+2
View File
@@ -17,6 +17,7 @@ test('lists static-page-publish in platform catalog', () => {
assert.ok(catalog.some((item) => item.name === 'static-page-publish'));
assert.ok(catalog.some((item) => item.name === 'schedule-assistant'));
assert.ok(catalog.some((item) => item.name === 'product-campaign-page'));
assert.ok(catalog.some((item) => item.name === 'long-image-download'));
});
test('granting publish skill enables static_publish capability', () => {
@@ -50,5 +51,6 @@ test('DEFAULT_USER_SKILLS enables common platform skills', () => {
assert.equal(DEFAULT_USER_SKILLS['form-builder'], true);
assert.equal(DEFAULT_USER_SKILLS['table-viewer'], true);
assert.equal(DEFAULT_USER_SKILLS['product-campaign-page'], true);
assert.equal(DEFAULT_USER_SKILLS['long-image-download'], true);
assert.equal(DEFAULT_USER_SKILLS['static-page-publish'], false);
});
+36
View File
@@ -0,0 +1,36 @@
---
name: long-image-download
description: 用 Playwright 将 MindSpace HTML 页面生成同名 PNG 长图,并返回可打开图片链接与附件下载链接
---
# 长图下载(Playwright
## 何时使用
- 用户要求“长图下载”“保存长图”“页面图片格式”“整页 PNG”
- 已经或即将用 `static-page-publish` 生成 `public/*.html`
## 必须流程
1. 先确保 HTML 已经用 `write_file` / `edit_file` 写入 `public/<页面名>.html`
2. 调用 sandbox-fs 工具 `generate_long_image`
```json
{
"html_path": "public/report.html",
"output_path": "public/report.long.png"
}
```
3. 用 `list_dir public` 确认 `<页面名>.long.png` 已存在
4. 回复用户时同时给:
- 页面链接:`[页面标题](.../public/report.html)`
- 长图预览链接:`[长图预览](.../public/report.long.png)`
- 长图下载链接:`[下载长图](.../public/report.html?download=long-image)`
## 规则
- `generate_long_image` 使用平台 Playwright,不要用 shell、自写截图脚本、`html2canvas``.thumbnail.svg` 冒充长图
- `.thumbnail.svg` 只是信息流封面,不是整页长图
- 长图文件名推荐与 HTML 同名:`report.html` -> `report.long.png`
- 没有确认 `.long.png` 已生成时,不要声称“长图已生成”
+3
View File
@@ -23,6 +23,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
6. 静态文件保存即可访问,**无需重启**
7. 页面需提供 **Word/PDF 等二进制下载** 时:文件单独落盘(如 `public/方案.docx`),链接用相对路径;**禁止**在 HTML 内用 `data:...;base64,...` 嵌入 docx(易截断损坏)
8. 页面若提供 **Word/PDF/附件下载**,相对链接指向的文件必须与 HTML **同目录(或子目录)且真实存在**;改 HTML 文件名时同步 **rename/copy** 伴生文件
9. 页面需提供**长图下载**时:必须先 `load_skill``long-image-download`,调用 `generate_long_image` 生成同目录 `public/<页面名>.long.png`,再返回长图预览与下载链接;禁止把 `.thumbnail.svg` 当成长图
详细约束以工作区内的 `.goosehints``.agents/skills/static-page-publish/SKILL.md` 为准。
@@ -34,6 +35,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
4. 保存后服务端**立即**生成 `<文件名>.thumbnail.svg`Agent 交互阶段即生效)
5. 按「回复格式」返回**可点击**公网链接
6. 若页面含下载按钮,必须用 `generate_docx`sandbox-fs 工具)生成 `public/<同名>.docx`,再确认链接目标已落盘
7. 若用户要求长图下载,必须用 `long-image-download` 生成 `public/<同名>.long.png`,并确认文件存在
## 伴生下载文件(必须)
@@ -59,6 +61,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
- 标题用页面真实主题名
- 可同时给出相对路径(如 `public/malaysia-travel-guide.html`
- 说明:保存即生效,无需重启
- 若生成了长图,同时给 `[长图预览](.../public/malaysia-travel-guide.long.png)``[下载长图](.../public/malaysia-travel-guide.html?download=long-image)`
## 信息流预览图(必须)
+28 -2
View File
@@ -6,10 +6,12 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const PUBLISH_SKILL_NAME = 'static-page-publish';
export const DOCX_SKILL_NAME = 'docx-generate';
export const LONG_IMAGE_SKILL_NAME = 'long-image-download';
export const PUBLISH_ROOT_DIR = 'MindSpace';
export const PUBLIC_ZONE_DIR = 'public';
export const PUBLISH_SKILL_DIR = path.join(__dirname, 'skills', PUBLISH_SKILL_NAME);
export const DOCX_SKILL_DIR = path.join(__dirname, 'skills', DOCX_SKILL_NAME);
export const LONG_IMAGE_SKILL_DIR = path.join(__dirname, 'skills', LONG_IMAGE_SKILL_NAME);
export const WORKSPACE_HINTS_FILENAME = '.tkmindhints';
export const LEGACY_WORKSPACE_HINTS_FILENAME = '.goosehints';
@@ -141,7 +143,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
1. **唯一可写目录**\`${publishDir}\`
2. **禁止**使用绝对路径 \`/Users/...\`\`../\` 跳出目录)
3. **允许**在本目录内使用 \`write_file\`\`edit_file\`\`read_file\`\`list_dir\`\`generate_docx\`**禁止**访问此目录外的路径(含 \`../\`、其它用户目录、项目根目录;系统会在 OS 层拦截越界访问)
3. **允许**在本目录内使用 \`write_file\`\`edit_file\`\`read_file\`\`list_dir\`\`generate_docx\`\`generate_long_image\`**禁止**访问此目录外的路径(含 \`../\`、其它用户目录、项目根目录;系统会在 OS 层拦截越界访问)
4. **禁止** Agent扩展管理修改工作区外文件
5. 页面默认写入 \`public/\` 分区,使用相对路径如 \`public/report.html\`\`public/assets/chart.png\`
6. 生成 HTML 向用户提供可访问链接格式
@@ -156,7 +158,8 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
4. 页面内资源使用**相对路径**\`assets/foo.png\`),不要用磁盘绝对路径
5. 保存 HTML 服务端会**立即**生成同名预览图 \`<文件名>.thumbnail.svg\`(Agent 交互阶段即生效,无需等用户保存到「我的空间」)
6. 下载按钮的相对链接目标 \`public/report.docx\`)必须通过 \`generate_docx\` 落盘,且 basename 与 HTML 一致
7. 完成后按回复格式返回可点击链接
7. 若用户要求长图下载必须先 \`load_skill\`\`long-image-download\`,调用 \`generate_long_image\` 生成并确认 \`public/<页面名>.long.png\` 已存在
8. 完成后按回复格式返回可点击链接
## 伴生下载文件必须
@@ -186,6 +189,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
- 标题用页面真实主题不要用点击这里链接
- 可同时给出本地相对路径 \`public/pilates-618.html\`)与公网链接
- 说明静态文件保存即生效**无需重启**
- 若生成了长图同时给 \`[长图预览](.../public/pilates-618.long.png)\`\`[下载长图](.../public/pilates-618.html?download=long-image)\`
## 信息流预览图必须
@@ -241,6 +245,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
- shell 仅用于本目录内整理文件/简单脚本不要 \`rm -rf\` 越界路径、不要安装系统级依赖
- **禁止** HTML 中用 \`data:...;base64,...\` 内嵌 Word/PDF;二进制文件单独落盘后用相对路径链接(见 \`docx-generate\` 技能)
- 用户要求 Word / docx 下载时禁止跳过 \`docx-generate\` 直接在 HTML 中伪造下载链接
- 用户要求长图下载时禁止跳过 \`long-image-download\` 或把 \`.thumbnail.svg\` 当成长图
## 示例
@@ -282,6 +287,13 @@ ${renderBrandingBlock(addressName)}
- 若用户要求页面里可下载 Word / docx必须先 \`load_skill\`\`docx-generate\`
- 先生成并确认 \`public/*.docx\` 已存在,再写 \`public/*.html\`
- HTML 中只能用同目录相对路径链接该文档禁止只写下载按钮却没有先把 \`.docx\` 落盘
## 长图下载
- 若用户要求长图下载必须先 \`load_skill\`\`long-image-download\`
- 调用 \`generate_long_image\` 生成 \`public/<页面名>.long.png\`
- 先确认 \`public/*.long.png\` 已存在,再回复 \`[长图预览](...long.png)\`\`[下载长图](...html?download=long-image)\`
- \`.thumbnail.svg\` 只是信息流封面,不能当成长图
`;
}
@@ -322,11 +334,13 @@ export function buildSandboxSessionConstraints({ baseConstraints, developerTools
'- 你有 write_file/edit_file 工具:**必须由你**写入 `public/xxx.html`(或工作区根目录 `.html`',
'- 开始前执行 load_skill → `static-page-publish`,按技能说明写入 mindspace-cover 元数据',
'- 如果用户要求 Word / docx 下载:先 `load_skill` → `docx-generate`,生成并确认 `public/*.docx` 已存在,再写 HTML 用相对路径链接',
'- 如果用户要求长图下载:先 `load_skill` → `long-image-download`,调用 `generate_long_image` 生成并确认 `public/*.long.png` 已存在,再回复长图预览与下载链接',
'- **禁止**用 shell / cat / heredoc / echo / cp 写入 HTMLshell 在容器内执行,文件不会出现在公网 MindSpace 路径',
'- 用 `apps__create_app` 设计页面时,最后仍要按 `static-page-publish` skill 把内容 write_file 落到 `public/`,才有公网链接',
'- **禁止**让用户手动保存到 public 或说无法生成页面(除非 write_file 调用失败)',
'- 完成后回复 `[页面标题](公网URL)` 可点击链接;写入 `public/` 时 URL 必须含 `/public/` 路径段;按本会话给出的公网前缀模板拼接真实地址',
'- 下载附件:Word 用 `generate_docx` 写入 `public/*.docx`,相对链接文件必须已在 `public/` 落盘,basename 与 HTML 一致',
'- 长图附件:用 `generate_long_image` 写入 `public/*.long.png`,不要把 `.thumbnail.svg` 当成长图',
);
return lines.join('\n');
}
@@ -346,6 +360,7 @@ export function buildPublishConstraints({ slug, username, publicBaseUrl, publish
'- **路径规则**:只用相对路径;禁止 `../`;工作区外的路径会被系统拒绝(OS 层强制,非软约束)',
'- **生成页面(必须亲自完成)**:先 `load_skill` → `static-page-publish`,再用 `write_file`/`edit_file` 写入 `public/页面.html`',
'- **Word 下载页(必须亲自完成)**:若用户要求 Word / docx 下载,必须先 `load_skill` → `docx-generate`,生成并确认 `public/*.docx` 已存在,再在 HTML 中用相对路径链接',
'- **长图下载(必须亲自完成)**:若用户要求长图下载,必须先 `load_skill` → `long-image-download`,调用 `generate_long_image` 生成并确认 `public/*.long.png` 已存在',
'- **用户可见回复**:不要向用户复述 load_skill、技能更新、页脚标记、mindspace-cover 等内部实现;完成后直接给出页面链接或结果',
'- **禁止**用 shell 写入 HTML**禁止**让用户「手动保存到 public 目录」或说「我无法生成页面」——除非 write_file 已失败并报告错误',
'- 完成后给出 Markdown 可点击公网链接 `[标题](URL)`;写入 `public/页面.html` 时 URL 为 `.../MindSpace/<用户ID>/public/页面.html`',
@@ -354,6 +369,7 @@ export function buildPublishConstraints({ slug, username, publicBaseUrl, publish
'- **禁止**让用户「手动保存到 public 目录」或说「我无法生成页面」——除非 write_file 已失败并报告错误',
'- 完成后给出 Markdown 可点击公网链接 `[标题](URL)`;写入 `public/页面.html` 时 URL 为 `.../MindSpace/<用户ID>/public/页面.html`,按模板拼真实地址',
'- 下载链接(如 `report.docx`)必须与 HTML 同目录且文件名一致;Word 必须用 sandbox-fs `generate_docx` 生成,不要用 `computercontroller` / shell 作为交付依据',
'- 长图链接:生成 `report.long.png` 后给 `[长图预览](.../report.long.png)`,下载用 `[下载长图](.../report.html?download=long-image)`',
`- 发布技能:\`${PUBLISH_SKILL_NAME}\`(生成页面前应 load_skill`,
].join('\n');
}
@@ -378,8 +394,18 @@ export function ensureDocxSkillInstalled(publishDir) {
return path.join(skillRoot, 'SKILL.md');
}
export function ensureLongImageSkillInstalled(publishDir) {
if (!fs.existsSync(LONG_IMAGE_SKILL_DIR)) return null;
const skillRoot = path.join(publishDir, '.agents', 'skills', LONG_IMAGE_SKILL_NAME);
fs.rmSync(skillRoot, { recursive: true, force: true });
fs.mkdirSync(path.dirname(skillRoot), { recursive: true });
fs.cpSync(LONG_IMAGE_SKILL_DIR, skillRoot, { recursive: true });
return path.join(skillRoot, 'SKILL.md');
}
export function ensurePublishSkillInstalled(publishDir, context) {
ensureDocxSkillInstalled(publishDir);
ensureLongImageSkillInstalled(publishDir);
const skillRoot = path.join(publishDir, '.agents', 'skills', PUBLISH_SKILL_NAME);
fs.mkdirSync(skillRoot, { recursive: true });
const skillPath = path.join(skillRoot, 'SKILL.md');
+10
View File
@@ -68,19 +68,25 @@ test('publish dir and public url use stable user id', () => {
});
const skillPath = path.join(layout.publishDir, '.agents', 'skills', 'static-page-publish', 'SKILL.md');
const docxSkillPath = path.join(layout.publishDir, '.agents', 'skills', 'docx-generate', 'SKILL.md');
const longImageSkillPath = path.join(layout.publishDir, '.agents', 'skills', 'long-image-download', 'SKILL.md');
const docxScriptPath = path.join(layout.publishDir, '.agents', 'skills', 'docx-generate', 'generate_docx.py');
assert.ok(fs.existsSync(skillPath));
assert.ok(fs.existsSync(docxSkillPath));
assert.ok(fs.existsSync(longImageSkillPath));
assert.ok(fs.existsSync(docxScriptPath));
const skillText = fs.readFileSync(skillPath, 'utf8');
const docxSkillText = fs.readFileSync(docxSkillPath, 'utf8');
const longImageSkillText = fs.readFileSync(longImageSkillPath, 'utf8');
assert.match(skillText, new RegExp(`m\\.tkmind\\.cn/${PUBLISH_ROOT_DIR}/${USER_ID}/${PUBLIC_ZONE_DIR}/`));
assert.match(skillText, /public\/report\.html/);
assert.match(skillText, /\[.*\]\(.*\)/);
assert.match(skillText, /mindspace-cover/);
assert.match(skillText, /\.thumbnail\.svg/);
assert.match(skillText, /docx-generate/);
assert.match(skillText, /long-image-download/);
assert.match(skillText, /generate_long_image/);
assert.match(docxSkillText, /public\/文件名\.docx/);
assert.match(longImageSkillText, /public\/report\.long\.png/);
assert.match(skillText, /report\.docx|伴生下载/);
});
@@ -114,6 +120,8 @@ test('buildPublishConstraints scopes default search to user workspace', () => {
assert.match(constraints, /public\/页面\.html/);
assert.match(constraints, /docx-generate/);
assert.match(constraints, /public\/\*\.docx/);
assert.match(constraints, /long-image-download/);
assert.match(constraints, /generate_long_image/);
assert.match(constraints, /\/public\//);
});
@@ -140,6 +148,8 @@ test('renderWorkspaceHints includes docx download guidance for published pages',
});
assert.match(text, /docx-generate/);
assert.match(text, /public\/\*\.docx/);
assert.match(text, /long-image-download/);
assert.match(text, /generate_long_image/);
});
test('isPathInsidePublishDir blocks escape', () => {