feat(mindspace): gate published page delivery

This commit is contained in:
john
2026-07-14 19:13:01 +08:00
parent ae9090948a
commit 27b11894f2
16 changed files with 448 additions and 39 deletions
+78 -2
View File
@@ -131,10 +131,12 @@ import {
resolveStaticHtmlContent,
} from './mindspace-chat-save.mjs';
import {
collectPublicHtmlWritePathsFromSessionEvent,
materializePublicHtmlWritesFromSessionEvent,
normalizePublicHtmlRelativePath,
syncPublicHtmlAfterFinish,
} from './mindspace-public-finish-sync.mjs';
import { getPageDeliveryContract, markPageDeliveryContractReady, preparePageDeliveryContract } from './mindspace-delivery-contract.mjs';
import { maybeRepairH5HtmlAfterFinish } from './mindspace-h5-html-finish-guard.mjs';
import { maybeRepairPageDataAfterFinish } from './mindspace-page-data-finish-guard.mjs';
import { ensurePageDataHtmlPagesBound } from './page-data-workspace-ensure.mjs';
@@ -5075,7 +5077,35 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
return sendDirectChatSessionEvents(req, res, portalDirectSnapshot);
}
const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: req.currentUser.id });
// `proxySessionEvents` deliberately invokes `onEvent` synchronously so an
// async callback here would leave rejected database writes unhandled.
// Retain every in-flight contract write and await it before marking files
// deliverable after Finish.
const deliveryContractWrites = new Map();
const syncPublicHtmlDuringStream = (event) => {
const paths = collectPublicHtmlWritePathsFromSessionEvent(event, { publishDir });
const eventMessages = event?.type === 'Message' && event.message
? [event.message]
: event?.type === 'UpdateConversation' && Array.isArray(event.conversation)
? event.conversation
: [];
const pgRequired = eventMessages.some(
(message) => message?.role === 'user' && message?.metadata?.memindRun?.pgRequired === true,
);
for (const relativePath of paths) {
if (deliveryContractWrites.has(relativePath)) continue;
const write = preparePageDeliveryContract({
pool: authPool,
userId: req.currentUser.id,
requestId: sid,
relativePath,
pgRequired,
}).catch((error) => {
console.warn(`[MindSpace] failed to prepare delivery contract for ${relativePath}: ${error?.message || error}`);
return null;
});
deliveryContractWrites.set(relativePath, write);
}
materializePublicHtmlWritesFromSessionEvent(event, { publishDir });
};
// After Finish, refresh the snapshot and persist any newly generated public
@@ -5131,7 +5161,7 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
`[MindSpace] missing public download files after finish for user ${uid}: ${syncResult.docxSync.missing.join(', ')}`,
);
}
await maybeRepairH5HtmlAfterFinish({
const htmlDelivery = await maybeRepairH5HtmlAfterFinish({
sessionId: sid,
userId: uid,
currentUser: req.currentUser,
@@ -5154,7 +5184,7 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
.join('\n')
: '';
await syncUserGeneratedPages(uid, { sessionId: sid });
await maybeRepairPageDataAfterFinish({
const pageDataDelivery = await maybeRepairPageDataAfterFinish({
sessionId: sid,
userId: uid,
publishDir,
@@ -5165,6 +5195,40 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
tkmindProxy,
userText: lastUserText,
});
const htmlReady = htmlDelivery?.skipped === 'ok';
const pageDataReady = ['ok', 'not_page_data'].includes(String(pageDataDelivery?.skipped ?? ''));
if (htmlReady && pageDataReady) {
const publicHtmlRelativePaths = syncResult?.publicHtmlRelativePaths ?? [];
const pgRequired = [...(Array.isArray(messages) ? messages : [])].some(
(message) => message?.role === 'user' && message?.metadata?.memindRun?.pgRequired === true,
);
for (const relativePath of publicHtmlRelativePaths) {
// A Finish-only write may not have reached the stream callback. This
// also upgrades an early partial stream contract with the definitive
// user delivery choice before it becomes ready.
await (deliveryContractWrites.get(relativePath) ?? preparePageDeliveryContract({
pool: authPool,
userId: uid,
requestId: sid,
relativePath,
pgRequired,
}));
if (deliveryContractWrites.has(relativePath)) {
await preparePageDeliveryContract({
pool: authPool,
userId: uid,
requestId: sid,
relativePath,
pgRequired,
});
}
await markPageDeliveryContractReady({
pool: authPool,
userId: uid,
relativePath,
}).catch(() => false);
}
}
if (lastUserMessage && memoryV2?.observePersonalMemory) {
await memoryV2.observePersonalMemory({
userId: uid,
@@ -6174,6 +6238,18 @@ async function serveUserPublishFile(req, res, next) {
// On-demand cover: rasterize <base>.thumbnail.svg → .thumbnail.png the first time a
// forwarded link's og:image is fetched (and refresh it when the SVG changes).
const resolvedPath = result.filePath;
if (authPool && result.ownerKey && /\.html$/i.test(resolvedPath)) {
const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: result.ownerKey });
const relativePath = path.relative(publishDir, resolvedPath).replace(/\\/g, '/');
const contract = await getPageDeliveryContract({
pool: authPool,
userId: result.ownerKey,
relativePath,
}).catch(() => null);
if (contract && contract.status !== 'ready') {
return res.status(409).type('text/plain; charset=utf-8').send('页面已生成,正在完成发布验证,请稍后重试。');
}
}
if (/\.thumbnail\.png$/i.test(resolvedPath)) {
const svgSibling = resolvedPath.replace(/\.png$/i, '.svg');
if (fs.existsSync(svgSibling)) {