From b38dd5961f6907760bc07b865a87eb4b0fe23312 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 2 Jul 2026 21:43:38 +0800 Subject: [PATCH] Record chat docx artifacts in packages --- mindspace-conversation-package-registry.mjs | 25 ++++++ ...ace-conversation-package-registry.test.mjs | 37 +++++++++ mindspace-conversation-package-routes.mjs | 51 ++++++++++++ ...space-conversation-package-routes.test.mjs | 82 ++++++++++++++++++- server.mjs | 77 ++++++++++++++++- 5 files changed, 269 insertions(+), 3 deletions(-) diff --git a/mindspace-conversation-package-registry.mjs b/mindspace-conversation-package-registry.mjs index 8a86e14..1baec0a 100644 --- a/mindspace-conversation-package-registry.mjs +++ b/mindspace-conversation-package-registry.mjs @@ -15,6 +15,8 @@ export function createConversationPackageRegistry({ store, service } = {}) { requireMethod(store, 'listArtifacts', 'missing_conversation_package_store'); requireMethod(service, 'putConversationManifest', 'missing_mindspace_service'); requireMethod(service, 'buildConversationManifest', 'missing_mindspace_service'); + requireMethod(service, 'putPackageObject', 'missing_mindspace_service'); + requireMethod(service, 'getPackageObject', 'missing_mindspace_service'); return { async ensurePackage(input) { @@ -32,12 +34,35 @@ export function createConversationPackageRegistry({ store, service } = {}) { return service.putConversationManifest({ packageRecord, artifacts }); }, + async putObjectForSession({ userId, sessionId, title = null, relativePath, body }) { + const packageRecord = await store.upsertPackage({ userId, sessionId, title }); + const writeResult = await service.putPackageObject({ packageRecord, relativePath, body }); + return { packageRecord, writeResult }; + }, + async readManifestForSession({ userId, sessionId }) { const packageRecord = await store.getPackageBySession(userId, sessionId); if (!packageRecord) return null; const artifacts = await store.listArtifacts(packageRecord.id); return service.buildConversationManifest({ packageRecord, artifacts }); }, + + async readArtifactObject({ userId, sessionId, artifactId }) { + const packageRecord = await store.getPackageBySession(userId, sessionId); + if (!packageRecord) return null; + const artifacts = await store.listArtifacts(packageRecord.id); + const artifact = artifacts.find((item) => item.id === artifactId); + if (!artifact?.storageKey) return null; + const prefix = `${packageRecord.storagePrefix}/`; + if (!artifact.storageKey.startsWith(prefix)) { + throw registryError('conversation artifact storage key is outside package', 'invalid_artifact_storage_key'); + } + const body = await service.getPackageObject({ + packageRecord, + relativePath: artifact.storageKey.slice(prefix.length), + }); + return { packageRecord, artifact, body }; + }, }; } diff --git a/mindspace-conversation-package-registry.test.mjs b/mindspace-conversation-package-registry.test.mjs index fa1b99e..dc2a55b 100644 --- a/mindspace-conversation-package-registry.test.mjs +++ b/mindspace-conversation-package-registry.test.mjs @@ -190,6 +190,43 @@ test('conversation package registry reads manifest without writing storage', asy ); }); +test('conversation package registry stores and reads private artifact objects', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-registry-')); + const service = createMindSpaceServiceFacade({ + storageAdapter: createLocalMindSpaceStorageAdapter(root), + publicBaseUrl: 'https://m.tkmind.cn', + }); + const store = createConversationPackageStore(createFakePool()); + const registry = createConversationPackageRegistry({ store, service }); + + const { packageRecord, writeResult } = await registry.putObjectForSession({ + userId: 'user-1', + sessionId: 'session-1', + title: '导出文档', + relativePath: 'artifacts/report.docx', + body: Buffer.from('docx-body'), + }); + const artifact = await registry.recordArtifact({ + id: 'ca_docx', + packageId: packageRecord.id, + artifactKind: 'docx', + displayName: 'report.docx', + mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + storageKey: writeResult.key, + sortOrder: 10, + now: 2000, + }); + + const result = await registry.readArtifactObject({ + userId: 'user-1', + sessionId: 'session-1', + artifactId: artifact.id, + }); + + assert.equal(result.artifact.displayName, 'report.docx'); + assert.equal(result.body.toString('utf8'), 'docx-body'); +}); + test('conversation package registry skips manifest writes for unknown sessions', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-registry-')); const registry = createConversationPackageRegistry({ diff --git a/mindspace-conversation-package-routes.mjs b/mindspace-conversation-package-routes.mjs index e5ee056..21c43dc 100644 --- a/mindspace-conversation-package-routes.mjs +++ b/mindspace-conversation-package-routes.mjs @@ -10,6 +10,11 @@ function resolveRegistry(input) { return typeof input === 'function' ? input() : input; } +function encodeDownloadFilename(filename) { + const safe = String(filename ?? '').trim() || 'mindspace-artifact'; + return `attachment; filename="${encodeURIComponent(safe)}"; filename*=UTF-8''${encodeURIComponent(safe)}`; +} + export function createGetConversationPackageHandler({ registry, getRegistry, @@ -44,3 +49,49 @@ export function createGetConversationPackageHandler({ } }; } + +export function createDownloadConversationPackageArtifactHandler({ + registry, + getRegistry, + userAuth, + getUserAuth, + ensureMindSpaceEnabled = () => true, + mindSpaceError = defaultRouteError, +} = {}) { + return async function downloadConversationPackageArtifact(req, res) { + const activeRegistry = resolveRegistry(getRegistry ?? registry); + if (!activeRegistry || !ensureMindSpaceEnabled(res, req)) { + if (res.headersSent) return undefined; + return res.status(503).json({ message: 'MindSpace 对话包未启用' }); + } + const sessionId = String(req.params?.sessionId ?? '').trim(); + const artifactId = String(req.params?.artifactId ?? '').trim(); + if (!sessionId) return res.status(400).json({ message: '缺少 sessionId' }); + if (!artifactId) return res.status(400).json({ message: '缺少 artifactId' }); + + try { + const activeUserAuth = resolveRegistry(getUserAuth ?? userAuth); + if (!(await activeUserAuth.ownsSession(req.currentUser.id, sessionId))) { + return res.status(403).json({ message: '无权访问该会话' }); + } + const result = await activeRegistry.readArtifactObject({ + userId: req.currentUser.id, + sessionId, + artifactId, + }); + if (!result) return res.status(404).json({ message: '文件不存在' }); + const filename = result.artifact.displayName || result.artifact.id; + res.set('Content-Type', result.artifact.mimeType || 'application/octet-stream'); + res.set('Content-Disposition', encodeDownloadFilename(filename)); + res.set('Cache-Control', 'private, no-store'); + if (req.requestId) res.setHeader('X-Request-Id', req.requestId); + return res.send(result.body); + } catch (error) { + return mindSpaceError(res, req, error); + } + }; +} + +export const conversationPackageRouteInternals = { + encodeDownloadFilename, +}; diff --git a/mindspace-conversation-package-routes.test.mjs b/mindspace-conversation-package-routes.test.mjs index d669140..16060e6 100644 --- a/mindspace-conversation-package-routes.test.mjs +++ b/mindspace-conversation-package-routes.test.mjs @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createGetConversationPackageHandler } from './mindspace-conversation-package-routes.mjs'; +import { + createDownloadConversationPackageArtifactHandler, + createGetConversationPackageHandler, +} from './mindspace-conversation-package-routes.mjs'; function createResponseRecorder() { return { @@ -16,6 +19,20 @@ function createResponseRecorder() { this.headersSent = true; return this; }, + headers: {}, + set(key, value) { + this.headers[key.toLowerCase()] = value; + return this; + }, + setHeader(key, value) { + this.headers[key.toLowerCase()] = value; + return this; + }, + send(payload) { + this.body = payload; + this.headersSent = true; + return this; + }, }; } @@ -107,3 +124,66 @@ test('GET conversation package respects MindSpace feature gates', async () => { assert.equal(res.statusCode, 403); assert.deepEqual(res.body, { message: 'MindSpace 已关闭' }); }); + +test('GET conversation package artifact downloads owned private objects', async () => { + const handler = createDownloadConversationPackageArtifactHandler({ + registry: { + async readArtifactObject(input) { + assert.deepEqual(input, { userId: 'user-1', sessionId: 'session-1', artifactId: 'ca_docx' }); + return { + artifact: { + id: 'ca_docx', + displayName: '研究报告.docx', + mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }, + body: Buffer.from('docx-body'), + }; + }, + }, + userAuth: { + async ownsSession() { + return true; + }, + }, + }); + const res = createResponseRecorder(); + + await handler( + { + currentUser: { id: 'user-1' }, + params: { sessionId: 'session-1', artifactId: 'ca_docx' }, + requestId: 'req-1', + }, + res, + ); + + assert.equal(res.statusCode, 200); + assert.equal(res.body.toString('utf8'), 'docx-body'); + assert.equal( + res.headers['content-type'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ); + assert.match(res.headers['content-disposition'], /filename\*=/); + assert.equal(res.headers['x-request-id'], 'req-1'); +}); + +test('GET conversation package artifact blocks unauthorized sessions before storage access', async () => { + const handler = createDownloadConversationPackageArtifactHandler({ + registry: { + async readArtifactObject() { + assert.fail('registry should not be called for unauthorized artifact downloads'); + }, + }, + userAuth: { + async ownsSession() { + return false; + }, + }, + }); + const res = createResponseRecorder(); + + await handler({ currentUser: { id: 'user-1' }, params: { sessionId: 'session-2', artifactId: 'ca_docx' } }, res); + + assert.equal(res.statusCode, 403); + assert.deepEqual(res.body, { message: '无权访问该会话' }); +}); diff --git a/server.mjs b/server.mjs index d5d8445..c9752af 100644 --- a/server.mjs +++ b/server.mjs @@ -43,7 +43,10 @@ import { ensureMindSpaceConfig } from './mindspace-config.mjs'; import { createAssetService } from './mindspace-assets.mjs'; import { createPageService, pageInternals, inlinePrivateAssetsInHtml, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; import { createConversationPackageRegistry } from './mindspace-conversation-package-registry.mjs'; -import { createGetConversationPackageHandler } from './mindspace-conversation-package-routes.mjs'; +import { + createDownloadConversationPackageArtifactHandler, + createGetConversationPackageHandler, +} from './mindspace-conversation-package-routes.mjs'; import { createConversationPackageStore } from './mindspace-conversation-package-store.mjs'; import { createMindSpaceServiceFacade } from './mindspace-service.mjs'; import { createLocalMindSpaceStorageAdapter } from './mindspace-storage-adapter.mjs'; @@ -2496,6 +2499,16 @@ api.get('/mindspace/v1/conversation-packages/:sessionId', createGetConversationP mindSpaceError, })); +api.get( + '/mindspace/v1/conversation-packages/:sessionId/artifacts/:artifactId/download', + createDownloadConversationPackageArtifactHandler({ + getRegistry: () => mindSpaceConversationPackageRegistry, + getUserAuth: () => userAuth, + ensureMindSpaceEnabled, + mindSpaceError, + }), +); + api.post('/mindspace/v1/uploads', async (req, res) => { if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req, { upload: true })) return; try { @@ -2874,6 +2887,7 @@ 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(); @@ -2993,6 +3007,58 @@ async function resolveChatSaveBundle(user, h5Root, input = {}) { }; } +async function registerChatDocxArtifactForConversation({ + user, + bundle, + requestBody = {}, + filename, + buffer, +}) { + 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; + try { + const selectedLinkIndex = Number(requestBody.selected_link_index ?? requestBody.selectedLinkIndex ?? 0); + const hash = crypto + .createHash('sha256') + .update(`${user.id}:${sessionId}:${messageId}:${selectedLinkIndex}:${filename}`) + .digest('hex') + .slice(0, 16); + const artifactId = `ca_docx_${hash}`; + const relativePath = `artifacts/${artifactId}/${filename}`; + const { packageRecord, writeResult } = + await mindSpaceConversationPackageRegistry.putObjectForSession({ + userId: user.id, + sessionId, + title: bundle?.source?.session?.name ?? null, + relativePath, + body: buffer, + }); + const canonicalUrl = + `/api/mindspace/v1/conversation-packages/${encodeURIComponent(sessionId)}` + + `/artifacts/${encodeURIComponent(artifactId)}/download`; + await mindSpaceConversationPackageRegistry.recordArtifact({ + id: artifactId, + packageId: packageRecord.id, + artifactKind: 'docx', + messageId, + displayName: filename, + mimeType: DOCX_MIME_TYPE, + sizeBytes: buffer.length, + storageKey: writeResult.key, + canonicalUrl, + sortOrder: Date.now(), + }); + await mindSpaceConversationPackageRegistry.writeManifestForSession({ + userId: user.id, + sessionId, + }); + } catch (error) { + console.warn('[MindSpace] failed to record chat docx artifact:', error?.message ?? error); + } +} + api.get('/mindspace/v1/pages/chat-save-preview', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { @@ -3177,7 +3243,14 @@ async function handleChatSaveDocx(req, res) { .replace(/^-+|-+$/g, '') .slice(0, 60) || 'mindspace-document'; const filename = `${filenameBase}.docx`; - res.set('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + await registerChatDocxArtifactForConversation({ + user: req.currentUser, + bundle, + requestBody: req.body, + filename, + buffer, + }); + res.set('Content-Type', DOCX_MIME_TYPE); res.set( 'Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"; filename*=UTF-8''${encodeURIComponent(filename)}`,