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; } } function encodeUrlPath(relativePath) { return String(relativePath ?? '') .split('/') .filter(Boolean) .map((part) => encodeURIComponent(part)) .join('/'); } export function normalizeStaticHtmlRelativePath(relativePath) { const parts = String(relativePath ?? '') .replace(/^\/+/, '') .split('/') .filter((part) => part && part !== '.' && part !== '..'); if (parts.length === 0) return ''; if (parts[0].toLowerCase() === 'public') return ['public', ...parts.slice(1)].join('/'); if (parts.length === 1 && parts[0].toLowerCase().endsWith('.html')) return `public/${parts[0]}`; return parts.join('/'); } function canonicalizeStaticPageUrl(publicUrl, originalRelativePath, canonicalRelativePath) { if (!canonicalRelativePath || canonicalRelativePath === String(originalRelativePath ?? '').replace(/^\/+/, '')) { return publicUrl; } const suffix = encodeUrlPath(originalRelativePath).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return String(publicUrl).replace(new RegExp(`${suffix}$`), encodeUrlPath(canonicalRelativePath)); } 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 originalRelativePath = decodePathSegment(match[2]); const relativePath = normalizeStaticHtmlRelativePath(originalRelativePath); if (normalizedUserId) { if (owner !== normalizedUserId && owner !== normalizedUsername) continue; } else if (normalizedUsername && owner !== normalizedUsername) { continue; } const key = `${owner}/${relativePath}`; if (seen.has(key)) continue; seen.add(key); links.push({ publicUrl: canonicalizeStaticPageUrl(match[0], originalRelativePath, relativePath), 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 collectPublishHtmlPaths(publishRoot, results, maxDepth = 6, depth = 0) { if (depth > maxDepth || results.length >= 200) return; let entries; try { entries = await fs.readdir(publishRoot, { withFileTypes: true }); } catch { return; } 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.toLowerCase().endsWith('.html')) { results.push(full); if (results.length >= 200) return; } } for (const entry of entries) { if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue; await collectPublishHtmlPaths(path.join(publishRoot, entry.name), results, maxDepth, depth + 1); if (results.length >= 200) return; } } function levenshteinDistance(left, right) { if (left === right) return 0; if (!left) return right.length; if (!right) return left.length; const prev = Array.from({ length: right.length + 1 }, (_, index) => index); const next = new Array(right.length + 1); for (let i = 0; i < left.length; i += 1) { next[0] = i + 1; for (let j = 0; j < right.length; j += 1) { const cost = left[i] === right[j] ? 0 : 1; next[j + 1] = Math.min( next[j] + 1, prev[j + 1] + 1, prev[j] + cost, ); } for (let j = 0; j <= right.length; j += 1) { prev[j] = next[j]; } } return prev[right.length]; } function htmlNameForSimilarity(relativePath) { return path.basename(String(relativePath ?? ''), '.html').toLowerCase(); } function isAcceptableSimilarHtmlMatch(requested, candidate, score, nextScore = Infinity) { if (!requested || !candidate || requested === candidate) return false; const longest = Math.max(requested.length, candidate.length); const allowedDistance = longest >= 18 ? 2 : 1; if (score > allowedDistance) return false; return nextScore > score; } export async function resolveClosestHtmlRelativePath(rootDir, relativePath) { const normalized = normalizeStaticHtmlRelativePath(relativePath); if (!normalized.toLowerCase().endsWith('.html')) return null; const requestedName = htmlNameForSimilarity(normalized); const candidates = []; await collectPublishHtmlPaths(rootDir, candidates); const ranked = candidates .map((absolute) => { const candidateRelative = path.relative(rootDir, absolute).split(path.sep).join('/'); const candidateName = htmlNameForSimilarity(candidateRelative); return { relativePath: candidateRelative, score: levenshteinDistance(requestedName, candidateName), }; }) .sort((left, right) => left.score - right.score || left.relativePath.localeCompare(right.relativePath)); if (ranked.length === 0) return null; const best = ranked[0]; const nextBestScore = ranked[1]?.score ?? Infinity; if (!isAcceptableSimilarHtmlMatch(requestedName, htmlNameForSimilarity(best.relativePath), best.score, nextBestScore)) { return null; } return best.relativePath; } export async function findPublishHtml(h5Root, userId, relativePath) { const normalized = normalizeStaticHtmlRelativePath(relativePath); 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); } const similarRelativePath = await resolveClosestHtmlRelativePath(publishRoot, normalized); if (similarRelativePath) { return readPublishHtml(h5Root, userId, similarRelativePath); } 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 }; }