feat(mindspace): 0630004 空间 UI、聊天连接、微信分享与 Agent 能力

含 MindSpace 三列布局与统计修复、聊天加载态与连接降级、平台页脚标记与 og:site_name 微信卡片、勾选资料删除 Agent 接口及内部话术过滤。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-30 09:30:51 +08:00
parent b1b8d3afc6
commit 722b18326f
53 changed files with 2450 additions and 313 deletions
+61 -7
View File
@@ -12,7 +12,8 @@ import {
bufferToImageDataUri,
} from './mindspace-thumbnails.mjs';
import { resolvePublishDir } from './user-publish.mjs';
import { prepareHtmlDownloadLinks } from './mindspace-html-download-links.mjs';
import { prepareHtmlDownloadLinks, inferWorkspaceHtmlRelativePath } from './mindspace-html-download-links.mjs';
import { prepareHtmlPageBrandMarkers } from './mindspace-page-tag.mjs';
import { purgeWorkspacePageArtifacts, extractAssetIdsFromHtml } from './mindspace-page-purge.mjs';
import { ensureWorkspaceHtmlThumbnail, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
import { upsertMindspaceCoverMeta } from './mindspace-cover-meta.mjs';
@@ -43,6 +44,18 @@ 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('/');
}
function normalizeText(value, maxLength, fieldName) {
const text = String(value ?? '').normalize('NFKC').trim();
if (!text) throw pageError(`${fieldName}不能为空`, 'invalid_page_input');
@@ -54,13 +67,16 @@ function normalizeText(value, maxLength, fieldName) {
function normalizePageInput(input) {
const title = normalizeText(input.title, MAX_TITLE_LENGTH, '标题');
const content = normalizeText(input.content, MAX_CONTENT_BYTES, '页面内容');
let content = normalizeText(input.content, MAX_CONTENT_BYTES, '页面内容');
const contentFormat = input.contentFormat === 'html' ? 'html' : 'markdown';
if (contentFormat === 'html') {
content = prepareHtmlPageBrandMarkers(content);
}
const contentBytes = Buffer.byteLength(content, 'utf8');
if (contentBytes > MAX_CONTENT_BYTES) {
throw pageError('页面内容超过 1 MB 限制', 'page_content_too_large');
}
const summary = String(input.summary ?? '').normalize('NFKC').trim().slice(0, MAX_SUMMARY_LENGTH);
const contentFormat = input.contentFormat === 'html' ? 'html' : 'markdown';
const templateId =
contentFormat === 'html'
? 'static-html'
@@ -579,6 +595,25 @@ export function createPageService(pool, options = {}) {
return rows[0] ? pageResponse(rows[0]) : null;
};
const findPageByRelativePath = async (userId, relativePath) => {
const normalized = normalizeWorkspaceRelativePath(relativePath);
if (!normalized) return null;
const [rows] = await pool.query(
`SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url
FROM h5_page_records p
JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id
JOIN h5_page_versions pv ON pv.id = p.current_version_id
LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online'
WHERE p.user_id = ?
AND p.status <> 'deleted'
AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ?
ORDER BY p.updated_at DESC, p.id DESC
LIMIT 1`,
[userId, normalized],
);
return rows[0] ? pageResponse(rows[0]) : null;
};
const listPages = async (userId, filters = {}) => {
const clauses = [`p.user_id = ?`, `p.status <> 'deleted'`];
const params = [userId];
@@ -729,9 +764,9 @@ export function createPageService(pool, options = {}) {
};
};
const loadPageWorkspaceContext = async (userId, pageId) => {
const loadPageWorkspaceContext = async (userId, pageId, htmlContent = null) => {
const [rows] = await pool.query(
`SELECT pv.source_snapshot_json
`SELECT pv.source_snapshot_json, p.title
FROM h5_page_records p
JOIN h5_page_versions pv ON pv.id = p.current_version_id
WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted'
@@ -744,8 +779,13 @@ export function createPageService(pool, options = {}) {
} catch {
snapshot = {};
}
const workspaceHtmlRelativePath = snapshot.relative_path ?? 'public/index.html';
const workspacePublishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null;
const workspaceHtmlRelativePath = inferWorkspaceHtmlRelativePath({
snapshotRelativePath: snapshot.relative_path,
pageTitle: rows[0]?.title,
htmlContent,
publishDir: workspacePublishDir,
});
return {
workspaceHtmlRelativePath,
workspacePublishDir,
@@ -753,10 +793,22 @@ export function createPageService(pool, options = {}) {
};
};
const rewriteHtmlDownloadLinksForPage = async (userId, pageId, html) => {
const ctx = await loadPageWorkspaceContext(userId, pageId, html);
const { html: rewritten } = await prepareHtmlDownloadLinks(pool, userId, html, {
htmlRelativePath: ctx.workspaceHtmlRelativePath,
publishDir: ctx.workspacePublishDir,
publishKey: ctx.publishKey,
publicBaseUrl: '',
preferAssetDownload: true,
});
return rewritten;
};
const finalizeHtmlPreview = async (userId, pageId, html) => {
const shell = renderHtmlPreview(html);
try {
const ctx = await loadPageWorkspaceContext(userId, pageId);
const ctx = await loadPageWorkspaceContext(userId, pageId, html);
const { html: rewritten } = await prepareHtmlDownloadLinks(pool, userId, shell, {
htmlRelativePath: ctx.workspaceHtmlRelativePath,
publishDir: ctx.workspacePublishDir,
@@ -1334,10 +1386,12 @@ export function createPageService(pool, options = {}) {
listPages,
findPageBySourceAsset,
findPageBySourceMessage,
findPageByRelativePath,
getPage,
getDeletePreview,
deletePage,
listVersions,
rewriteHtmlDownloadLinksForPage,
renderPreview: async (userId, pageId) => {
const page = await getPage(userId, pageId);
const html =