feat: complete mindspace conversation packages

This commit is contained in:
john
2026-07-02 23:46:05 +08:00
parent b084f6d23c
commit b258004dad
31 changed files with 1990 additions and 118 deletions
+219 -37
View File
@@ -48,6 +48,7 @@ import {
createDownloadConversationPackageManifestHandler,
createGetConversationPackageHandler,
} from './mindspace-conversation-package-routes.mjs';
import { backfillConversationPackageArtifacts } from './mindspace-conversation-package-backfill.mjs';
import { createConversationPackageStore } from './mindspace-conversation-package-store.mjs';
import { createMindSpaceServiceFacade } from './mindspace-service.mjs';
import { createLocalMindSpaceStorageAdapter } from './mindspace-storage-adapter.mjs';
@@ -55,7 +56,11 @@ import { createPageLiveEditService } from './mindspace-page-live-edit.mjs';
import { createAssetAgentService } from './mindspace-asset-agent.mjs';
import { createPageEditSessionService } from './mindspace-page-edit-session.mjs';
import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs';
import { createPublicationService, rewriteWorkspacePublicAssetReferences } from './mindspace-publications.mjs';
import {
createPublicationService,
publicationInternals,
rewriteWorkspacePublicAssetReferences,
} from './mindspace-publications.mjs';
import { createPlazaPostService, formatPostRow, mapPlazaError } from './plaza-posts.mjs';
import { createPlazaEventService } from './plaza-events.mjs';
import { createPlazaRecommendService } from './plaza-recommend.mjs';
@@ -96,7 +101,9 @@ import {
resolveStaticHtmlContent,
} from './mindspace-chat-save.mjs';
import {
collectOwnPublicHtmlArtifactRefs,
materializePublicHtmlWritesFromSessionEvent,
normalizePublicHtmlRelativePath,
syncPublicHtmlAfterFinish,
} from './mindspace-public-finish-sync.mjs';
import { syncGeneratedPagesFromPublicAssets } from './mindspace-page-sync.mjs';
@@ -119,6 +126,10 @@ import {
renderLongImageBuffer,
} from './mindspace-long-image.mjs';
import { generateDocxBuffer } from './mindspace-docx-export.mjs';
import {
DOCX_MIME_TYPE,
registerChatDocxArtifactForConversation,
} from './mindspace-chat-docx-package.mjs';
import { scanContent } from './mindspace-content-scan.mjs';
import { renderImageAssetViewerHtml, wantsInlineImageViewer } from './mindspace-asset-preview.mjs';
import { createRechargeService } from './billing-recharge.mjs';
@@ -258,6 +269,7 @@ let conversationMemoryService = null;
let mindSpace = null;
let mindSpaceAssets = null;
let mindSpaceAudit = null;
let mindSpaceServiceFacade = null;
let mindSpaceConversationPackageRegistry = null;
let mindSpacePages = null;
let mindSpacePageLiveEdit = null;
@@ -309,12 +321,23 @@ async function bootstrapUserAuth() {
});
const mindSpaceStorageRoot =
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace');
mindSpaceServiceFacade = createMindSpaceServiceFacade({
storageAdapter: createLocalMindSpaceStorageAdapter(mindSpaceStorageRoot),
publicBaseUrl: resolvePublicBaseUrl(),
conversationPackageBackfill: ({ user, sessionId }) =>
backfillConversationPackageArtifacts({
pool,
registry: mindSpaceConversationPackageRegistry,
storageRoot: mindSpaceStorageRoot,
h5Root: __dirname,
user,
sessionId,
}),
conversationPackagePublicHtmlHydrator: hydratePublicHtmlArtifactsForConversationPackage,
});
mindSpaceConversationPackageRegistry = createConversationPackageRegistry({
store: createConversationPackageStore(pool),
service: createMindSpaceServiceFacade({
storageAdapter: createLocalMindSpaceStorageAdapter(mindSpaceStorageRoot),
publicBaseUrl: resolvePublicBaseUrl(),
}),
service: mindSpaceServiceFacade,
});
mindSpaceAssets = createAssetService(pool, {
h5Root: __dirname,
@@ -2498,6 +2521,11 @@ api.get('/mindspace/v1/conversation-packages/:sessionId', createGetConversationP
ensureMindSpaceEnabled,
sendData,
mindSpaceError,
beforeReadManifest: ({ req, sessionId }) =>
mindSpaceServiceFacade?.prepareConversationPackageRead({
user: req.currentUser,
sessionId,
}),
}));
api.get(
@@ -2507,6 +2535,11 @@ api.get(
getUserAuth: () => userAuth,
ensureMindSpaceEnabled,
mindSpaceError,
beforeReadManifest: ({ req, sessionId }) =>
mindSpaceServiceFacade?.prepareConversationPackageRead({
user: req.currentUser,
sessionId,
}),
}),
);
@@ -2564,6 +2597,19 @@ api.post('/mindspace/v1/uploads/:uploadId/complete', async (req, res) => {
}
});
api.post('/mindspace/v1/conversation-packages/:sessionId/claim-uploads', async (req, res) => {
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req, { upload: true })) return;
try {
const result = await mindSpaceAssets.claimUploadArtifactsForConversation(req.currentUser.id, {
sessionId: req.params.sessionId,
messageId: req.body?.message_id,
});
return sendData(res, req, result);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.delete('/mindspace/v1/uploads/:uploadId', async (req, res) => {
if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
@@ -2898,7 +2944,6 @@ async function resolveExistingSavedPage(userId, { sessionId, messageId, relative
}
const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'public']);
const DOCX_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
const pageSyncInFlight = new Map();
@@ -3018,58 +3063,174 @@ async function resolveChatSaveBundle(user, h5Root, input = {}) {
};
}
async function registerChatDocxArtifactForConversation({
user,
bundle,
requestBody = {},
filename,
buffer,
async function registerPublishedLongImageArtifactForConversation({
result,
image,
canonicalUrl,
}) {
if (!mindSpaceConversationPackageRegistry || !user?.id || !filename || !Buffer.isBuffer(buffer)) return;
const sessionId = String(requestBody.session_id ?? requestBody.sessionId ?? '').trim();
const messageId = String(requestBody.message_id ?? requestBody.messageId ?? '').trim();
if (!sessionId || !messageId) return;
const ownerId = String(result?.ownerId ?? '').trim();
const pageSource = result?.pageSource ?? {};
const sessionId = String(pageSource.sourceSessionId ?? '').trim();
const messageId = String(pageSource.sourceMessageId ?? '').trim();
const publicationId = String(result?.publication?.id ?? '').trim();
if (
!mindSpaceConversationPackageRegistry ||
!ownerId ||
!sessionId ||
!publicationId ||
!Buffer.isBuffer(image)
) {
return;
}
try {
const selectedLinkIndex = Number(requestBody.selected_link_index ?? requestBody.selectedLinkIndex ?? 0);
const hash = crypto
.createHash('sha256')
.update(`${user.id}:${sessionId}:${messageId}:${selectedLinkIndex}:${filename}`)
.update(`${ownerId}:${sessionId}:${publicationId}:long-image`)
.digest('hex')
.slice(0, 16);
const artifactId = `ca_docx_${hash}`;
const artifactId = `ca_long_image_${hash}`;
const filename = `${publicationId || 'published-page'}.long.png`;
const relativePath = `artifacts/${artifactId}/${filename}`;
const now = Date.now();
const { packageRecord, writeResult } =
await mindSpaceConversationPackageRegistry.putObjectForSession({
userId: user.id,
userId: ownerId,
sessionId,
title: bundle?.source?.session?.name ?? null,
title: pageSource.title ?? null,
relativePath,
body: buffer,
body: image,
});
const canonicalUrl =
`/api/mindspace/v1/conversation-packages/${encodeURIComponent(sessionId)}` +
`/artifacts/${encodeURIComponent(artifactId)}/download`;
await mindSpaceConversationPackageRegistry.recordArtifact({
id: artifactId,
packageId: packageRecord.id,
artifactKind: 'docx',
messageId,
artifactKind: 'long_image',
role: 'assistant',
pageId: pageSource.pageId ?? result?.publication?.pageId ?? null,
publicationId,
messageId: messageId || null,
displayName: filename,
mimeType: DOCX_MIME_TYPE,
sizeBytes: buffer.length,
mimeType: 'image/png',
sizeBytes: image.length,
storageKey: writeResult.key,
canonicalUrl,
sortOrder: Date.now(),
sortOrder: now,
now,
});
await mindSpaceConversationPackageRegistry.writeManifestForSession({
userId: user.id,
userId: ownerId,
sessionId,
});
} catch (error) {
console.warn('[MindSpace] failed to record chat docx artifact:', error?.message ?? error);
console.warn('[MindSpace] failed to record published long image artifact:', error?.message ?? error);
}
}
async function registerPublicHtmlArtifactsForConversation({
user,
publishDir,
sessionId,
title = null,
relativePaths = [],
artifactRefs = [],
}) {
if (
!mindSpaceConversationPackageRegistry ||
!user?.id ||
!publishDir ||
!sessionId ||
((!Array.isArray(relativePaths) || relativePaths.length === 0) &&
(!Array.isArray(artifactRefs) || artifactRefs.length === 0))
) {
return [];
}
try {
const publishRoot = path.resolve(publishDir);
const packageTitle = String(title ?? '').trim() || null;
const existingManifest = await mindSpaceConversationPackageRegistry
.readManifestForSession({ userId: user.id, sessionId })
.catch(() => null);
const packageRecord = existingManifest?.packageId && existingManifest.title
? { id: existingManifest.packageId }
: await mindSpaceConversationPackageRegistry.ensurePackage({
userId: user.id,
sessionId,
title: packageTitle ?? existingManifest?.title ?? null,
});
const recorded = [];
const now = Date.now();
const refs =
Array.isArray(artifactRefs) && artifactRefs.length > 0
? artifactRefs
: relativePaths.map((relativePath) => ({ relativePath }));
for (const ref of refs) {
const relativePath = typeof ref === 'string' ? ref : ref?.relativePath;
const normalized = normalizePublicHtmlRelativePath(relativePath);
if (!normalized || !normalized.toLowerCase().endsWith('.html')) continue;
const absolutePath = path.resolve(publishRoot, normalized);
if (absolutePath !== publishRoot && !absolutePath.startsWith(`${publishRoot}${path.sep}`)) continue;
let stat = null;
try {
stat = await fsPromises.stat(absolutePath);
} catch {
continue;
}
if (!stat.isFile()) continue;
const hash = crypto
.createHash('sha256')
.update(`${user.id}:${sessionId}:${normalized}`)
.digest('hex')
.slice(0, 16);
const artifactId = `ca_public_html_${hash}`;
await mindSpaceConversationPackageRegistry.recordArtifact({
id: artifactId,
packageId: packageRecord.id,
artifactKind: 'public_html',
role: 'assistant',
messageId: typeof ref?.messageId === 'string' && ref.messageId.trim() ? ref.messageId.trim() : null,
displayName: path.posix.basename(normalized),
mimeType: 'text/html',
sizeBytes: stat.size,
canonicalUrl: buildPublicUrl(resolvePublicBaseUrl(), user.id, normalized),
sortOrder: Number.isFinite(stat.mtimeMs) ? Math.round(stat.mtimeMs) : now,
now,
});
recorded.push({ artifactId, relativePath: normalized });
}
if (recorded.length > 0) {
await mindSpaceConversationPackageRegistry.writeManifestForSession({
userId: user.id,
sessionId,
});
}
return recorded;
} catch (error) {
console.warn('[MindSpace] failed to record public html artifacts:', error?.message ?? error);
return [];
}
}
async function hydratePublicHtmlArtifactsForConversationPackage({
user,
sessionId,
}) {
if (!sessionSnapshotService?.isEnabled?.() || !user?.id || !sessionId) return [];
const publishDir = resolvePublishDir(__dirname, { id: user.id });
const snapshot = await sessionSnapshotService.get(sessionId).catch(() => null);
const messages = Array.isArray(snapshot?.messages) ? snapshot.messages : [];
const artifactRefs = collectOwnPublicHtmlArtifactRefs({
messages,
currentUser: user,
publishDir,
});
return registerPublicHtmlArtifactsForConversation({
user,
publishDir,
sessionId,
title: snapshot?.session?.name,
artifactRefs,
});
}
api.get('/mindspace/v1/pages/chat-save-preview', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
@@ -3255,11 +3416,14 @@ async function handleChatSaveDocx(req, res) {
.slice(0, 60) || 'mindspace-document';
const filename = `${filenameBase}.docx`;
await registerChatDocxArtifactForConversation({
registry: mindSpaceConversationPackageRegistry,
user: req.currentUser,
bundle,
requestBody: req.body,
filename,
buffer,
}).catch((error) => {
console.warn('[MindSpace] failed to record chat docx artifact:', error?.message ?? error);
});
res.set('Content-Type', DOCX_MIME_TYPE);
res.set(
@@ -4524,6 +4688,14 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets
? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options)
: null,
registerPublicHtmlArtifacts: (_userId, options) =>
registerPublicHtmlArtifactsForConversation({
user: req.currentUser,
publishDir,
sessionId: options?.sessionId,
relativePaths: options?.relativePaths,
artifactRefs: options?.artifactRefs,
}),
});
if (Array.isArray(syncResult?.docxSync?.missing) && syncResult.docxSync.missing.length > 0) {
console.warn(
@@ -4604,6 +4776,12 @@ api.use(
);
app.use('/api', api);
// Express routing is case-insensitive by default, so the lowercase /mindspace API
// mount would otherwise capture public /MindSpace/... page URLs.
app.use(`/${PUBLISH_ROOT_DIR}`, async (req, res, next) => {
await userAuthReady;
return serveUserPublishFile(req, res, next);
});
app.use('/mindspace', api);
function scriptSrcDirective({ inline = false, urls = [], hashes = [] } = {}) {
@@ -5061,6 +5239,15 @@ async function sendPublishedPage(req, res, result, { embed = false, raw = false
try {
const rawUrl = new URL(appendQueryParam(sharePath || originalPath, 'view', 'raw'), origin || 'http://localhost');
const image = await renderLongImageBuffer({ url: rawUrl.toString() });
const longImageUrl = new URL(
appendQueryParam(sharePath || originalPath, 'download', 'long-image'),
origin || 'http://localhost',
).toString();
await registerPublishedLongImageArtifactForConversation({
result,
image,
canonicalUrl: longImageUrl,
});
res.set('Content-Type', 'image/png');
res.set('Content-Disposition', 'attachment; filename="mindspace-public-page.long.png"');
res.set('Cache-Control', 'no-store');
@@ -5588,11 +5775,6 @@ async function serveUserPublishFile(req, res, next) {
await sendPublishFile(req, res, resolvedPath);
}
app.use(`/${PUBLISH_ROOT_DIR}`, async (req, res, next) => {
await userAuthReady;
return serveUserPublishFile(req, res, next);
});
app.use('/temp', (req, res) => {
res.redirect(301, `/${PUBLISH_ROOT_DIR}${req.url}`);
});