import fs from 'node:fs'; import path from 'node:path'; import { extractStaticPageLinks, materializePrivateAssetsInPublicHtmlFiles } from './mindspace-chat-save.mjs'; import { DOWNLOADABLE_FILE_PATTERN } from './mindspace-html-download-links.mjs'; import { scheduleWorkspaceHtmlThumbnailSidecars } from './mindspace-workspace-thumbnails.mjs'; /** * Public HTML finish-sync invariants (regression guard — do not simplify away). * edit_file patches must materialize onto MindSpace//public/*.html. * See docs/regression-guards/mindspace-publish-and-chat-finish.md */ const PUBLIC_HTML_PATH_PATTERN = /(?:^|[^a-z0-9_./-])(public\/[a-z0-9][a-z0-9._/-]{0,255}\.html)\b/i; const PUBLIC_HTML_PATH_PATTERN_GLOBAL = /(?:^|[^a-z0-9_./-])(public\/[a-z0-9][a-z0-9._/-]{0,255}\.html)\b/gi; const PUBLIC_DOWNLOAD_ATTR_PATTERN = /(?:href|src)=["']([^"'#?\s]+)["']/gi; const DEFAULT_DOWNLOAD_SYNC_MIN_SIZE = 1024; function normalizePublicDownloadHref(href) { const clean = String(href ?? '') .split('?')[0] .split('#')[0] .replace(/^\.\//, '') .trim(); if ( !clean || clean.includes('://') || clean.startsWith('data:') || !DOWNLOADABLE_FILE_PATTERN.test(clean) ) { return null; } if (clean.startsWith('../oa/')) return clean.slice(3); if (clean.startsWith('oa/')) return clean; if (!clean.includes('/')) return `public/${clean}`; return clean; } function extractPublicDownloadReferencesFromHtml(publishDir) { const root = path.resolve(String(publishDir ?? '')); const publicDir = path.join(root, 'public'); if (!fs.existsSync(publicDir) || !fs.statSync(publicDir).isDirectory()) return []; const refs = new Map(); for (const file of fs.readdirSync(publicDir)) { if (!file.toLowerCase().endsWith('.html')) continue; const content = fs.readFileSync(path.join(publicDir, file), 'utf8'); const htmlRelativePath = `public/${file}`; for (const match of content.matchAll(PUBLIC_DOWNLOAD_ATTR_PATTERN)) { const relativePath = normalizePublicDownloadHref(match[1]); if (relativePath && !refs.has(relativePath)) { refs.set(relativePath, { relativePath, htmlRelativePath }); } } } return [...refs.values()]; } function listOaDownloadCandidates(publishDir, extension, { minSize = DEFAULT_DOWNLOAD_SYNC_MIN_SIZE } = {}) { const oaDir = path.join(path.resolve(String(publishDir ?? '')), 'oa'); if (!fs.existsSync(oaDir) || !fs.statSync(oaDir).isDirectory()) return []; return fs .readdirSync(oaDir) .filter((name) => name.toLowerCase().endsWith(extension)) .map((name) => { const absolutePath = path.join(oaDir, name); const stat = fs.statSync(absolutePath); return { name, absolutePath, size: stat.size, mtimeMs: stat.mtimeMs }; }) .filter((item) => item.size >= minSize) .sort((a, b) => b.mtimeMs - a.mtimeMs || b.size - a.size); } function resolvePublicDownloadSyncSource( root, relativePath, htmlRelativePath, { minSize = DEFAULT_DOWNLOAD_SYNC_MIN_SIZE } = {}, ) { const extension = path.extname(relativePath).toLowerCase(); const basename = path.basename(relativePath); const htmlStemFilename = path.posix.basename(htmlRelativePath).replace(/\.html$/i, extension); const exactCandidates = [ path.posix.join('oa', basename), path.posix.join('oa', htmlStemFilename), ]; for (const candidate of exactCandidates) { const absolutePath = path.resolve(root, candidate); if (absolutePath === path.resolve(root, relativePath)) continue; try { const stat = fs.statSync(absolutePath); if (stat.isFile() && stat.size >= minSize) { return { name: path.basename(candidate), absolutePath, size: stat.size, strategy: 'exact_oa_match', }; } } catch { // Ignore missing exact candidates and continue. } } const fallbackCandidates = listOaDownloadCandidates(root, extension, { minSize }); if (fallbackCandidates.length === 1) { return { ...fallbackCandidates[0], strategy: 'single_oa_fallback' }; } return null; } export function syncPublicDocxDownloads({ publishDir, minCompleteSize = DEFAULT_DOWNLOAD_SYNC_MIN_SIZE, } = {}) { const root = path.resolve(String(publishDir ?? '')); if (!root) return { synced: [], skipped: [], missing: [], source: null, sourceByRelativePath: {} }; const synced = []; const skipped = []; const missing = []; const sourceByRelativePath = {}; for (const reference of extractPublicDownloadReferencesFromHtml(root)) { const { relativePath, htmlRelativePath } = reference; const destination = path.resolve(root, relativePath); if (destination !== root && !destination.startsWith(`${root}${path.sep}`)) { skipped.push(relativePath); continue; } const source = resolvePublicDownloadSyncSource(root, relativePath, htmlRelativePath, { minSize: minCompleteSize, }); const destinationExists = fs.existsSync(destination) && fs.statSync(destination).isFile(); if (destinationExists && !source) { skipped.push(relativePath); continue; } if (!source) { missing.push(relativePath); continue; } if (destinationExists) { try { if (fs.statSync(destination).size >= source.size) { skipped.push(relativePath); continue; } } catch { // Fall through and rewrite from source. } } try { fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.copyFileSync(source.absolutePath, destination); synced.push(relativePath); sourceByRelativePath[relativePath] = source.name; } catch { skipped.push(relativePath); } } const sourceNames = [...new Set(Object.values(sourceByRelativePath))]; return { synced, skipped, missing, source: sourceNames.length === 1 ? sourceNames[0] : null, sourceByRelativePath, }; } function messageText(message) { const textParts = Array.isArray(message?.content) ? message.content .filter((item) => item?.type === 'text' && typeof item.text === 'string') .map((item) => item.text.trim()) .filter(Boolean) : []; const displayText = typeof message?.metadata?.displayText === 'string' ? message.metadata.displayText.trim() : ''; return [...textParts, displayText].filter(Boolean).join('\n'); } function messageId(message) { const id = message?.id ?? message?.message_id ?? message?.metadata?.messageId ?? message?.metadata?.message_id ?? null; const normalized = String(id ?? '').trim(); return normalized || null; } export function normalizePublicHtmlRelativePath(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 publicHtmlFileExists(publishDir, relativePath) { if (!publishDir) return false; const root = path.resolve(String(publishDir ?? '')); const normalized = normalizePublicHtmlRelativePath(relativePath); if (!root || !normalized.toLowerCase().endsWith('.html')) return false; const target = path.resolve(root, normalized); if (target !== root && !target.startsWith(`${root}${path.sep}`)) return false; return fs.existsSync(target) && fs.statSync(target).isFile(); } export function collectOwnPublicHtmlRelativePaths({ messages, currentUser, publishDir, materialized = [], skipped = [], } = {}) { const paths = new Set(); const add = (relativePath) => { const normalized = normalizePublicHtmlRelativePath(relativePath); if (normalized && publicHtmlFileExists(publishDir, normalized)) paths.add(normalized); }; for (const relativePath of [...materialized, ...skipped]) add(relativePath); for (const message of Array.isArray(messages) ? messages : []) { const text = messageText(message); if (!text) continue; for (const link of extractStaticPageLinks(text, { userId: currentUser?.id, username: currentUser?.username ?? null, })) { add(link.relativePath); } for (const match of text.matchAll(PUBLIC_HTML_PATH_PATTERN_GLOBAL)) { add(match[1]); } } return [...paths].sort(); } export function collectOwnPublicHtmlArtifactRefs({ messages, currentUser, publishDir, materialized = [], skipped = [], } = {}) { const refs = new Map(); const add = (relativePath, sourceMessageId = null) => { const normalized = normalizePublicHtmlRelativePath(relativePath); if (!normalized || !publicHtmlFileExists(publishDir, normalized)) return; const existing = refs.get(normalized); refs.set(normalized, { relativePath: normalized, messageId: existing?.messageId ?? sourceMessageId ?? null, }); }; for (const relativePath of [...materialized, ...skipped]) add(relativePath); for (const artifact of extractPublicHtmlWriteArtifacts(messages, { publishDir })) { add(artifact.relativePath, artifact.messageId ?? null); } for (const message of Array.isArray(messages) ? messages : []) { const sourceMessageId = messageId(message); const text = messageText(message); if (!text) continue; for (const link of extractStaticPageLinks(text, { userId: currentUser?.id, username: currentUser?.username ?? null, })) { add(link.relativePath, sourceMessageId); } for (const match of text.matchAll(PUBLIC_HTML_PATH_PATTERN_GLOBAL)) { add(match[1], sourceMessageId); } } return [...refs.values()].sort((a, b) => a.relativePath.localeCompare(b.relativePath)); } function isWriteLikeHtmlTool(name, args) { const action = String(args?.action ?? '').toLowerCase(); const normalizedName = String(name ?? '').trim(); const writeLikeDeveloper = (normalizedName === 'developer' && (action === 'write' || action === 'edit')) || normalizedName === 'write' || normalizedName.endsWith('__write'); const writeLikeSandbox = (normalizedName === 'write_file' || normalizedName === 'edit_file' || normalizedName.endsWith('__write_file') || normalizedName.endsWith('__edit_file')) && typeof args?.path === 'string'; return (writeLikeDeveloper && typeof args?.path === 'string') || writeLikeSandbox; } function isEditLikeHtmlTool(name, args) { const normalizedName = String(name ?? '').trim(); return ( (normalizedName === 'edit_file' || normalizedName.endsWith('__edit_file') || (normalizedName === 'developer' && String(args?.action ?? '').toLowerCase() === 'edit')) && typeof args?.path === 'string' ); } function readPublicHtmlBaseline(publishDir, relativePath) { const root = path.resolve(String(publishDir ?? '')); if (!root) return ''; const destination = path.resolve(root, relativePath); if (destination !== root && !destination.startsWith(`${root}${path.sep}`)) return ''; if (!fs.existsSync(destination) || !fs.statSync(destination).isFile()) return ''; try { return fs.readFileSync(destination, 'utf8'); } catch { return ''; } } function applyPublicHtmlEdit(existing, args) { if (typeof args?.new_str !== 'string') return null; const oldStr = typeof args.old_str === 'string' ? args.old_str : ''; if (oldStr && !existing.includes(oldStr)) return null; return oldStr ? existing.replace(oldStr, args.new_str) : args.new_str; } export function extractPublicHtmlWriteArtifacts(messages = [], { publishDir = null } = {}) { const artifacts = new Map(); for (const message of messages) { const sourceMessageId = messageId(message); for (const item of message?.content ?? []) { if (item?.type !== 'toolRequest') continue; const toolCall = item.toolCall?.value; const args = toolCall?.arguments ?? {}; if (!isWriteLikeHtmlTool(toolCall?.name, args)) continue; const candidate = String(args.path ?? '').trim(); if (!candidate.toLowerCase().endsWith('.html')) continue; const relativePath = normalizePublicHtmlRelativePath(candidate); if (!relativePath) continue; let content = null; if (typeof args.content === 'string') { content = args.content; } else if (isEditLikeHtmlTool(toolCall?.name, args)) { const baseline = artifacts.get(relativePath)?.content ?? readPublicHtmlBaseline(publishDir, relativePath); content = applyPublicHtmlEdit(baseline, args); } else if (typeof args.new_str === 'string' && !args.old_str) { content = args.new_str; } if (content == null) continue; artifacts.set(relativePath, { relativePath, content, messageId: sourceMessageId }); } } return [...artifacts.values()]; } const PUBLIC_HTML_STUB_MARKERS = ['临时补出', '服务号兜底', '服务号自动补出简版页面']; export function isStubPublicHtmlContent(content) { const value = String(content ?? ''); return PUBLIC_HTML_STUB_MARKERS.some((marker) => value.includes(marker)); } function shouldReplaceExistingPublicHtml(destination, nextContent) { if (!fs.existsSync(destination) || !fs.statSync(destination).isFile()) return true; try { const existing = fs.readFileSync(destination, 'utf8'); if (isStubPublicHtmlContent(existing)) return true; return typeof nextContent === 'string' && nextContent.length > 0 && existing !== nextContent; } catch { return Boolean(nextContent); } } export function materializeMissingPublicHtmlWrites({ messages, publishDir }) { const root = path.resolve(String(publishDir ?? '')); if (!root) return { materialized: [], skipped: [] }; const materialized = []; const skipped = []; for (const artifact of extractPublicHtmlWriteArtifacts(messages, { publishDir: root })) { const destination = path.resolve(root, artifact.relativePath); if (destination !== root && !destination.startsWith(`${root}${path.sep}`)) { skipped.push(artifact.relativePath); continue; } if (fs.existsSync(destination) && fs.statSync(destination).isFile()) { if (!shouldReplaceExistingPublicHtml(destination, artifact.content)) { skipped.push(artifact.relativePath); continue; } } try { fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.writeFileSync(destination, artifact.content, 'utf8'); materialized.push(artifact.relativePath); scheduleWorkspaceHtmlThumbnailSidecars(root, artifact.relativePath); } catch { skipped.push(artifact.relativePath); } } return { materialized, skipped }; } export function materializePublicHtmlWritesFromSessionEvent( event, { publishDir, recentCount = 20 } = {}, ) { if (!event || !publishDir) { return { materialized: [], skipped: [] }; } if (event.type === 'Message' && event.message) { return materializeMissingPublicHtmlWrites({ messages: [event.message], publishDir, }); } if (event.type === 'UpdateConversation' && Array.isArray(event.conversation)) { return materializeMissingPublicHtmlWrites({ messages: event.conversation.slice(-Math.max(1, recentCount)), publishDir, }); } return { materialized: [], skipped: [] }; } /** Read the public HTML targets from a stream event without writing them. */ export function collectPublicHtmlWritePathsFromSessionEvent(event, { publishDir, recentCount = 20 } = {}) { if (!event || !publishDir) return []; const messages = event.type === 'Message' && event.message ? [event.message] : event.type === 'UpdateConversation' && Array.isArray(event.conversation) ? event.conversation.slice(-Math.max(1, recentCount)) : []; return [...new Set(extractPublicHtmlWriteArtifacts(messages, { publishDir }) .map((artifact) => artifact.relativePath) .filter(Boolean))]; } function messageHasPublicHtmlToolRequest(message, { publishDir = null } = {}) { if (extractPublicHtmlWriteArtifacts([message], { publishDir }).length > 0) { return true; } for (const item of message?.content ?? []) { if (item?.type !== 'toolRequest') continue; const toolCall = item.toolCall?.value; const args = toolCall?.arguments ?? {}; if (!isWriteLikeHtmlTool(toolCall?.name, args)) continue; const candidate = String(args.path ?? '').trim(); if (!candidate.toLowerCase().endsWith('.html')) continue; if (normalizePublicHtmlRelativePath(candidate)) return true; } return false; } export function hasRecentOwnPublicHtmlReference( messages, currentUser, { recentCount = 80, publishDir = null } = {}, ) { if (!Array.isArray(messages) || messages.length === 0 || !currentUser?.id) return false; const recentMessages = messages.slice(-Math.max(1, recentCount)); for (const message of recentMessages) { if (messageHasPublicHtmlToolRequest(message, { publishDir })) { return true; } const text = messageText(message); if (!text) continue; if ( extractStaticPageLinks(text, { userId: currentUser.id, username: currentUser.username ?? null, }).length > 0 ) { return true; } if (PUBLIC_HTML_PATH_PATTERN.test(text)) { return true; } } return false; } export async function syncPublicHtmlAfterFinish({ messages, currentUser, publishDir, syncWorkspaceAssets, registerPublicHtmlArtifacts, sessionId = null, pool = null, storageRoot = null, h5Root = null, } = {}) { if (!hasRecentOwnPublicHtmlReference(messages, currentUser, { publishDir })) { return { materialized: [], skipped: [], synced: false }; } const { materialized, skipped } = materializeMissingPublicHtmlWrites({ messages, publishDir }); const docxSync = syncPublicDocxDownloads({ publishDir }); const publicHtmlArtifactRefs = collectOwnPublicHtmlArtifactRefs({ messages, currentUser, publishDir, materialized, skipped, }); const publicHtmlRelativePaths = publicHtmlArtifactRefs.map((ref) => ref.relativePath); const sourceMessageId = publicHtmlArtifactRefs.find((ref) => ref.messageId)?.messageId ?? null; let assetMaterialization = { materialized: [], skipped: [] }; if (pool && storageRoot && h5Root && currentUser?.id && publicHtmlRelativePaths.length > 0) { assetMaterialization = await materializePrivateAssetsInPublicHtmlFiles({ pool, storageRoot, h5Root, userId: currentUser.id, publishDir, relativePaths: publicHtmlRelativePaths, }).catch(() => ({ materialized: [], skipped: publicHtmlRelativePaths })); } let synced = false; if (typeof syncWorkspaceAssets === 'function' && currentUser?.id) { await syncWorkspaceAssets(currentUser.id, { categoryCode: 'public', sourceSessionId: sessionId, sourceMessageId, onlyRelativePaths: publicHtmlRelativePaths, }); synced = true; } if (typeof registerPublicHtmlArtifacts === 'function' && currentUser?.id && sessionId) { await registerPublicHtmlArtifacts(currentUser.id, { sessionId, relativePaths: publicHtmlRelativePaths, artifactRefs: publicHtmlArtifactRefs, }); } return { materialized, skipped, synced, docxSync, publicHtmlRelativePaths, publicHtmlArtifactRefs, assetMaterialization, }; }