fix(mindspace): refresh published HTML after workspace sync

Keep online publication snapshots aligned when public/*.html changes, default static page binds to public access with MindSpace delivery URLs, and split browser-safe workspace helpers out of the Vite import chain.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-10 20:44:04 +08:00
parent 7f8d692d16
commit e3063ea806
12 changed files with 279 additions and 110 deletions
+62
View File
@@ -0,0 +1,62 @@
export function buildSelectedAssetReadInstructions(context) {
const selected = context.selectedAssets ?? [];
if (selected.length !== 1) return [];
const asset = selected[0];
const h5ApiBase = String(context.h5ApiBase ?? '').replace(/\/$/, '');
const sessionId = String(context.agentSessionId ?? '').trim();
if (!h5ApiBase) return [];
const sessionToken = sessionId || '<CURRENT_AGENT_SESSION>';
const sessionHint = sessionId
? ''
: '- session_id 未写入上下文时,使用当前 Agent 会话 ID 替换 `<CURRENT_AGENT_SESSION>`。\n';
return [
'【勾选资料读取(硬性)】',
`- 用户已勾选「${asset.displayName}」(id: ${asset.id}`,
'- **禁止** curl / fetch 浏览器登录接口 `GET /api/mindspace/v1/assets/:id/download`goosed 无 cookie,会 401',
'- **优先**用 `list_dir oa` / `read_file` / `cat oa/文件名` 读取工作区已落盘副本',
sessionHint,
'- 若工作区尚无该文件,**必须**用 shell 调用 Agent 代理下载并落盘到 `oa/`',
'```bash',
`curl -sS -X POST '${h5ApiBase}/api/agent/mindspace_asset_download' \\`,
" -H 'Content-Type: application/json' \\",
` -d '{"session_id":"${sessionToken}","asset_id":"${asset.id}"}'`,
'```',
'- 成功后响应 JSON 含 `relativePath`(如 `oa/report.xlsx`),后续分析/引用只用该相对路径',
'- **禁止**访问其它用户目录、MindSpace 根目录或 `./MindSpace/<username>/` 等猜测路径',
'',
].filter(Boolean);
}
export function buildSelectedAssetDeleteInstructions(context) {
const selected = context.selectedAssets ?? [];
if (selected.length !== 1) return [];
const asset = selected[0];
const h5ApiBase = String(context.h5ApiBase ?? '').replace(/\/$/, '');
const sessionId = String(context.agentSessionId ?? '').trim();
if (!h5ApiBase) return [];
const sessionToken = sessionId || '<CURRENT_AGENT_SESSION>';
const sessionHint = sessionId
? ''
: '- session_id 未写入上下文时,使用当前 Agent 会话 ID 替换 `<CURRENT_AGENT_SESSION>`。\n';
return [
'【勾选资料删除(硬性)】',
`- 你对用户 MindSpace 拥有完整读写删改权限;用户已勾选「${asset.displayName}」(id: ${asset.id})。`,
'- 用户要求删除这份资料时:先说明将删除哪一份并等待用户明确确认(如「确定」「删吧」「确认删除」);**禁止**未确认就删。',
sessionHint,
'- 用户确认后,**必须立即**用 shell 执行(不要只口头说已删,也不要让用户自己去界面点删除):',
'```bash',
`curl -sS -X POST '${h5ApiBase}/api/agent/mindspace_asset_delete' \\`,
" -H 'Content-Type: application/json' \\",
` -d '{"session_id":"${sessionToken}","asset_id":"${asset.id}","confirmed":true}'`,
'```',
'- curl 成功后再回复「已删除」;若返回 asset_in_use,说明被页面引用,告知用户先删关联页面。',
'- 禁止删除未勾选的其它文件。',
'',
].filter(Boolean);
}
+9 -63
View File
@@ -1,6 +1,10 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { resolveAssetWorkspaceRelativePath } from './mindspace-workspace-path.mjs';
import {
buildSelectedAssetDeleteInstructions,
buildSelectedAssetReadInstructions,
} from './mindspace-asset-agent-instructions.mjs';
import { resolveAssetWorkspaceRelativePath } from './mindspace-workspace-relative-path.mjs';
import { ensureUserZoneDirs } from './user-space.mjs';
function sanitizeBasename(value) {
@@ -37,68 +41,10 @@ async function materializeAssetToWorkspace({
};
}
export function buildSelectedAssetReadInstructions(context) {
const selected = context.selectedAssets ?? [];
if (selected.length !== 1) return [];
const asset = selected[0];
const h5ApiBase = String(context.h5ApiBase ?? '').replace(/\/$/, '');
const sessionId = String(context.agentSessionId ?? '').trim();
if (!h5ApiBase) return [];
const sessionToken = sessionId || '<CURRENT_AGENT_SESSION>';
const sessionHint = sessionId
? ''
: '- session_id 未写入上下文时,使用当前 Agent 会话 ID 替换 `<CURRENT_AGENT_SESSION>`。\n';
return [
'【勾选资料读取(硬性)】',
`- 用户已勾选「${asset.displayName}」(id: ${asset.id}`,
'- **禁止** curl / fetch 浏览器登录接口 `GET /api/mindspace/v1/assets/:id/download`goosed 无 cookie,会 401',
'- **优先**用 `list_dir oa` / `read_file` / `cat oa/文件名` 读取工作区已落盘副本',
sessionHint,
'- 若工作区尚无该文件,**必须**用 shell 调用 Agent 代理下载并落盘到 `oa/`',
'```bash',
`curl -sS -X POST '${h5ApiBase}/api/agent/mindspace_asset_download' \\`,
" -H 'Content-Type: application/json' \\",
` -d '{"session_id":"${sessionToken}","asset_id":"${asset.id}"}'`,
'```',
'- 成功后响应 JSON 含 `relativePath`(如 `oa/report.xlsx`),后续分析/引用只用该相对路径',
'- **禁止**访问其它用户目录、MindSpace 根目录或 `./MindSpace/<username>/` 等猜测路径',
'',
].filter(Boolean);
}
export function buildSelectedAssetDeleteInstructions(context) {
const selected = context.selectedAssets ?? [];
if (selected.length !== 1) return [];
const asset = selected[0];
const h5ApiBase = String(context.h5ApiBase ?? '').replace(/\/$/, '');
const sessionId = String(context.agentSessionId ?? '').trim();
if (!h5ApiBase) return [];
const sessionToken = sessionId || '<CURRENT_AGENT_SESSION>';
const sessionHint = sessionId
? ''
: '- session_id 未写入上下文时,使用当前 Agent 会话 ID 替换 `<CURRENT_AGENT_SESSION>`。\n';
return [
'【勾选资料删除(硬性)】',
`- 你对用户 MindSpace 拥有完整读写删改权限;用户已勾选「${asset.displayName}」(id: ${asset.id})。`,
'- 用户要求删除这份资料时:先说明将删除哪一份并等待用户明确确认(如「确定」「删吧」「确认删除」);**禁止**未确认就删。',
sessionHint,
'- 用户确认后,**必须立即**用 shell 执行(不要只口头说已删,也不要让用户自己去界面点删除):',
'```bash',
`curl -sS -X POST '${h5ApiBase}/api/agent/mindspace_asset_delete' \\`,
" -H 'Content-Type: application/json' \\",
` -d '{"session_id":"${sessionToken}","asset_id":"${asset.id}","confirmed":true}'`,
'```',
'- curl 成功后再回复「已删除」;若返回 asset_in_use,说明被页面引用,告知用户先删关联页面。',
'- 禁止删除未勾选的其它文件。',
'',
].filter(Boolean);
}
export {
buildSelectedAssetDeleteInstructions,
buildSelectedAssetReadInstructions,
} from './mindspace-asset-agent-instructions.mjs';
export function createAssetAgentService({
assetService,
+1 -1
View File
@@ -2,7 +2,7 @@ import { buildMindSpacePageSaveInstructions } from './mindspace-page-patch.mjs';
import {
buildSelectedAssetDeleteInstructions,
buildSelectedAssetReadInstructions,
} from './mindspace-asset-agent.mjs';
} from './mindspace-asset-agent-instructions.mjs';
const VIEW_LABELS = {
home: '空间首页',
+2 -11
View File
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { redactContent, scanContent } from './mindspace-content-scan.mjs';
import { normalizeWorkspaceRelativePath } from './mindspace-workspace-relative-path.mjs';
import {
ensurePageThumbnail,
extractCoverSignals,
@@ -44,17 +45,7 @@ function pageError(message, code, details) {
return Object.assign(new Error(message), { code, details });
}
/** Normalize workspace HTML paths so `demo.html` and `public/demo.html` match. */
export function normalizeWorkspaceRelativePath(relativePath) {
const parts = String(relativePath ?? '')
.replace(/^\/+/, '')
.split('/')
.filter((part) => part && part !== '.' && part !== '..');
if (parts.length === 0) return '';
if (parts[0].toLowerCase() === 'public') return parts.join('/');
if (parts.length === 1 && parts[0].toLowerCase().endsWith('.html')) return `public/${parts[0]}`;
return parts.join('/');
}
export { normalizeWorkspaceRelativePath } from './mindspace-workspace-relative-path.mjs';
function normalizeText(value, maxLength, fieldName) {
const text = String(value ?? '').normalize('NFKC').trim();
+40
View File
@@ -138,6 +138,29 @@ function publicationResponse(row) {
};
}
function pageRecordStateFromAccessMode(accessMode) {
const isPublic = accessMode === 'public';
return {
status: isPublic ? 'published' : 'protected',
visibility: isPublic ? 'public' : 'private',
};
}
async function syncPageRecordFromAccessMode(executor, userId, pageId, accessMode, now = Date.now()) {
const { status, visibility } = pageRecordStateFromAccessMode(accessMode);
await executor.query(
`UPDATE h5_page_records
SET status = ?, visibility = ?, updated_at = ?
WHERE id = ? AND user_id = ? AND status <> 'deleted'`,
[status, visibility, now, pageId, userId],
);
}
function buildOnlinePublicationPublicUrl(ownerSlug, urlSlug) {
const publicBaseUrl = resolvePublicBaseUrl();
return `${publicBaseUrl}/u/${encodeURIComponent(ownerSlug)}/pages/${urlSlug}`;
}
function publicHomepageResponse(owner, pages) {
const totalViews = pages.reduce(
(sum, page) => sum + Number(page.viewCount ?? page.view_count ?? 0),
@@ -980,8 +1003,18 @@ export function createPublicationService(pool, options = {}) {
WHERE id = ?`,
[htmlBytes, checksum, publication.asset_version_id],
);
let publicUrl = publication.public_url;
if (publication.access_mode === 'public') {
publicUrl = buildOnlinePublicationPublicUrl(ownerSlug, publication.url_slug);
await pool.query(
`UPDATE h5_publish_records SET public_url = ?, updated_at = ? WHERE id = ? AND user_id = ?`,
[publicUrl, now, publication.id, userId],
);
}
await syncPageRecordFromAccessMode(pool, userId, pageId, publication.access_mode, now);
return publicationResponse({
...publication,
public_url: publicUrl,
updated_at: now,
});
};
@@ -1023,6 +1056,13 @@ export function createPublicationService(pool, options = {}) {
WHERE id = ? AND user_id = ?`,
[normalizedMode, normalizedExpiresAt, passwordHash, now, now, publicationId, userId],
);
await syncPageRecordFromAccessMode(
conn,
userId,
publication.page_id,
normalizedMode,
now,
);
await conn.commit();
const updated = {
+24 -4
View File
@@ -336,7 +336,8 @@ const ALL_TOOLS = [
title: { type: 'string', description: '页面标题,可选,默认从 HTML <title> 读取' },
accessMode: {
type: 'string',
description: '发布访问模式:public、password、login_required,默认 password',
description:
'发布访问模式:public、password、login_required;纯静态页默认 public,含 Page Data datasets 时默认 password',
},
password: {
type: 'string',
@@ -650,7 +651,13 @@ async function callTool(name, args) {
if (!ownerUserId) throw new Error('缺少用户上下文,无法绑定页面');
const pool = getQuotaPool();
if (!pool) throw new Error('当前环境未配置数据库,无法绑定页面');
const accessMode = String(args.accessMode ?? 'password').trim() || 'password';
const hasDatasets =
args.datasets != null &&
typeof args.datasets === 'object' &&
Object.keys(args.datasets).length > 0;
const accessMode =
String(args.accessMode ?? (hasDatasets ? 'password' : 'public')).trim() ||
(hasDatasets ? 'password' : 'public');
const h5Root = resolveH5RootFromSandbox();
const storageRoot = resolveMindSpaceStorageRoot(h5Root);
const result = await bindWorkspaceHtmlForPageData({
@@ -664,7 +671,7 @@ async function callTool(name, args) {
accessMode,
password: args.password,
urlSlug: args.urlSlug,
pageDataPolicy: args.datasets
pageDataPolicy: hasDatasets
? {
ownerUserId,
accessMode,
@@ -672,7 +679,20 @@ async function callTool(name, args) {
}
: null,
});
return [{ type: 'text', text: JSON.stringify(result, null, 2) }];
return [
{
type: 'text',
text: JSON.stringify(
{
...result,
userDeliveryNote:
'向用户交付链接时优先使用 deliveryUrl / workspaceUrlMindSpace 路径),不要用 publicationUrl',
},
null,
2,
),
},
];
}
case 'schedule_create_item': {
const timezone = args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? 'Asia/Shanghai';
+54 -1
View File
@@ -45,6 +45,57 @@ export function createWorkspacePageDeliverService({
});
}
async function listOnlineAutoSyncedWorkspacePages(userId) {
if (!pool || !userId) return [];
const [rows] = await pool.query(
`SELECT p.id AS page_id, p.title, p.current_version_id, p.workspace_relative_path,
pv.source_snapshot_json
FROM h5_page_records p
JOIN h5_page_versions pv ON pv.id = p.current_version_id
JOIN h5_publish_records pr
ON pr.page_id = p.id AND pr.user_id = p.user_id AND pr.status = 'online'
WHERE p.user_id = ?
AND p.status <> 'deleted'`,
[userId],
);
return (rows ?? []).filter((row) => {
const snapshot = parseJsonColumn(row.source_snapshot_json);
const relativePath = row.workspace_relative_path ?? snapshot.relative_path ?? null;
if (!isPublicWorkspaceHtmlPath(relativePath)) return false;
return snapshot.auto_synced === true || snapshot.content_mode === 'static_html';
});
}
async function refreshOnlineWorkspacePublications(userId) {
if (!publicationService?.refreshOnlinePublicationHtml || !userId) {
return { refreshed: 0, skipped: 0, errors: [] };
}
const candidates = await listOnlineAutoSyncedWorkspacePages(userId);
let refreshed = 0;
let skipped = 0;
const errors = [];
for (const candidate of candidates) {
try {
const result = await publicationService.refreshOnlinePublicationHtml(
userId,
candidate.page_id,
);
if (result) refreshed += 1;
else skipped += 1;
} catch (error) {
errors.push({
pageId: candidate.page_id,
message: error instanceof Error ? error.message : String(error),
});
logger?.warn?.(
`[MindSpace] refresh publication failed for page ${candidate.page_id}:`,
error instanceof Error ? error.message : error,
);
}
}
return { refreshed, skipped, errors };
}
async function ensureWorkspaceHtmlPublications(userId) {
if (!publicationService?.publish || !pageService?.getPage || !userId) {
return { published: 0, skipped: 0, errors: [] };
@@ -88,11 +139,13 @@ export function createWorkspacePageDeliverService({
syncResult = await pageSyncService.syncUserGeneratedPages(userId);
}
const publishResult = await ensureWorkspaceHtmlPublications(userId);
return { sync: syncResult, publish: publishResult };
const refreshResult = await refreshOnlineWorkspacePublications(userId);
return { sync: syncResult, publish: publishResult, refresh: refreshResult };
}
return {
syncAndDeliver,
ensureWorkspaceHtmlPublications,
refreshOnlineWorkspacePublications,
};
}
+31
View File
@@ -2,6 +2,37 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { createWorkspacePageDeliverService } from './mindspace-workspace-page-deliver.mjs';
test('refreshOnlineWorkspacePublications refreshes online auto-synced workspace html pages', async () => {
const refreshed = [];
const service = createWorkspacePageDeliverService({
pool: {
async query() {
return [[{
page_id: 'page-1',
title: '隔离验证页',
current_version_id: 'ver-1',
workspace_relative_path: 'public/demo.html',
source_snapshot_json: JSON.stringify({
auto_synced: true,
relative_path: 'public/demo.html',
content_mode: 'static_html',
}),
}]];
},
},
publicationService: {
async refreshOnlinePublicationHtml(userId, pageId) {
refreshed.push({ userId, pageId });
return { id: 'pub-1', publicUrl: 'http://127.0.0.1:8081/u/john/pages/page-demo' };
},
},
});
const result = await service.refreshOnlineWorkspacePublications('user-1');
assert.equal(result.refreshed, 1);
assert.deepEqual(refreshed, [{ userId: 'user-1', pageId: 'page-1' }]);
});
test('ensureWorkspaceHtmlPublications publishes auto-synced workspace html pages', async () => {
const published = [];
const service = createWorkspacePageDeliverService({
+10 -28
View File
@@ -1,9 +1,17 @@
import fs from 'node:fs';
import path from 'node:path';
import { normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
import {
normalizeWorkspaceRelativePath,
resolveAssetWorkspaceRelativePath,
resolvePageWorkspaceRelativePath,
} from './mindspace-workspace-relative-path.mjs';
import { resolvePublishDir } from './user-publish.mjs';
export { normalizeWorkspaceRelativePath };
export {
normalizeWorkspaceRelativePath,
resolveAssetWorkspaceRelativePath,
resolvePageWorkspaceRelativePath,
};
export async function readWorkspacePublishHtml(h5Root, userId, relativePath) {
const normalized = normalizeWorkspaceRelativePath(relativePath);
@@ -15,29 +23,3 @@ export async function readWorkspacePublishHtml(h5Root, userId, relativePath) {
return null;
}
}
export function resolvePageWorkspaceRelativePath(snapshot) {
if (!snapshot || typeof snapshot !== 'object') return null;
return normalizeWorkspaceRelativePath(snapshot.relative_path) || null;
}
export function resolveAssetWorkspaceRelativePath({
categoryCode = null,
originalFilename = null,
explicitPath = null,
} = {}) {
const explicit = normalizeWorkspaceRelativePath(explicitPath);
if (explicit) return explicit;
const filename = String(originalFilename ?? '')
.replace(/\\/g, '/')
.replace(/^\/+/, '');
if (!filename) return null;
if (/^(?:public|oa)\//i.test(filename)) {
return normalizeWorkspaceRelativePath(filename);
}
const category = String(categoryCode ?? '').trim();
if (category === 'public' || category === 'oa') {
return normalizeWorkspaceRelativePath(`${category}/${filename}`);
}
return null;
}
+39
View File
@@ -0,0 +1,39 @@
/** Browser-safe workspace path normalization shared by Portal and Vite client imports. */
/** Normalize workspace HTML paths so `demo.html` and `public/demo.html` match. */
export function normalizeWorkspaceRelativePath(relativePath) {
const parts = String(relativePath ?? '')
.replace(/^\/+/, '')
.split('/')
.filter((part) => part && part !== '.' && part !== '..');
if (parts.length === 0) return '';
if (parts[0].toLowerCase() === 'public') return parts.join('/');
if (parts.length === 1 && parts[0].toLowerCase().endsWith('.html')) return `public/${parts[0]}`;
return parts.join('/');
}
export function resolvePageWorkspaceRelativePath(snapshot) {
if (!snapshot || typeof snapshot !== 'object') return null;
return normalizeWorkspaceRelativePath(snapshot.relative_path) || null;
}
export function resolveAssetWorkspaceRelativePath({
categoryCode = null,
originalFilename = null,
explicitPath = null,
} = {}) {
const explicit = normalizeWorkspaceRelativePath(explicitPath);
if (explicit) return explicit;
const filename = String(originalFilename ?? '')
.replace(/\\/g, '/')
.replace(/^\/+/, '');
if (!filename) return null;
if (/^(?:public|oa)\//i.test(filename)) {
return normalizeWorkspaceRelativePath(filename);
}
const category = String(categoryCode ?? '').trim();
if (category === 'public' || category === 'oa') {
return normalizeWorkspaceRelativePath(`${category}/${filename}`);
}
return null;
}
+6 -2
View File
@@ -143,7 +143,7 @@ export async function bindWorkspaceHtmlForPageData({
workspaceRoot,
relativePath,
title = null,
accessMode = 'password',
accessMode = 'public',
password = null,
pageDataPolicy = null,
urlSlug = null,
@@ -225,6 +225,7 @@ export async function bindWorkspaceHtmlForPageData({
policy;
}
const workspaceUrl = buildWorkspacePublicUrl(userId, normalized);
return {
pageId: page.id,
pageTitle: page.title,
@@ -233,7 +234,10 @@ export async function bindWorkspaceHtmlForPageData({
publicationAccessMode: publication.accessMode,
publicationPasswordApplied: normalizedAccessMode === 'password',
publicationUrl: publication.publicUrl,
workspaceUrl: buildWorkspacePublicUrl(userId, normalized),
workspaceUrl,
deliveryUrl: workspaceUrl,
deliveryHint:
'向用户交付静态页时优先使用 deliveryUrlMindSpace /MindSpace/<用户ID>/public/... 路径),publicationUrl 仅作补充',
policy,
};
}
+1
View File
@@ -25,6 +25,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
8. 只有用户明确要求 **Word/PDF 等二进制下载** 时:文件单独落盘(如 `public/方案.docx`),链接用相对路径;**禁止**在 HTML 内用 `data:...;base64,...` 嵌入 docx(易截断损坏)
9. 只有用户明确要求**长图下载**时:必须先 `load_skill``long-image-download`,调用 `generate_long_image` 生成同目录 `public/<页面名>.long.png`,再返回长图预览与下载链接;禁止把 `.thumbnail.svg` 当成长图
10. **禁止**在 HTML 中用 CDN `https://...` 引用 `<script src>`MindSpace 发布页 CSP 仅允许同源脚本。常用库优先用平台路径(如 Chart.js → `/assets/chart.umd.min.js`),或下载到 `public/assets/` 后相对引用
11. **纯静态页(无 Page Data 问卷/后台)禁止调用 `private_data_bind_workspace_page`**`write_file` / `edit_file` 落盘 `public/*.html` 后,直接按下方「回复格式」交付 **MindSpace 路径**`/MindSpace/<用户ID>/public/...`),无需 bind、无需口令
详细约束以工作区内的 `.goosehints``.agents/skills/static-page-publish/SKILL.md` 为准。