6d99d762da
Introduce AI image generation with chat shortcut and agent API, improve MindSpace chat-to-page save resolution, and seed Plaza covers with production deploy scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
531 lines
17 KiB
JavaScript
531 lines
17 KiB
JavaScript
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import {
|
|
migrateUserPublishDir,
|
|
PUBLISH_ROOT_DIR,
|
|
PUBLISH_KEY_UUID,
|
|
resolveLegacyPublishDir,
|
|
} from './user-publish.mjs';
|
|
import { workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
|
|
|
|
const URL_PATTERN =
|
|
/https?:\/\/[^\s<>"')\]]+\/(?:MindSpace|temp)\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html)/gi;
|
|
|
|
function decodePathSegment(segment) {
|
|
try {
|
|
return decodeURIComponent(segment);
|
|
} catch {
|
|
return segment;
|
|
}
|
|
}
|
|
|
|
export function extractStaticPageLinks(content, { userId, username } = {}) {
|
|
const text = String(content ?? '');
|
|
const links = [];
|
|
const seen = new Set();
|
|
const normalizedUserId = userId ? String(userId).trim().toLowerCase() : null;
|
|
const normalizedUsername = username ? String(username).trim().toLowerCase() : null;
|
|
for (const match of text.matchAll(URL_PATTERN)) {
|
|
const owner = decodePathSegment(match[1]).toLowerCase();
|
|
const relativePath = decodePathSegment(match[2]);
|
|
const ownedByUser =
|
|
(normalizedUserId && owner === normalizedUserId) ||
|
|
(normalizedUsername && owner === normalizedUsername);
|
|
if ((normalizedUserId || normalizedUsername) && !ownedByUser) continue;
|
|
const key = `${owner}/${relativePath}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
links.push({
|
|
publicUrl: match[0],
|
|
owner,
|
|
relativePath,
|
|
filename: path.basename(relativePath),
|
|
});
|
|
}
|
|
return links;
|
|
}
|
|
|
|
export function buildWorkspaceAssetUrl(userId, relativePath) {
|
|
const key = String(userId ?? '').trim();
|
|
const clean = String(relativePath ?? '')
|
|
.replace(/^\/+/, '')
|
|
.split('/')
|
|
.filter((part) => part && part !== '.' && part !== '..')
|
|
.map((part) => encodeURIComponent(part))
|
|
.join('/');
|
|
if (!key || !clean) return null;
|
|
return `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(key)}/${clean}`;
|
|
}
|
|
|
|
export function buildWorkspaceThumbnailUrl(userId, htmlRelativePath) {
|
|
const thumbRel = workspaceThumbnailRelativePath(htmlRelativePath);
|
|
return buildWorkspaceAssetUrl(userId, thumbRel);
|
|
}
|
|
|
|
export function resolvePublishHtmlAbsolutePath(h5Root, userId, relativePath) {
|
|
const key = String(userId ?? '').trim().toLowerCase();
|
|
if (!PUBLISH_KEY_UUID.test(key)) {
|
|
throw Object.assign(new Error('无效的用户 ID'), { code: 'invalid_page_path' });
|
|
}
|
|
const clean = String(relativePath ?? '')
|
|
.replace(/^\/+/, '')
|
|
.split('/')
|
|
.filter((part) => part && part !== '.' && part !== '..')
|
|
.join('/');
|
|
if (!key || !clean || !clean.toLowerCase().endsWith('.html')) {
|
|
throw Object.assign(new Error('无效的页面路径'), { code: 'invalid_page_path' });
|
|
}
|
|
const publishRoot = path.resolve(h5Root, PUBLISH_ROOT_DIR, key);
|
|
const absolute = path.resolve(publishRoot, clean);
|
|
if (absolute !== publishRoot && !absolute.startsWith(`${publishRoot}${path.sep}`)) {
|
|
throw Object.assign(new Error('页面路径越界'), { code: 'invalid_page_path' });
|
|
}
|
|
return absolute;
|
|
}
|
|
|
|
export async function readPublishHtml(h5Root, userId, relativePath) {
|
|
const absolute = resolvePublishHtmlAbsolutePath(h5Root, userId, relativePath);
|
|
const content = await fs.readFile(absolute, 'utf8');
|
|
if (!content.trim()) {
|
|
throw Object.assign(new Error('页面内容为空'), { code: 'empty_page_content' });
|
|
}
|
|
return { absolute, content, relativePath, filename: path.basename(relativePath) };
|
|
}
|
|
|
|
async function walkPublishHtmlByBasename(publishRoot, basename, maxDepth = 6, depth = 0) {
|
|
if (depth > maxDepth || !basename.toLowerCase().endsWith('.html')) return null;
|
|
let entries;
|
|
try {
|
|
entries = await fs.readdir(publishRoot, { withFileTypes: true });
|
|
} catch {
|
|
return null;
|
|
}
|
|
for (const entry of entries) {
|
|
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
const full = path.join(publishRoot, entry.name);
|
|
if (entry.isFile() && entry.name === basename) return full;
|
|
}
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
const found = await walkPublishHtmlByBasename(
|
|
path.join(publishRoot, entry.name),
|
|
basename,
|
|
maxDepth,
|
|
depth + 1,
|
|
);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function findPublishHtmlInRoot(publishRoot, relativePath, { readFile = fs.readFile } = {}) {
|
|
const normalized = String(relativePath ?? '').replace(/^\/+/, '');
|
|
const basename = path.basename(normalized);
|
|
const candidates = [
|
|
normalized,
|
|
path.posix.join('public', basename),
|
|
basename,
|
|
].filter((value, index, list) => value && list.indexOf(value) === index);
|
|
|
|
for (const candidate of candidates) {
|
|
const absolute = path.resolve(publishRoot, candidate);
|
|
if (absolute !== publishRoot && !absolute.startsWith(`${publishRoot}${path.sep}`)) continue;
|
|
if (!absolute.toLowerCase().endsWith('.html')) continue;
|
|
try {
|
|
const content = await readFile(absolute, 'utf8');
|
|
if (!content.trim()) continue;
|
|
const resolvedRelativePath = path.relative(publishRoot, absolute).split(path.sep).join('/');
|
|
return {
|
|
absolute,
|
|
content,
|
|
relativePath: resolvedRelativePath,
|
|
filename: path.basename(resolvedRelativePath),
|
|
};
|
|
} catch {
|
|
// try next candidate
|
|
}
|
|
}
|
|
|
|
const absolute = await walkPublishHtmlByBasename(publishRoot, basename);
|
|
if (absolute) {
|
|
const resolvedRelativePath = path.relative(publishRoot, absolute).split(path.sep).join('/');
|
|
const content = await readFile(absolute, 'utf8');
|
|
if (!content.trim()) {
|
|
throw Object.assign(new Error('页面内容为空'), { code: 'empty_page_content' });
|
|
}
|
|
return {
|
|
absolute,
|
|
content,
|
|
relativePath: resolvedRelativePath,
|
|
filename: path.basename(resolvedRelativePath),
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export async function findPublishHtml(h5Root, userId, relativePath, { username } = {}) {
|
|
const key = String(userId ?? '').trim().toLowerCase();
|
|
if (username && key) {
|
|
migrateUserPublishDir(h5Root, { id: key, username });
|
|
}
|
|
|
|
const publishRoot = path.resolve(h5Root, PUBLISH_ROOT_DIR, key);
|
|
const primary = await findPublishHtmlInRoot(publishRoot, relativePath);
|
|
if (primary) return primary;
|
|
|
|
const legacyRoot = username ? resolveLegacyPublishDir(h5Root, { username }) : null;
|
|
if (legacyRoot && path.resolve(legacyRoot) !== publishRoot) {
|
|
const legacy = await findPublishHtmlInRoot(legacyRoot, relativePath);
|
|
if (legacy) {
|
|
migrateUserPublishDir(h5Root, { id: key, username });
|
|
return readPublishHtml(h5Root, userId, legacy.relativePath);
|
|
}
|
|
}
|
|
|
|
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
|
|
}
|
|
|
|
export function buildWorkspaceBaseHref(userId, htmlRelativePath) {
|
|
const key = String(userId ?? '').trim();
|
|
const dir = path.posix.dirname(String(htmlRelativePath ?? '').replace(/^\/+/, ''));
|
|
const segments = dir === '.' ? [] : dir.split('/').filter(Boolean);
|
|
const encoded = segments.map((part) => encodeURIComponent(part)).join('/');
|
|
return `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(key)}/${encoded ? `${encoded}/` : ''}`;
|
|
}
|
|
|
|
export function injectHtmlBaseHref(html, baseHref) {
|
|
const safeBase = String(baseHref ?? '').replace(/"/g, '%22');
|
|
if (!safeBase) return html;
|
|
if (/<base\s/i.test(html)) {
|
|
return html.replace(/<base\s[^>]*href="[^"]*"[^>]*>/i, `<base href="${safeBase}">`);
|
|
}
|
|
if (/<head[^>]*>/i.test(html)) {
|
|
return html.replace(/<head[^>]*>/i, (match) => `${match}\n<base href="${safeBase}">`);
|
|
}
|
|
return `<!DOCTYPE html><html><head><base href="${safeBase}"></head><body>${html}</body></html>`;
|
|
}
|
|
|
|
export function buildChatSavePreviewQuery({
|
|
sessionId,
|
|
messageId,
|
|
selectedLinkIndex = 0,
|
|
previewTitle,
|
|
previewSummary,
|
|
} = {}) {
|
|
const params = new URLSearchParams({
|
|
session_id: String(sessionId ?? ''),
|
|
message_id: String(messageId ?? ''),
|
|
selected_link_index: String(selectedLinkIndex),
|
|
});
|
|
const title = String(previewTitle ?? '').trim();
|
|
const summary = String(previewSummary ?? '').trim();
|
|
if (title) params.set('preview_title', title);
|
|
if (summary) params.set('preview_summary', summary);
|
|
return params;
|
|
}
|
|
|
|
export function buildChatSavePreviewFrameUrl(input) {
|
|
return `/api/mindspace/v1/pages/chat-save-preview?${buildChatSavePreviewQuery(input).toString()}`;
|
|
}
|
|
|
|
export function buildChatSaveThumbnailUrl(input) {
|
|
return `/api/mindspace/v1/pages/chat-save-thumbnail?${buildChatSavePreviewQuery(input).toString()}`;
|
|
}
|
|
|
|
function titleFromHtml(html) {
|
|
const match = String(html).match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
return match?.[1]?.trim() ?? '';
|
|
}
|
|
|
|
function summaryFromHtml(html) {
|
|
const stripped = String(html)
|
|
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
return stripped.slice(0, 180);
|
|
}
|
|
|
|
function dedupeDuplicatedFilename(filename) {
|
|
const match = String(filename ?? '').match(/^(.+)\.html$/i);
|
|
if (!match) return filename;
|
|
let base = match[1];
|
|
const half = Math.floor(base.length / 2);
|
|
if (half >= 2 && base.slice(0, half) === base.slice(half)) {
|
|
base = base.slice(0, half);
|
|
}
|
|
base = base.replace(/(.{4,}?)\1+/g, '$1');
|
|
base = base.replace(/-v-v(\d+)/g, '-v$1');
|
|
base = base.replace(/-v(\d)\1+$/g, '-v$1');
|
|
return `${base}.html`;
|
|
}
|
|
|
|
export function sanitizeMessageContentForSave(content) {
|
|
let text = String(content ?? '');
|
|
text = text.replace(/httpshttps:\/+/gi, 'https://');
|
|
text = text.replace(/https:(?:\/\/)+/gi, 'https://');
|
|
text = text.replace(/(https?:\/\/[^\s<>"')\]]+?)(?:\1)+/gi, '$1');
|
|
text = text.replace(/([\u4e00-\u9fffA-Za-z0-9._-]+)\s+\1/g, '$1');
|
|
text = text.replace(/(\.html)\.html\b/gi, '.html');
|
|
text = text.replace(/((?:MindSpace|temp)\/[^/\s<>"')\]]+\/)(public\/)\2/gi, '$1$2');
|
|
return text;
|
|
}
|
|
|
|
export function extractHtmlFilenameHints(content) {
|
|
const hints = new Set();
|
|
for (const match of String(content ?? '').matchAll(/([a-z0-9][a-z0-9._-]*\.html)/gi)) {
|
|
hints.add(dedupeDuplicatedFilename(match[1].toLowerCase()));
|
|
}
|
|
return [...hints];
|
|
}
|
|
|
|
export function analyzeChatMessageForSave({
|
|
content,
|
|
userId,
|
|
username,
|
|
h5Root,
|
|
selectedLinkIndex = 0,
|
|
}) {
|
|
const sanitized = sanitizeMessageContentForSave(content);
|
|
const links = extractStaticPageLinks(sanitized, { userId, username });
|
|
const text = sanitized.replace(/\s+/g, ' ').trim();
|
|
const suggestedTitleFromText = text
|
|
.replace(/^#{1,6}\s*/, '')
|
|
.replace(/[*_`~[\]]/g, '')
|
|
.trim()
|
|
.slice(0, 48);
|
|
|
|
if (links.length === 0) {
|
|
return {
|
|
contentMode: 'markdown',
|
|
links: [],
|
|
selectedLink: null,
|
|
suggestedTitle: suggestedTitleFromText || 'AI 创作页面',
|
|
suggestedSummary: text.slice(0, 160),
|
|
previewUrl: null,
|
|
relativePath: null,
|
|
filename: null,
|
|
};
|
|
}
|
|
|
|
const index = Math.min(Math.max(0, selectedLinkIndex), links.length - 1);
|
|
const selectedLink = links[index];
|
|
return {
|
|
contentMode: 'static_html',
|
|
links,
|
|
selectedLink,
|
|
suggestedTitle:
|
|
selectedLink.filename.replace(/\.html$/i, '').replace(/[-_]/g, ' ') ||
|
|
suggestedTitleFromText ||
|
|
'AI 生成页面',
|
|
suggestedSummary: text.slice(0, 160),
|
|
previewUrl: selectedLink.publicUrl,
|
|
relativePath: selectedLink.relativePath,
|
|
filename: selectedLink.filename,
|
|
h5Root,
|
|
userId,
|
|
username,
|
|
};
|
|
}
|
|
|
|
function uniqueRelativePaths(relativePath) {
|
|
const basename = path.basename(String(relativePath ?? ''));
|
|
return [
|
|
String(relativePath ?? '').replace(/^\/+/, ''),
|
|
String(relativePath ?? '')
|
|
.replace(/^\/+/, '')
|
|
.replace(/^public\//, ''),
|
|
`public/${basename}`,
|
|
basename,
|
|
].filter((value, index, list) => value && list.indexOf(value) === index);
|
|
}
|
|
|
|
function buildPublishHtmlFetchUrls({ userId, link, publicBaseUrl }) {
|
|
const urls = new Set();
|
|
const base = String(publicBaseUrl ?? '').replace(/\/$/, '');
|
|
if (base && link) {
|
|
for (const rel of uniqueRelativePaths(link.relativePath)) {
|
|
const asset = buildWorkspaceAssetUrl(userId, rel);
|
|
if (asset) urls.add(`${base}${asset}`);
|
|
}
|
|
}
|
|
if (link?.publicUrl) urls.add(link.publicUrl);
|
|
return [...urls];
|
|
}
|
|
|
|
async function mirrorFetchedPublishHtml(h5Root, userId, relativePath, content, { username } = {}) {
|
|
try {
|
|
if (username) {
|
|
migrateUserPublishDir(h5Root, { id: userId, username });
|
|
}
|
|
const absolute = resolvePublishHtmlAbsolutePath(h5Root, userId, relativePath);
|
|
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
|
await fs.writeFile(absolute, content, 'utf8');
|
|
return absolute;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function fetchPublishHtmlFallback(
|
|
analysis,
|
|
{ publicBaseUrl, fetchImpl = fetch } = {},
|
|
) {
|
|
if (analysis.contentMode !== 'static_html' || !analysis.selectedLink) return null;
|
|
const candidatePaths = uniqueRelativePaths(analysis.selectedLink.relativePath);
|
|
const urls = buildPublishHtmlFetchUrls({
|
|
userId: analysis.userId,
|
|
link: analysis.selectedLink,
|
|
publicBaseUrl,
|
|
});
|
|
for (const url of urls) {
|
|
try {
|
|
const response = await fetchImpl(url, { redirect: 'follow' });
|
|
if (!response.ok) continue;
|
|
const contentType = response.headers.get('content-type') ?? '';
|
|
if (contentType.includes('json')) continue;
|
|
const content = await response.text();
|
|
if (!content.trim() || !/<html/i.test(content)) continue;
|
|
let relativePath = candidatePaths[0];
|
|
for (const rel of candidatePaths) {
|
|
if (url.includes(encodeURIComponent(rel)) || url.endsWith(`/${rel}`) || url.endsWith(rel)) {
|
|
relativePath = rel;
|
|
break;
|
|
}
|
|
}
|
|
const absolute = await mirrorFetchedPublishHtml(
|
|
analysis.h5Root,
|
|
analysis.userId,
|
|
relativePath,
|
|
content,
|
|
{ username: analysis.username },
|
|
);
|
|
const title = titleFromHtml(content);
|
|
const summary = summaryFromHtml(content);
|
|
return {
|
|
absolute:
|
|
absolute ??
|
|
resolvePublishHtmlAbsolutePath(analysis.h5Root, analysis.userId, relativePath),
|
|
content,
|
|
relativePath,
|
|
filename: path.basename(relativePath),
|
|
suggestedTitle: title || analysis.suggestedTitle,
|
|
suggestedSummary: summary || analysis.suggestedSummary,
|
|
publicUrl: analysis.selectedLink.publicUrl,
|
|
};
|
|
} catch {
|
|
// try next url
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function resolveStaticHtmlContent(analysis, options = {}) {
|
|
if (analysis.contentMode !== 'static_html' || !analysis.selectedLink) {
|
|
return null;
|
|
}
|
|
try {
|
|
const loaded = await findPublishHtml(
|
|
analysis.h5Root,
|
|
analysis.userId,
|
|
analysis.selectedLink.relativePath,
|
|
{ username: analysis.username },
|
|
);
|
|
const title = titleFromHtml(loaded.content);
|
|
const summary = summaryFromHtml(loaded.content);
|
|
return {
|
|
...loaded,
|
|
suggestedTitle: title || analysis.suggestedTitle,
|
|
suggestedSummary: summary || analysis.suggestedSummary,
|
|
publicUrl: analysis.selectedLink.publicUrl,
|
|
};
|
|
} catch (error) {
|
|
if (error?.code !== 'static_page_not_found') throw error;
|
|
const fetched = await fetchPublishHtmlFallback(analysis, options);
|
|
if (fetched) return fetched;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function resolveChatSaveAnalysis({
|
|
content,
|
|
userId,
|
|
username,
|
|
h5Root,
|
|
selectedLinkIndex = 0,
|
|
publicBaseUrl,
|
|
fetchImpl,
|
|
}) {
|
|
const sanitized = sanitizeMessageContentForSave(content);
|
|
let analysis = analyzeChatMessageForSave({
|
|
content: sanitized,
|
|
userId,
|
|
username,
|
|
h5Root,
|
|
selectedLinkIndex,
|
|
});
|
|
|
|
const resolveOptions = { publicBaseUrl, fetchImpl };
|
|
|
|
if (analysis.contentMode === 'static_html' && analysis.selectedLink) {
|
|
try {
|
|
const resolvedHtml = await resolveStaticHtmlContent(analysis, resolveOptions);
|
|
return { analysis, resolvedHtml };
|
|
} catch {
|
|
// fall through to filename hints
|
|
}
|
|
}
|
|
|
|
for (const hint of extractHtmlFilenameHints(sanitized)) {
|
|
try {
|
|
const loaded = await findPublishHtml(h5Root, userId, hint, { username });
|
|
const syntheticLink = {
|
|
publicUrl:
|
|
buildWorkspaceAssetUrl(userId, loaded.relativePath) ??
|
|
`/${PUBLISH_ROOT_DIR}/${userId}/${loaded.relativePath}`,
|
|
owner: String(userId ?? '').trim().toLowerCase(),
|
|
relativePath: loaded.relativePath,
|
|
filename: loaded.filename,
|
|
};
|
|
analysis = {
|
|
contentMode: 'static_html',
|
|
links: [syntheticLink],
|
|
selectedLink: syntheticLink,
|
|
suggestedTitle:
|
|
titleFromHtml(loaded.content) ||
|
|
syntheticLink.filename.replace(/\.html$/i, '').replace(/[-_]/g, ' ') ||
|
|
'AI 生成页面',
|
|
suggestedSummary: summaryFromHtml(loaded.content) || sanitized.slice(0, 160),
|
|
previewUrl: syntheticLink.publicUrl,
|
|
relativePath: loaded.relativePath,
|
|
filename: loaded.filename,
|
|
h5Root,
|
|
userId,
|
|
username,
|
|
};
|
|
return {
|
|
analysis,
|
|
resolvedHtml: {
|
|
...loaded,
|
|
suggestedTitle: analysis.suggestedTitle,
|
|
suggestedSummary: analysis.suggestedSummary,
|
|
publicUrl: syntheticLink.publicUrl,
|
|
},
|
|
};
|
|
} catch {
|
|
// try next hint
|
|
}
|
|
}
|
|
|
|
if (analysis.contentMode === 'static_html' && analysis.selectedLink) {
|
|
const fetched = await fetchPublishHtmlFallback(analysis, resolveOptions);
|
|
if (fetched) {
|
|
return { analysis, resolvedHtml: fetched };
|
|
}
|
|
}
|
|
|
|
return { analysis, resolvedHtml: null };
|
|
}
|