feat: complete mindspace conversation packages
This commit is contained in:
+219
-37
@@ -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}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user