diff --git a/mindspace-chat-save.mjs b/mindspace-chat-save.mjs
index 396bf06..efb9ab4 100644
--- a/mindspace-chat-save.mjs
+++ b/mindspace-chat-save.mjs
@@ -50,6 +50,7 @@ export async function materializePrivateAssetsInWorkspaceHtml({
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 };
@@ -106,6 +107,55 @@ export async function materializePrivateAssetsInWorkspaceHtml({
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])),
diff --git a/mindspace-chat-save.test.mjs b/mindspace-chat-save.test.mjs
index 38c89bc..d35c292 100644
--- a/mindspace-chat-save.test.mjs
+++ b/mindspace-chat-save.test.mjs
@@ -14,6 +14,7 @@ import {
findPublishHtml,
injectHtmlBaseHref,
materializePrivateAssetsInWorkspaceHtml,
+ materializePrivateAssetsInPublicHtmlFiles,
repairMissingHtmlAssetReferences,
resolveClosestHtmlRelativePath,
resolveChatSaveAnalysis,
@@ -226,6 +227,44 @@ test('materializePrivateAssetsInWorkspaceHtml copies images to public/.tmp-image
await fs.stat(path.join(publishDir, '.tmp-images', `${assetId}.jpg`));
});
+test('materializePrivateAssetsInPublicHtmlFiles rewrites referenced public html files', async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-chat-save-materialize-batch-'));
+ const storageRoot = path.join(root, 'storage');
+ const assetId = '44444444-4444-4444-8444-444444444444';
+ const userId = USER_ID;
+ const storageKey = `users/${userId}/assets/${assetId}/versions/v1`;
+ const sourcePath = path.join(storageRoot, storageKey);
+ await fs.mkdir(path.dirname(sourcePath), { recursive: true });
+ await fs.writeFile(sourcePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
+
+ const publishDir = path.join(root, 'MindSpace', userId);
+ const htmlRelativePath = 'public/share.html';
+ const htmlPath = path.join(publishDir, htmlRelativePath);
+ await fs.mkdir(path.dirname(htmlPath), { recursive: true });
+ const html = `
`;
+ await fs.writeFile(htmlPath, html, 'utf8');
+
+ const pool = {
+ query: async () => [
+ [{ id: assetId, mime_type: 'image/jpeg', original_filename: 'hero.jpg', storage_key: storageKey }],
+ ],
+ };
+
+ const result = await materializePrivateAssetsInPublicHtmlFiles({
+ pool,
+ storageRoot,
+ h5Root: root,
+ userId,
+ publishDir,
+ relativePaths: [htmlRelativePath],
+ });
+
+ assert.equal(result.materialized.length, 1);
+ assert.equal(result.materialized[0].relativePath, htmlRelativePath);
+ const saved = await fs.readFile(htmlPath, 'utf8');
+ assert.match(saved, /\.tmp-images\/44444444-4444-4444-8444-444444444444\.jpg/);
+});
+
test('repairMissingHtmlAssetReferences swaps invalid html asset ids from chat message', async () => {
const validId = '22222222-2222-4222-8222-222222222222';
const invalidId = '33333333-3333-4333-8333-333333333333';
diff --git a/mindspace-public-finish-sync.mjs b/mindspace-public-finish-sync.mjs
index d9792d8..3d5c295 100644
--- a/mindspace-public-finish-sync.mjs
+++ b/mindspace-public-finish-sync.mjs
@@ -1,7 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
-import { extractStaticPageLinks } from './mindspace-chat-save.mjs';
+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';
@@ -480,6 +480,9 @@ export async function syncPublicHtmlAfterFinish({
syncWorkspaceAssets,
registerPublicHtmlArtifacts,
sessionId = null,
+ pool = null,
+ storageRoot = null,
+ h5Root = null,
} = {}) {
if (!hasRecentOwnPublicHtmlReference(messages, currentUser, { publishDir })) {
return { materialized: [], skipped: [], synced: false };
@@ -496,6 +499,17 @@ export async function syncPublicHtmlAfterFinish({
});
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, {
@@ -512,5 +526,13 @@ export async function syncPublicHtmlAfterFinish({
artifactRefs: publicHtmlArtifactRefs,
});
}
- return { materialized, skipped, synced, docxSync, publicHtmlRelativePaths, publicHtmlArtifactRefs };
+ return {
+ materialized,
+ skipped,
+ synced,
+ docxSync,
+ publicHtmlRelativePaths,
+ publicHtmlArtifactRefs,
+ assetMaterialization,
+ };
}
diff --git a/mindspace-public-finish-sync.test.mjs b/mindspace-public-finish-sync.test.mjs
index 0ab490d..90751c5 100644
--- a/mindspace-public-finish-sync.test.mjs
+++ b/mindspace-public-finish-sync.test.mjs
@@ -249,6 +249,58 @@ test('syncPublicHtmlAfterFinish registers existing public html artifacts for the
}
});
+test('syncPublicHtmlAfterFinish materializes private image assets into public html files', async () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-assets-'));
+ const publishDir = path.join(root, 'MindSpace', CURRENT_USER.id);
+ const storageRoot = path.join(root, 'storage');
+ const assetId = '55555555-5555-4555-8555-555555555555';
+ const storageKey = `users/${CURRENT_USER.id}/assets/${assetId}/versions/v1`;
+ fs.mkdirSync(path.dirname(path.join(storageRoot, storageKey)), { recursive: true });
+ fs.writeFileSync(path.join(storageRoot, storageKey), Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
+ try {
+ fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
+ fs.writeFileSync(
+ path.join(publishDir, 'public/tea.html'),
+ `
`,
+ );
+ const messages = [
+ {
+ id: 'msg-tea-1',
+ role: 'assistant',
+ content: [
+ {
+ type: 'text',
+ text: `[茶馆](https://m.tkmind.cn/MindSpace/${CURRENT_USER.id}/public/tea.html)`,
+ },
+ ],
+ },
+ ];
+ const pool = {
+ query: async () => [
+ [{ id: assetId, mime_type: 'image/jpeg', original_filename: 'tea.jpg', storage_key: storageKey }],
+ ],
+ };
+
+ const result = await syncPublicHtmlAfterFinish({
+ messages,
+ currentUser: CURRENT_USER,
+ publishDir,
+ sessionId: 'session-tea',
+ pool,
+ storageRoot,
+ h5Root: root,
+ syncWorkspaceAssets: async () => {},
+ });
+
+ assert.equal(result.assetMaterialization.materialized.length, 1);
+ const saved = fs.readFileSync(path.join(publishDir, 'public/tea.html'), 'utf8');
+ assert.match(saved, /\.tmp-images\/55555555-5555-4555-8555-555555555555\.jpg/);
+ assert.ok(fs.existsSync(path.join(publishDir, 'public/.tmp-images', `${assetId}.jpg`)));
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
test('collectOwnPublicHtmlArtifactRefs includes source message ids when available', () => {
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
try {
diff --git a/server.mjs b/server.mjs
index aa055ad..6afb19e 100644
--- a/server.mjs
+++ b/server.mjs
@@ -116,6 +116,7 @@ import {
injectHtmlBaseHref,
resolveClosestHtmlRelativePath,
createAssetDataUriResolver,
+ htmlReferencesPrivateAssets,
materializePrivateAssetsInWorkspaceHtml,
repairMissingHtmlAssetReferences,
resolveChatSaveAnalysis,
@@ -4846,6 +4847,9 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
currentUser: req.currentUser,
publishDir,
sessionId: sid,
+ pool: authPool,
+ storageRoot: resolveMindSpaceRuntimeConfig(__dirname, process.env).storageRoot,
+ h5Root: __dirname,
syncWorkspaceAssets:
WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets
? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options)
@@ -5728,6 +5732,30 @@ async function sendLongImageDownloadIfRequested(req, res, filePath) {
});
}
+async function ensurePublicHtmlPrivateAssetsMaterialized(filePath, html) {
+ if (!authPool || !htmlReferencesPrivateAssets(html)) return html;
+ const normalized = path.resolve(String(filePath ?? ''));
+ const ownerMatch = normalized.match(
+ new RegExp(`[\\\\/]${PUBLISH_ROOT_DIR}[\\\\/]([0-9a-f-]{36})[\\\\/]`, 'i'),
+ );
+ const userId = ownerMatch?.[1] ?? null;
+ if (!userId) return html;
+ const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: userId });
+ const htmlRelativePath = path.relative(publishDir, normalized).replace(/\\/g, '/');
+ if (!htmlRelativePath.startsWith(`${PUBLIC_ZONE_DIR}/`)) return html;
+ const { storageRoot } = resolveMindSpaceRuntimeConfig(__dirname, process.env);
+ const result = await materializePrivateAssetsInWorkspaceHtml({
+ pool: authPool,
+ storageRoot,
+ h5Root: __dirname,
+ userId,
+ html,
+ htmlRelativePath,
+ writeBack: true,
+ }).catch(() => ({ html, changed: false }));
+ return result.changed ? result.html : html;
+}
+
async function sendPublishFile(req, res, filePath) {
if (!filePath.toLowerCase().endsWith('.html')) {
res.sendFile(filePath, (err) => {
@@ -5743,6 +5771,7 @@ async function sendPublishFile(req, res, filePath) {
res.status(404).json({ message: '文件不存在' });
return;
}
+ html = await ensurePublicHtmlPrivateAssetsMaterialized(filePath, html);
html = preparePublicHtmlAssetDelivery(html, INTERNAL_AGENT_SECRET);
const embed = isPlazaEmbedRequest(req.query);
if (embed) {