import fs from 'node:fs/promises'; import path from 'node:path'; import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR, resolvePublishDir } from './user-publish.mjs'; import { workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs'; const PRIVATE_ASSET_URL_PATTERN = /(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi; const PUBLIC_TEMP_IMAGE_DIR = '.tmp-images'; function extensionForMime(mimeType, filename = '') { const ext = path.extname(String(filename ?? '')).replace(/^\./, '').toLowerCase(); if (ext && /^[a-z0-9]{1,8}$/.test(ext)) return ext === 'jpeg' ? 'jpg' : ext; if (mimeType === 'image/jpeg') return 'jpg'; if (mimeType === 'image/png') return 'png'; if (mimeType === 'image/webp') return 'webp'; if (mimeType === 'image/gif') return 'gif'; return 'bin'; } function publicTempImageFilename(assetId, mimeType, fallbackFilename = '') { return `${assetId}.${extensionForMime(mimeType, fallbackFilename)}`; } function absoluteStoragePath(storageRoot, storageKey) { const resolvedRoot = path.resolve(storageRoot); const resolved = path.resolve(resolvedRoot, storageKey); if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path.sep}`)) { throw Object.assign(new Error('路径越界'), { code: 'invalid_storage_path' }); } return resolved; } function workspaceTempImageRelativeUrl(htmlRelativePath, filename) { const htmlDir = path.posix.dirname(String(htmlRelativePath ?? '').replace(/^\/+/, '')); const tmpDir = path.posix.join(PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR); const relDir = htmlDir === '.' || !htmlDir ? tmpDir : path.posix.relative(htmlDir, tmpDir).replace(/\\/g, '/'); return relDir ? `${relDir}/${filename}` : filename; } export async function materializePrivateAssetsInWorkspaceHtml({ pool, storageRoot, h5Root, userId, html, htmlRelativePath, writeBack = false, }) { PRIVATE_ASSET_URL_PATTERN.lastIndex = 0; const matches = [...String(html).matchAll(PRIVATE_ASSET_URL_PATTERN)]; if (matches.length === 0) { return { html, count: 0, changed: false }; } const assetIds = [...new Set(matches.map((match) => match[1]).filter(Boolean))]; const [assets] = await pool.query( `SELECT a.id, a.mime_type, a.original_filename, v.storage_key FROM h5_assets a JOIN h5_asset_versions v ON v.id = a.current_version_id WHERE a.user_id = ? AND a.id IN (?) AND a.mime_type LIKE 'image/%' AND a.status <> 'deleted'`, [userId, assetIds], ); const publishDir = resolvePublishDir(h5Root, { id: userId }); const tmpDir = path.join(publishDir, PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR); const urlMap = new Map(); for (const asset of assets) { const filename = publicTempImageFilename(asset.id, asset.mime_type, asset.original_filename); const target = path.join(tmpDir, filename); try { const sourcePath = absoluteStoragePath(storageRoot, asset.storage_key); await fs.mkdir(tmpDir, { recursive: true }); await fs.copyFile(sourcePath, target); urlMap.set(asset.id, workspaceTempImageRelativeUrl(htmlRelativePath, filename)); } catch { // skip unreadable assets } } if (urlMap.size === 0) { return { html, count: 0, changed: false }; } let count = 0; const result = String(html).replace(PRIVATE_ASSET_URL_PATTERN, (value, assetId) => { const nextUrl = urlMap.get(assetId); if (!nextUrl) return value; count += 1; return nextUrl; }); if (count === 0 || result === html) { return { html, count: 0, changed: false }; } if (writeBack) { const htmlPath = path.join(publishDir, htmlRelativePath); await fs.writeFile(htmlPath, result, 'utf8'); } return { html: result, count, changed: true }; } export function htmlReferencesPrivateAssets(html) { PRIVATE_ASSET_URL_PATTERN.lastIndex = 0; const found = PRIVATE_ASSET_URL_PATTERN.test(String(html ?? '')); PRIVATE_ASSET_URL_PATTERN.lastIndex = 0; return found; } export async function materializePrivateAssetsInPublicHtmlFiles({ pool, storageRoot, h5Root, userId, publishDir, relativePaths = [], }) { const materialized = []; const skipped = []; const uniquePaths = [...new Set(relativePaths)].filter(Boolean); for (const htmlRelativePath of uniquePaths) { const htmlPath = path.join(publishDir, htmlRelativePath); let html; try { html = await fs.readFile(htmlPath, 'utf8'); } catch { skipped.push(htmlRelativePath); continue; } if (!htmlReferencesPrivateAssets(html)) { skipped.push(htmlRelativePath); continue; } const result = await materializePrivateAssetsInWorkspaceHtml({ pool, storageRoot, h5Root, userId, html, htmlRelativePath, writeBack: true, }); if (result.changed) { materialized.push({ relativePath: htmlRelativePath, count: result.count }); } else { skipped.push(htmlRelativePath); } } return { materialized, skipped }; } export async function repairMissingHtmlAssetReferences({ pool, userId, sourceContent, html }) { const collectIds = (text) => [ ...new Set([...String(text).matchAll(PRIVATE_ASSET_URL_PATTERN)].map((match) => match[1])), ]; const htmlIds = collectIds(html); const messageIds = collectIds(sourceContent); if (htmlIds.length === 0 || messageIds.length === 0) { return { html, changed: false }; } const [rows] = await pool.query( `SELECT id FROM h5_assets WHERE user_id = ? AND id IN (?) AND mime_type LIKE 'image/%' AND status <> 'deleted'`, [userId, [...new Set([...htmlIds, ...messageIds])]], ); const existing = new Set(rows.map((row) => row.id)); const invalidHtmlIds = htmlIds.filter((id) => !existing.has(id)); const validMessageIds = messageIds.filter((id) => existing.has(id)); if (invalidHtmlIds.length === 0 || validMessageIds.length === 0) { return { html, changed: false }; } const idMap = new Map(); for (let index = 0; index < invalidHtmlIds.length; index += 1) { const replacement = validMessageIds[index]; if (!replacement) break; idMap.set(invalidHtmlIds[index], replacement); } if (idMap.size === 0) { return { html, changed: false }; } let changed = false; const nextHtml = String(html).replace(PRIVATE_ASSET_URL_PATTERN, (value, assetId) => { const replacement = idMap.get(assetId); if (!replacement) return value; changed = true; return value.replace(assetId, replacement); }); return { html: nextHtml, changed }; } export function createAssetDataUriResolver(pool, storageRoot, userId) { const cache = new Map(); return async (assetId) => { const key = String(assetId ?? '').trim(); if (!key) return null; if (cache.has(key)) return cache.get(key); const [rows] = await pool.query( `SELECT a.mime_type, v.storage_key FROM h5_assets a JOIN h5_asset_versions v ON v.id = a.current_version_id WHERE a.user_id = ? AND a.id = ? AND a.mime_type LIKE 'image/%' AND a.status <> 'deleted' LIMIT 1`, [userId, key], ); const asset = rows[0]; if (!asset) { cache.set(key, null); return null; } try { const buffer = await fs.readFile(absoluteStoragePath(storageRoot, asset.storage_key)); const mimeType = String(asset.mime_type || 'image/jpeg'); const dataUri = `data:${mimeType};base64,${buffer.toString('base64')}`; cache.set(key, dataUri); return dataUri; } catch { cache.set(key, null); return null; } }; } 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 }; }