From b4a2b2bdaf7038733045c82b63b2115eef0c8da9 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 1 Jul 2026 12:40:59 +0800 Subject: [PATCH 1/2] fix: gate public page download links before Portal release Add a MindSpace public HTML checker for missing companion docx/PDF paths, wire it into runtime packaging and release-portal-runtime-prod.sh, and document the workflow for agents and ops. Co-authored-by: Cursor --- docs/发包必看.md | 5 + mindspace-public-links.mjs | 151 +++++++++++++++++++++++ mindspace-public-links.test.mjs | 74 +++++++++++ package.json | 4 +- scripts/build-portal-runtime.mjs | 17 +++ scripts/check-mindspace-public-links.mjs | 60 +++++++++ scripts/release-portal-runtime-prod.sh | 39 +++++- skills/static-page-publish/SKILL.md | 9 ++ user-publish.mjs | 11 +- user-publish.test.mjs | 1 + 10 files changed, 368 insertions(+), 3 deletions(-) create mode 100644 mindspace-public-links.mjs create mode 100644 mindspace-public-links.test.mjs create mode 100644 scripts/check-mindspace-public-links.mjs diff --git a/docs/发包必看.md b/docs/发包必看.md index 19d851a..aa8ee66 100644 --- a/docs/发包必看.md +++ b/docs/发包必看.md @@ -36,8 +36,11 @@ rm -f /Users/john/PycharmProjects/test/test-memind-release-goosed-mcp/node_modul ```bash node --test db.test.mjs capabilities.test.mjs llm-providers.test.mjs wechat-mp.test.mjs +npm run check:mindspace-public-links ``` +6. `public/*.html` 里的**下载/附件**相对链接(如 `report.docx`)必须在同目录真实存在。发版脚本默认只查这类链接(`--downloads-only`);全量资源检查用 `npm run check:mindspace-public-links:all`。失败会阻断发版;确知要带着已知坏链上线时,才可临时 `ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES=1`。 + ## 2. Portal runtime 发布唯一流程 只使用: @@ -55,6 +58,7 @@ bash scripts/release-portal-runtime-prod.sh --skip-tests --yes - 原子切换 live 目录。 - 更新 LaunchAgent 指向 `/Users/john/Project/Memind/scripts/run-memind-portal-prod.sh`。 - 健康检查 `http://127.0.0.1:8081/api/status` 返回 200。 +- 校验 `MindSpace/*/public/*.html` 下载/附件相对链接均已落盘(`check-mindspace-public-links.mjs --downloads-only`)。 发布成功后,记录脚本输出里的: @@ -86,6 +90,7 @@ ssh john@58.38.22.103 ' printf "goosed_${p}=" curl -k -s -o /dev/null -w "%{http_code}\n" https://127.0.0.1:${p}/status done + cd /Users/john/Project/Memind && node scripts/check-mindspace-public-links.mjs --root MindSpace --downloads-only pid=$(pgrep -f "node .*server.mjs" | head -1) echo "portal_pid=${pid}" ps eww -p "$pid" | tr " " "\n" | awk -F= "/^(DATABASE_URL|H5_PORT|TKMIND_API_TARGETS|GOOSED_MCP_NODE_PATH|GOOSED_MCP_SERVER_PATH)=/ {print \$1\"=SET\"}" diff --git a/mindspace-public-links.mjs b/mindspace-public-links.mjs new file mode 100644 index 0000000..ce459dc --- /dev/null +++ b/mindspace-public-links.mjs @@ -0,0 +1,151 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const SKIP_REFERENCE = /^(?:https?:|mailto:|javascript:|data:|tel:|#|\/\/)/i; +const DOWNLOAD_EXTENSIONS = /\.(?:docx|doc|pdf|xlsx|xls|pptx|ppt|zip|rar|7z|csv)$/i; + +export function isFilesystemPublicReference(raw) { + const value = String(raw ?? '').trim(); + if (!value) return false; + if (SKIP_REFERENCE.test(value)) return false; + // /api/... and /MindSpace/... are server routes, not on-disk siblings of public HTML. + if (value.startsWith('/')) return false; + return true; +} + +/** @deprecated use isFilesystemPublicReference */ +export function isRelativePublicReference(raw) { + return isFilesystemPublicReference(raw); +} + +export function isDownloadLikeReference(raw, source = '') { + if (!isFilesystemPublicReference(raw)) return false; + const ref = normalizeReferencePath(raw); + if (DOWNLOAD_EXTENSIONS.test(ref)) return true; + return /\bdownload\b/i.test(String(source ?? '')); +} + +export function normalizeReferencePath(raw) { + const trimmed = String(raw ?? '') + .trim() + .split('#')[0] + .split('?')[0] + .trim(); + if (!trimmed) return ''; + try { + return decodeURIComponent(trimmed); + } catch { + return trimmed; + } +} + +export function resolveReferencePath(htmlDir, raw) { + const ref = normalizeReferencePath(raw); + if (!ref) return null; + return path.resolve(htmlDir, ref); +} + +function parseMindspaceCoverPaths(contentAttr) { + const paths = []; + if (!contentAttr) return paths; + let parsed = null; + try { + parsed = JSON.parse(contentAttr); + } catch { + for (const key of ['cover', 'image']) { + const match = contentAttr.match(new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`)); + if (match?.[1]) paths.push(match[1]); + } + return paths; + } + for (const key of ['cover', 'image']) { + if (parsed?.[key]) paths.push(String(parsed[key])); + } + return paths; +} + +export function collectPublicHtmlReferences(html, htmlDir, { downloadsOnly = false } = {}) { + const refs = []; + const seen = new Set(); + + const add = (raw, source) => { + if (downloadsOnly && !isDownloadLikeReference(raw, source)) return; + if (!downloadsOnly && !isFilesystemPublicReference(raw)) return; + const ref = normalizeReferencePath(raw); + if (!ref) return; + const key = `${source}\0${ref}`; + if (seen.has(key)) return; + seen.add(key); + refs.push({ + ref, + source, + resolvedPath: resolveReferencePath(htmlDir, ref), + }); + }; + + for (const match of String(html ?? '').matchAll(/\b(?:href|src)\s*=\s*["']([^"']+)["']/gi)) { + add(match[1], match[0]); + } + + if (!downloadsOnly) { + for (const match of String(html ?? '').matchAll( + /]*\bname\s*=\s*["']mindspace-cover["'][^>]*>/gi, + )) { + const tag = match[0]; + const contentMatch = + tag.match(/\bcontent\s*=\s*"([^"]*)"/i) ?? tag.match(/\bcontent\s*=\s*'([^']*)'/i); + for (const coverPath of parseMindspaceCoverPaths(contentMatch?.[1])) { + add(coverPath, 'mindspace-cover'); + } + } + } + + return refs; +} + +export function findMissingPublicHtmlReferences(htmlPath, html = null, options = {}) { + const resolvedHtmlPath = path.resolve(htmlPath); + const content = html ?? fs.readFileSync(resolvedHtmlPath, 'utf8'); + const htmlDir = path.dirname(resolvedHtmlPath); + const missing = []; + const seen = new Set(); + + for (const item of collectPublicHtmlReferences(content, htmlDir, options)) { + const key = item.ref; + if (seen.has(key)) continue; + seen.add(key); + if (!item.resolvedPath || !fs.existsSync(item.resolvedPath)) { + missing.push({ htmlPath: resolvedHtmlPath, ...item }); + } + } + + return missing; +} + +export function listPublicHtmlFiles(publishRoot, { userId = null } = {}) { + const root = path.resolve(publishRoot); + if (!fs.existsSync(root)) return []; + + const userDirs = userId + ? [path.join(root, userId)] + : fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => path.join(root, d.name)); + + const files = []; + for (const userDir of userDirs) { + const publicDir = path.join(userDir, 'public'); + if (!fs.existsSync(publicDir) || !fs.statSync(publicDir).isDirectory()) continue; + for (const name of fs.readdirSync(publicDir)) { + if (!name.toLowerCase().endsWith('.html')) continue; + files.push(path.join(publicDir, name)); + } + } + return files.sort(); +} + +export function scanPublicHtmlLinks(publishRoot, options = {}) { + const issues = []; + for (const htmlPath of listPublicHtmlFiles(publishRoot, options)) { + issues.push(...findMissingPublicHtmlReferences(htmlPath, null, options)); + } + return issues; +} diff --git a/mindspace-public-links.test.mjs b/mindspace-public-links.test.mjs new file mode 100644 index 0000000..3531d45 --- /dev/null +++ b/mindspace-public-links.test.mjs @@ -0,0 +1,74 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + collectPublicHtmlReferences, + findMissingPublicHtmlReferences, + isDownloadLikeReference, + isFilesystemPublicReference, + scanPublicHtmlLinks, +} from './mindspace-public-links.mjs'; + +test('isFilesystemPublicReference skips external and absolute server paths', () => { + assert.equal(isFilesystemPublicReference('duck.docx'), true); + assert.equal(isFilesystemPublicReference('assets/hero.jpg'), true); + assert.equal(isFilesystemPublicReference('https://example.com/a.docx'), false); + assert.equal(isFilesystemPublicReference('/api/mindspace/v1/assets/a/download'), false); + assert.equal(isFilesystemPublicReference('/MindSpace/u/public/page.html'), false); +}); + +test('isDownloadLikeReference matches download attr and attachment extensions', () => { + assert.equal(isDownloadLikeReference('report.docx', 'href="report.docx" download'), true); + assert.equal(isDownloadLikeReference('assets/chart.png', 'src="assets/chart.png"'), false); + assert.equal(isDownloadLikeReference('data/report.pdf', 'href="data/report.pdf"'), true); +}); + +test('collectPublicHtmlReferences downloads-only ignores api src and cover', () => { + const html = ` + + + + +下载 + +chart +`; + const all = collectPublicHtmlReferences(html, '/tmp/public'); + const downloads = collectPublicHtmlReferences(html, '/tmp/public', { downloadsOnly: true }); + assert.deepEqual( + all.map((item) => item.ref).sort(), + ['assets/chart.png', 'assets/hero.jpg', 'report.docx'], + ); + assert.deepEqual(downloads.map((item) => item.ref), ['report.docx']); +}); + +test('findMissingPublicHtmlReferences reports missing sibling files', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ms-links-')); + const htmlPath = path.join(dir, 'report.html'); + fs.writeFileSync(htmlPath, '下载'); + const missing = findMissingPublicHtmlReferences(htmlPath, null, { downloadsOnly: true }); + assert.equal(missing.length, 1); + assert.equal(missing[0].ref, 'report.docx'); + + fs.writeFileSync(path.join(dir, 'report.docx'), 'doc'); + assert.deepEqual(findMissingPublicHtmlReferences(htmlPath, null, { downloadsOnly: true }), []); +}); + +test('normalizeReferencePath decodes percent-encoded filenames', () => { + const html = 'x'; + const refs = collectPublicHtmlReferences(html, '/tmp/public', { downloadsOnly: true }); + assert.equal(refs[0]?.ref, '养猪.docx'); +}); + +test('scanPublicHtmlLinks walks MindSpace user public html files', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ms-root-')); + const publicDir = path.join(root, 'user-a', 'public'); + fs.mkdirSync(publicDir, { recursive: true }); + fs.writeFileSync(path.join(publicDir, 'page.html'), 'pdf'); + const issues = scanPublicHtmlLinks(root, { downloadsOnly: true }); + assert.equal(issues.length, 1); + assert.match(issues[0].htmlPath, /page\.html$/); + assert.equal(issues[0].ref, 'missing.pdf'); +}); diff --git a/package.json b/package.json index 87e15a2..4323e57 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,9 @@ "enrich:plaza": "node scripts/enrich-plaza-posts.mjs", "build": "vite build", "build:portal-runtime": "node scripts/build-portal-runtime.mjs", - "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs", + "check:mindspace-public-links": "node scripts/check-mindspace-public-links.mjs --downloads-only", + "check:mindspace-public-links:all": "node scripts/check-mindspace-public-links.mjs --all-links", + "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs", "test:mindspace-e2e": "node scripts/mindspace-e2e.mjs", "test:mindspace-pages-e2e": "node scripts/mindspace-pages-e2e.mjs", "test:mindspace-publications-e2e": "node scripts/mindspace-publications-e2e.mjs", diff --git a/scripts/build-portal-runtime.mjs b/scripts/build-portal-runtime.mjs index 15ad61d..2b78a20 100755 --- a/scripts/build-portal-runtime.mjs +++ b/scripts/build-portal-runtime.mjs @@ -173,6 +173,19 @@ async function rewriteNodeModulesSymlinks(runtimeNodeModulesDir, sourceNodeModul await visit(runtimeNodeModulesDir); } +async function copyMindspacePublicLinkTools() { + console.log('==> 拷贝 MindSpace 公开页链接检查工具'); + await fs.copyFile( + path.join(root, 'mindspace-public-links.mjs'), + path.join(runtimeRoot, 'mindspace-public-links.mjs'), + ); + await fs.mkdir(path.join(runtimeRoot, 'scripts'), { recursive: true }); + await fs.copyFile( + path.join(root, 'scripts', 'check-mindspace-public-links.mjs'), + path.join(runtimeRoot, 'scripts', 'check-mindspace-public-links.mjs'), + ); +} + async function writeMetadata() { const packageJson = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8')); const runtimePackageJson = { @@ -233,6 +246,9 @@ async function writeMetadata() { 'Bundled alongside server.mjs (required for sandbox-fs MCP):', ' mindspace-sandbox-mcp.mjs (esbuild bundle; includes schedule-service deps)', '', + 'Post-deploy validation (scripts/check-mindspace-public-links.mjs):', + ' Scans MindSpace/*/public/*.html for missing relative href/src/cover targets.', + '', 'Key runtime differences must stay in .env, not in the artifact:', ' DATABASE_URL / MYSQL_*', ' H5_PUBLIC_BASE_URL', @@ -254,6 +270,7 @@ async function main() { await bundleServer(); await bundleSandboxMcp(); await copyNodeModules(); + await copyMindspacePublicLinkTools(); await writeMetadata(); await fs.chmod(path.join(runtimeRoot, 'scripts', 'run-memind-portal-prod.sh'), 0o755); console.log(''); diff --git a/scripts/check-mindspace-public-links.mjs b/scripts/check-mindspace-public-links.mjs new file mode 100644 index 0000000..2f0155b --- /dev/null +++ b/scripts/check-mindspace-public-links.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +/** + * Scan MindSpace public/*.html for broken relative links. + * + * Default (--downloads-only): attachment / download links only (release gate). + * --all-links: also check img/src and mindspace-cover assets. + * + * Usage: + * node scripts/check-mindspace-public-links.mjs + * node scripts/check-mindspace-public-links.mjs --user + * node scripts/check-mindspace-public-links.mjs --root /path/to/MindSpace + * node scripts/check-mindspace-public-links.mjs --all-links + */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { scanPublicHtmlLinks } from '../mindspace-public-links.mjs'; + +const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const DEFAULT_PUBLISH_ROOT = 'MindSpace'; + +function parseArgs(argv) { + let root = path.join(repoRoot, DEFAULT_PUBLISH_ROOT); + let userId = null; + let allLinks = false; + for (let i = 2; i < argv.length; i += 1) { + if (argv[i] === '--root' && argv[i + 1]) { + root = path.resolve(argv[i + 1]); + i += 1; + } else if (argv[i] === '--user' && argv[i + 1]) { + userId = argv[i + 1]; + i += 1; + } else if (argv[i] === '--all-links') { + allLinks = true; + } else if (argv[i] === '--downloads-only') { + allLinks = false; + } else if (argv[i] === '--help' || argv[i] === '-h') { + console.log( + `Usage: node scripts/check-mindspace-public-links.mjs [--root ${DEFAULT_PUBLISH_ROOT}] [--user ] [--downloads-only|--all-links]`, + ); + process.exit(0); + } + } + return { root, userId, downloadsOnly: !allLinks }; +} + +const { root, userId, downloadsOnly } = parseArgs(process.argv); +const issues = scanPublicHtmlLinks(root, { userId, downloadsOnly }); +const modeLabel = downloadsOnly ? 'download/attachment links' : 'all relative links'; + +if (issues.length === 0) { + console.log(`OK: no missing ${modeLabel} under ${root}${userId ? ` (user ${userId})` : ''}`); + process.exit(0); +} + +console.error(`Found ${issues.length} missing ${modeLabel} under ${root}:`); +for (const issue of issues) { + const relHtml = path.relative(root, issue.htmlPath); + console.error(`- ${relHtml}: ${issue.ref} (${issue.source})`); +} +process.exit(1); diff --git a/scripts/release-portal-runtime-prod.sh b/scripts/release-portal-runtime-prod.sh index 647ee4e..8741a47 100755 --- a/scripts/release-portal-runtime-prod.sh +++ b/scripts/release-portal-runtime-prod.sh @@ -139,7 +139,7 @@ fi verify_runtime_artifact() { local missing=0 - for required in server.mjs mindspace-sandbox-mcp.mjs dist package.json scripts/run-memind-portal-prod.sh; do + for required in server.mjs mindspace-sandbox-mcp.mjs mindspace-public-links.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/check-mindspace-public-links.mjs; do if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2 missing=1 @@ -153,6 +153,29 @@ verify_runtime_artifact() { verify_runtime_artifact +verify_mindspace_public_links() { + local target_root="${1:-${ROOT}/MindSpace}" + local label="${2:-本地 MindSpace}" + if [[ "${ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES:-0}" == "1" ]]; then + say "跳过 ${label} 公开页链接检查(ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES=1)" + return 0 + fi + if [[ ! -d "${target_root}" ]]; then + say "跳过 ${label} 公开页链接检查(目录不存在: ${target_root})" + return 0 + fi + say "检查 ${label} 公开页相对链接" + if ! node "${ROOT}/scripts/check-mindspace-public-links.mjs" --root "${target_root}" --downloads-only; then + echo "MindSpace 公开页存在缺失的相对下载/资源链接。" >&2 + echo "修复 public/*.html 中的 href/src/cover 路径,或临时设置 ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES=1 跳过。" >&2 + exit 1 + fi +} + +if [[ "${DRY_RUN}" -ne 1 ]]; then + verify_mindspace_public_links "${ROOT}/MindSpace" "本地" +fi + verify_remote_goosed_dependency() { say "检查 103 goosed 依赖" ssh -o BatchMode=yes "${HOST}" 'bash -s' <<'REMOTE' @@ -435,10 +458,24 @@ if [[ "${portal_code}" != "200" ]]; then exit 1 fi +if [[ "${ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES:-0}" != "1" && -d "${APP_DIR}/MindSpace" && -f "${APP_DIR}/scripts/check-mindspace-public-links.mjs" ]]; then + say "检查 MindSpace 公开页相对链接" + node_bin="/opt/homebrew/opt/node@24/bin/node" + if [[ ! -x "${node_bin}" ]]; then + node_bin="$(command -v node)" + fi + if ! "${node_bin}" "${APP_DIR}/scripts/check-mindspace-public-links.mjs" --root "${APP_DIR}/MindSpace" --downloads-only; then + echo "MindSpace public link check failed: broken relative download/asset links under public/*.html" >&2 + echo "Set ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES=1 only if you accept shipping with known broken links." >&2 + exit 1 + fi +fi + say "检查 live 目录中不再保留源码树" allowed_live_mjs=( "${APP_DIR}/server.mjs" "${APP_DIR}/mindspace-sandbox-mcp.mjs" + "${APP_DIR}/mindspace-public-links.mjs" ) extra_files="" while IFS= read -r file; do diff --git a/skills/static-page-publish/SKILL.md b/skills/static-page-publish/SKILL.md index 65ac9ff..834ee56 100644 --- a/skills/static-page-publish/SKILL.md +++ b/skills/static-page-publish/SKILL.md @@ -19,6 +19,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 3. 读 CSV/列目录:用工作区内的 `shell`(`ls oa/`、`cat file.csv`)或 `tree`;**禁止**用公网 URL 代替 4. 公网链接**仅**用于让用户浏览器打开已发布的 HTML,不能用来列目录或读数据文件 5. 静态文件保存即可访问,**无需重启** +6. 页面若提供 **Word/PDF/附件下载**,相对链接指向的文件必须与 HTML **同目录(或子目录)且真实存在**;改 HTML 文件名时同步 **rename/copy** 伴生文件 详细约束以工作区内的 `.goosehints` 与 `.agents/skills/static-page-publish/SKILL.md` 为准。 @@ -29,6 +30,14 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 3. 在 `` 写入 **mindspace-cover**(必须与页面主题一致,见下文) 4. 保存后服务端**立即**生成 `<文件名>.thumbnail.svg`(Agent 交互阶段即生效) 5. 按「回复格式」返回**可点击**公网链接 +6. 若页面含下载按钮,确认 `public/<同名>.docx`(或链接中的相对路径)已落盘 + +## 伴生下载文件(必须) + +- `` 等相对下载链接,目标文件必须已在 HTML 同目录或子目录 +- 推荐 `public/report.html` + `public/report.docx`;**禁止** HTML 链接名与磁盘文件名不一致 +- 从 `oa/` 引用文档时,先 **复制** 到 `public/` 再写链接 +- 交付前 `list_dir public/` 自检;可跑 `npm run check:mindspace-public-links` ## 回复格式(必须) diff --git a/user-publish.mjs b/user-publish.mjs index 274840b..14563b0 100644 --- a/user-publish.mjs +++ b/user-publish.mjs @@ -142,7 +142,14 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 3. 在 \`\` 写入 **mindspace-cover** 元数据(见下文,必须与页面主题一致) 4. 页面内资源使用**相对路径**(\`assets/foo.png\`),不要用磁盘绝对路径 5. 保存 HTML 后,服务端会**立即**生成同名预览图 \`<文件名>.thumbnail.svg\`(Agent 交互阶段即生效,无需等用户保存到「我的空间」) -6. 完成后按「回复格式」返回可点击链接 +6. 下载按钮的相对链接目标(如 \`public/report.docx\`)必须已落盘且 basename 与 HTML 一致 +7. 完成后按「回复格式」返回可点击链接 + +## 伴生下载文件(必须) + +- 相对路径下载链接(\`.docx\` / \`.pdf\` 等)必须在 HTML 同目录或子目录真实存在 +- 改 HTML 文件名时同步 rename/copy 伴生文件;从 \`oa/\` 复制到 \`public/\` 再链接 +- 可跑 \`node scripts/check-mindspace-public-links.mjs\` 自检 ## 回复格式(必须) @@ -279,6 +286,7 @@ export function buildSandboxSessionConstraints({ baseConstraints, developerTools '- 开始前执行 load_skill → `static-page-publish`,按技能说明写入 mindspace-cover 元数据', '- **禁止**让用户手动保存到 public 或说无法生成页面(除非 write_file 调用失败)', '- 完成后回复 `[页面标题](公网URL)` 可点击链接;写入 `public/` 时 URL 必须含 `/public/` 路径段', + '- 下载附件:相对链接文件必须已在 `public/` 落盘,basename 与 HTML 一致', ); return lines.join('\n'); } @@ -299,6 +307,7 @@ export function buildPublishConstraints({ slug, username, publicBaseUrl, publish '- **生成页面(必须亲自完成)**:先 `load_skill` → `static-page-publish`,再用 `write_file`/`edit_file` 写入 `public/页面.html`', '- **禁止**让用户「手动保存到 public 目录」或说「我无法生成页面」——除非 write_file 已失败并报告错误', '- 完成后给出 Markdown 可点击公网链接 `[标题](URL)`;写入 `public/页面.html` 时 URL 为 `.../MindSpace/<用户ID>/public/页面.html`', + '- 下载链接(如 `report.docx`)必须与 HTML 同目录且文件名一致', `- 发布技能:\`${PUBLISH_SKILL_NAME}\`(生成页面前应 load_skill)`, ].join('\n'); } diff --git a/user-publish.test.mjs b/user-publish.test.mjs index 8404a17..8cc2b62 100644 --- a/user-publish.test.mjs +++ b/user-publish.test.mjs @@ -62,6 +62,7 @@ test('publish dir and public url use stable user id', () => { assert.match(skillText, /\[.*\]\(.*\)/); assert.match(skillText, /mindspace-cover/); assert.match(skillText, /\.thumbnail\.svg/); + assert.match(skillText, /report\.docx|伴生下载/); }); test('migrateUserPublishDir merges legacy username directory', () => { From 945b0a609b48c331e27cc98cbe139226702eff8a Mon Sep 17 00:00:00 2001 From: john Date: Wed, 1 Jul 2026 14:11:09 +0800 Subject: [PATCH 2/2] fix: guide agent to a real public link instead of dead-ending on apps__create_app Diagnosed via production session logs (RDS + shared goose PG) that some users' page-generation attempts drifted: the agent used apps__create_app (no public URL) then fabricated a placeholder domain when asked for a link, and separately burned many turns scraping unreachable/anti-bot search engines. Steer both flows toward the already-working path instead of banning tools outright: - apps__create_app is fine for designing/previewing a page, but the agent must still write_file the result into public/*.html per the static-page-publish skill and return a link built from the real public URL template (no invented domains) - for real-world lookups, load_skill('web') first and prefer Bing/360 over retrying google.com or hammering anti-bot sites Also folds in generate_docx guidance for Word-download attachments (sandbox-fs tool) that landed in the same files during this pass. Co-Authored-By: Claude --- skills/static-page-publish/SKILL.md | 5 ++++- skills/web/SKILL.md | 6 ++++++ user-publish.mjs | 16 ++++++++++------ user-space.mjs | 5 +++++ 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/skills/static-page-publish/SKILL.md b/skills/static-page-publish/SKILL.md index 834ee56..37c6fa6 100644 --- a/skills/static-page-publish/SKILL.md +++ b/skills/static-page-publish/SKILL.md @@ -14,6 +14,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 ## 规则摘要 +0. 可以用 `apps__create_app` 设计/预览页面,但那一步只是在 Apps 窗口内生成交互式 App,**还没有公网链接**;只要用户要「可访问的链接」「分享出去」,最后必须把内容 `write_file` 落到 `public/*.html`,按下方「回复格式」给出真实链接,不要停在 App 阶段就回复链接 1. 只在**当前用户工作区**(会话 `working_dir`)内读写与搜索,从 `.` 开始 2. 查找 CSV/文档时只用相对路径(如 `oa/report.csv`),**禁止**去上级目录、MindSpace 根目录、其它用户目录或主机路径搜索 3. 读 CSV/列目录:用工作区内的 `shell`(`ls oa/`、`cat file.csv`)或 `tree`;**禁止**用公网 URL 代替 @@ -30,12 +31,13 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 3. 在 `` 写入 **mindspace-cover**(必须与页面主题一致,见下文) 4. 保存后服务端**立即**生成 `<文件名>.thumbnail.svg`(Agent 交互阶段即生效) 5. 按「回复格式」返回**可点击**公网链接 -6. 若页面含下载按钮,确认 `public/<同名>.docx`(或链接中的相对路径)已落盘 +6. 若页面含下载按钮,必须用 `generate_docx`(sandbox-fs 工具)生成 `public/<同名>.docx`,再确认链接目标已落盘 ## 伴生下载文件(必须) - `` 等相对下载链接,目标文件必须已在 HTML 同目录或子目录 - 推荐 `public/report.html` + `public/report.docx`;**禁止** HTML 链接名与磁盘文件名不一致 +- 生成 Word 时必须调用 sandbox-fs 的 `generate_docx`;**禁止**用 `computercontroller` / shell 生成生产下载文件作为交付依据 - 从 `oa/` 引用文档时,先 **复制** 到 `public/` 再写链接 - 交付前 `list_dir public/` 自检;可跑 `npm run check:mindspace-public-links` @@ -51,6 +53,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 - **必须**使用 `[页面标题](完整URL)`,不要只给裸 URL 或「点这里」 - 页面写入 `public/` 时,URL **必须**包含 `/public/` 路径段(与磁盘路径一致) +- 域名严格按本节模板拼接(`https://goo.tkmind.cn/MindSpace/<用户ID>/public/...`);拿不到真实前缀时,先给相对路径 `public/xxx.html` 说明,不要自己猜一个域名 - 标题用页面真实主题名 - 可同时给出相对路径(如 `public/malaysia-travel-guide.html`) - 说明:保存即生效,无需重启 diff --git a/skills/web/SKILL.md b/skills/web/SKILL.md index 3fc86c6..057c3c6 100644 --- a/skills/web/SKILL.md +++ b/skills/web/SKILL.md @@ -26,3 +26,9 @@ description: 网页抓取与搜索技能:访问网页、查阅文档、搜索 2. `extract_text: true`(默认)获取可读文本,`false` 获取原始 HTML 3. 不要访问不明来源的链接,向用户确认后再访问 4. 官方文档优先于第三方博客 + +## 国内网络环境(建议) + +- 生产环境访问不了 `google.com`,优先用本技能的 `web_search`(DuckDuckGo)/`fetch_url`,避免直接拿 `computercontroller__web_scrape` 抓 Google 页面来回重试 +- `web_search` 连续几次没有可用结果时,改用 `fetch_url` 直接访问 `https://cn.bing.com/search?q=...` 或 `https://www.so.com/s?q=...` 这类国内可达的搜索入口 +- 百度/知乎/大众点评等站点有反爬拦截,遇到跳转或空结果就换个搜索源,不必在同一个来源上反复硬抓 diff --git a/user-publish.mjs b/user-publish.mjs index 14563b0..6b19c31 100644 --- a/user-publish.mjs +++ b/user-publish.mjs @@ -128,7 +128,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 1. **唯一可写目录**:\`${publishDir}\` 2. **禁止**使用绝对路径(如 \`/Users/...\`、\`../\` 跳出目录) -3. **允许**在本目录内使用 \`write_file\`、\`edit_file\`、\`read_file\`、\`list_dir\`;**禁止**访问此目录外的路径(含 \`../\`、其它用户目录、项目根目录;系统会在 OS 层拦截越界访问) +3. **允许**在本目录内使用 \`write_file\`、\`edit_file\`、\`read_file\`、\`list_dir\`、\`generate_docx\`;**禁止**访问此目录外的路径(含 \`../\`、其它用户目录、项目根目录;系统会在 OS 层拦截越界访问) 4. **禁止**子 Agent、扩展管理、修改工作区外文件 5. 页面默认写入 \`public/\` 分区,使用相对路径如 \`public/report.html\`、\`public/assets/chart.png\` 6. 生成 HTML 后,向用户提供可访问链接,格式: @@ -142,12 +142,13 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 3. 在 \`\` 写入 **mindspace-cover** 元数据(见下文,必须与页面主题一致) 4. 页面内资源使用**相对路径**(\`assets/foo.png\`),不要用磁盘绝对路径 5. 保存 HTML 后,服务端会**立即**生成同名预览图 \`<文件名>.thumbnail.svg\`(Agent 交互阶段即生效,无需等用户保存到「我的空间」) -6. 下载按钮的相对链接目标(如 \`public/report.docx\`)必须已落盘且 basename 与 HTML 一致 +6. 下载按钮的相对链接目标(如 \`public/report.docx\`)必须通过 \`generate_docx\` 落盘,且 basename 与 HTML 一致 7. 完成后按「回复格式」返回可点击链接 ## 伴生下载文件(必须) - 相对路径下载链接(\`.docx\` / \`.pdf\` 等)必须在 HTML 同目录或子目录真实存在 +- Word 文档必须用 sandbox-fs 的 \`generate_docx\` 生成;禁止用 \`computercontroller\` / shell 生成生产下载文件作为交付依据 - 改 HTML 文件名时同步 rename/copy 伴生文件;从 \`oa/\` 复制到 \`public/\` 再链接 - 可跑 \`node scripts/check-mindspace-public-links.mjs\` 自检 @@ -284,9 +285,10 @@ export function buildSandboxSessionConstraints({ baseConstraints, developerTools '## 生成 / 发布 HTML 页面', '- 你有 write_file/edit_file 工具:**必须由你**写入 `public/xxx.html`(或工作区根目录 `.html`)', '- 开始前执行 load_skill → `static-page-publish`,按技能说明写入 mindspace-cover 元数据', + '- 用 `apps__create_app` 设计页面时,最后仍要按 `static-page-publish` skill 把内容 write_file 落到 `public/`,才有公网链接', '- **禁止**让用户手动保存到 public 或说无法生成页面(除非 write_file 调用失败)', - '- 完成后回复 `[页面标题](公网URL)` 可点击链接;写入 `public/` 时 URL 必须含 `/public/` 路径段', - '- 下载附件:相对链接文件必须已在 `public/` 落盘,basename 与 HTML 一致', + '- 完成后回复 `[页面标题](公网URL)` 可点击链接;写入 `public/` 时 URL 必须含 `/public/` 路径段;按本会话给出的公网前缀模板拼接真实地址', + '- 下载附件:Word 用 `generate_docx` 写入 `public/*.docx`,相对链接文件必须已在 `public/` 落盘,basename 与 HTML 一致', ); return lines.join('\n'); } @@ -305,9 +307,11 @@ export function buildPublishConstraints({ slug, username, publicBaseUrl, publish '- **禁止**:访问 `assets/` 内部路径、其它用户目录、主机绝对路径;禁止用公网 URL 列目录或读 CSV', '- **路径规则**:只用相对路径;禁止 `../`;工作区外的路径会被系统拒绝(OS 层强制,非软约束)', '- **生成页面(必须亲自完成)**:先 `load_skill` → `static-page-publish`,再用 `write_file`/`edit_file` 写入 `public/页面.html`', + '- 若先用 `apps__create_app` 设计/预览,最后仍要把内容 write_file 落到 `public/页面.html` 才有公网链接', '- **禁止**让用户「手动保存到 public 目录」或说「我无法生成页面」——除非 write_file 已失败并报告错误', - '- 完成后给出 Markdown 可点击公网链接 `[标题](URL)`;写入 `public/页面.html` 时 URL 为 `.../MindSpace/<用户ID>/public/页面.html`', - '- 下载链接(如 `report.docx`)必须与 HTML 同目录且文件名一致', + '- 完成后给出 Markdown 可点击公网链接 `[标题](URL)`;写入 `public/页面.html` 时 URL 为 `.../MindSpace/<用户ID>/public/页面.html`,按模板拼真实地址', + '- **需要查实时/真实世界信息时**:先 `load_skill` → `web`,优先 `web_search`/`fetch_url`;国内 google.com 不可达,多次无果时改走 Bing/360 等可达搜索源', + '- 下载链接(如 `report.docx`)必须与 HTML 同目录且文件名一致;Word 必须用 sandbox-fs `generate_docx` 生成,不要用 `computercontroller` / shell 作为交付依据', `- 发布技能:\`${PUBLISH_SKILL_NAME}\`(生成页面前应 load_skill)`, ].join('\n'); } diff --git a/user-space.mjs b/user-space.mjs index 9ee271a..c3428f2 100644 --- a/user-space.mjs +++ b/user-space.mjs @@ -100,8 +100,11 @@ ${zoneLines.join('\n')} - 用户要网页 / HTML / 报告 / 分享链接时:**你必须亲自完成**,不要推给用户手动操作 - 先 \`load_skill\` → \`static-page-publish\`,再使用 \`write_file\` 创建 \`public/页面名.html\`(需要调整已有页面时用 \`edit_file\`) +- 页面需要 Word / docx 下载时,必须用 sandbox-fs 的 \`generate_docx\` 写入 \`public/*.docx\`,不要用 shell/computercontroller 生成生产下载文件 +- 若用 \`apps__create_app\` 设计/预览页面:这一步只是在 Apps 窗口内创建交互式 App,**还没有公网链接**;必须紧接着按 \`static-page-publish\` skill 把最终内容 \`write_file\` 落到 \`public/*.html\`,才能给出真实可访问的链接 - 写入 \`\` 的 **mindspace-cover** 元数据(详见 \`.agents/skills/static-page-publish/SKILL.md\`) - 保存后立即给出 Markdown 可点击公网链接 \`[标题](URL)\`;写入 \`public/页面.html\` 时 URL 为 \`.../MindSpace/<用户ID>/public/页面.html\` +- 给链接时按 \`static-page-publish\` skill 里的公网前缀模板拼接真实地址,不确定域名时用相对路径 \`public/xxx.html\` 说明,不要猜一个域名充数 - **禁止**回复「请手动保存到 public 目录」「我无法生成页面」——除非 \`write_file\` / \`edit_file\` 调用已失败并说明具体错误 ## 工作区文件与 OA 界面 @@ -126,6 +129,8 @@ export function buildUserSpaceConstraints({ username, workspaceRoot, publicBaseU '- **禁止**:访问其它用户目录、MindSpace 根目录、data/mindspace 内部路径、主机绝对路径', '- **禁止**用公网 URL 列目录或读 CSV;生成 HTML 写入 `public/` 并给出公网链接', '- **生成页面**:先 `load_skill` → `static-page-publish`,再用 `write_file` / `edit_file` 写入 `public/*.html`;**禁止**让用户手动保存文件', + '- 用 `apps__create_app` 设计页面时,最后一步仍要按 `static-page-publish` skill 把内容 `write_file` 落到 `public/*.html`,再给出下方前缀拼出的真实链接,不要停在 App 阶段就回复链接', + '- **需要查实时/真实世界信息(如机构名单、新闻、行情)时**:先 `load_skill` → `web`,优先用其 `web_search`/`fetch_url`;国内环境下 google.com 不可达,`web_search` 多次无果时改走 Bing/360 等可达搜索源,避免对反爬站点反复硬抓', publicBaseUrl && slug ? `- 公网 HTML 前缀(公开区):\`${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${slug}/public/\`(写入 \`public/页面.html\` 时分享链接必须含 \`public/\`)` : null,