import fs from 'node:fs/promises'; import path from 'node:path'; import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID } 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]); if (normalizedUserId) { if (owner !== normalizedUserId) continue; } else if (normalizedUsername && owner !== normalizedUsername) { 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; } export async function findPublishHtml(h5Root, userId, relativePath) { 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) { try { return await readPublishHtml(h5Root, userId, candidate); } catch { // try next candidate } } const publishRoot = path.resolve( h5Root, PUBLISH_ROOT_DIR, String(userId ?? '').trim().toLowerCase(), ); const absolute = await walkPublishHtmlByBasename(publishRoot, basename); if (absolute) { const resolvedRelativePath = path.relative(publishRoot, absolute).split(path.sep).join('/'); return readPublishHtml(h5Root, userId, resolvedRelativePath); } 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 (/]*href="[^"]*"[^>]*>/i, ``); } if (/]*>/i.test(html)) { return html.replace(/]*>/i, (match) => `${match}\n`); } return `${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>/i); return match?.[1]?.trim() ?? ''; } function summaryFromHtml(html) { const stripped = String(html) .replace(//gi, ' ') .replace(//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(/(.+?)\1+/g, '$1'); base = base.replace(/-v-v(\d+)/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, }; } export async function resolveStaticHtmlContent(analysis) { if (analysis.contentMode !== 'static_html' || !analysis.selectedLink) { return null; } const loaded = await findPublishHtml( analysis.h5Root, analysis.userId, analysis.selectedLink.relativePath, ); const title = titleFromHtml(loaded.content); const summary = summaryFromHtml(loaded.content); return { ...loaded, suggestedTitle: title || analysis.suggestedTitle, suggestedSummary: summary || analysis.suggestedSummary, publicUrl: analysis.selectedLink.publicUrl, }; } export async function resolveChatSaveAnalysis({ content, userId, username, h5Root, selectedLinkIndex = 0, }) { const sanitized = sanitizeMessageContentForSave(content); let analysis = analyzeChatMessageForSave({ content: sanitized, userId, username, h5Root, selectedLinkIndex, }); if (analysis.contentMode === 'static_html' && analysis.selectedLink) { try { const resolvedHtml = await resolveStaticHtmlContent(analysis); return { analysis, resolvedHtml }; } catch { // fall through to filename hints } } for (const hint of extractHtmlFilenameHints(sanitized)) { try { const loaded = await findPublishHtml(h5Root, userId, hint); 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 } } return { analysis, resolvedHtml: null }; }