403 lines
14 KiB
JavaScript
403 lines
14 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
import { extractStaticPageLinks } from './mindspace-chat-save.mjs';
|
|
import { DOWNLOADABLE_FILE_PATTERN } from './mindspace-html-download-links.mjs';
|
|
|
|
/**
|
|
* Public HTML finish-sync invariants (regression guard — do not simplify away).
|
|
* edit_file patches must materialize onto MindSpace/<userId>/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_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');
|
|
}
|
|
|
|
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 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) {
|
|
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 });
|
|
}
|
|
}
|
|
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);
|
|
} 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: [] };
|
|
}
|
|
|
|
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,
|
|
} = {}) {
|
|
if (!hasRecentOwnPublicHtmlReference(messages, currentUser, { publishDir })) {
|
|
return { materialized: [], skipped: [], synced: false };
|
|
}
|
|
|
|
const { materialized, skipped } = materializeMissingPublicHtmlWrites({ messages, publishDir });
|
|
const docxSync = syncPublicDocxDownloads({ publishDir });
|
|
let synced = false;
|
|
if (typeof syncWorkspaceAssets === 'function' && currentUser?.id) {
|
|
await syncWorkspaceAssets(currentUser.id, { categoryCode: 'public' });
|
|
synced = true;
|
|
}
|
|
return { materialized, skipped, synced, docxSync };
|
|
}
|