Files
memind/mindspace-chat-save.mjs
T
john 0ae7a6a677 fix(mindspace): 公开页上传图片物化到 static 路径供转发访问
Finish 同步与首次访问时将私有资产 API 图片复制到 public/.tmp-images,
避免其他用户转发链接后因无登录态无法加载页面内图片。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 17:03:47 +08:00

722 lines
24 KiB
JavaScript

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 (/<base\s/i.test(html)) {
return html.replace(/<base\s[^>]*href="[^"]*"[^>]*>/i, `<base href="${safeBase}">`);
}
if (/<head[^>]*>/i.test(html)) {
return html.replace(/<head[^>]*>/i, (match) => `${match}\n<base href="${safeBase}">`);
}
return `<!DOCTYPE html><html><head><base href="${safeBase}"></head><body>${html}</body></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[^>]*>([^<]+)<\/title>/i);
return match?.[1]?.trim() ?? '';
}
function summaryFromHtml(html) {
const stripped = String(html)
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/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 };
}