Record chat docx artifacts in packages
This commit is contained in:
@@ -15,6 +15,8 @@ export function createConversationPackageRegistry({ store, service } = {}) {
|
|||||||
requireMethod(store, 'listArtifacts', 'missing_conversation_package_store');
|
requireMethod(store, 'listArtifacts', 'missing_conversation_package_store');
|
||||||
requireMethod(service, 'putConversationManifest', 'missing_mindspace_service');
|
requireMethod(service, 'putConversationManifest', 'missing_mindspace_service');
|
||||||
requireMethod(service, 'buildConversationManifest', 'missing_mindspace_service');
|
requireMethod(service, 'buildConversationManifest', 'missing_mindspace_service');
|
||||||
|
requireMethod(service, 'putPackageObject', 'missing_mindspace_service');
|
||||||
|
requireMethod(service, 'getPackageObject', 'missing_mindspace_service');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async ensurePackage(input) {
|
async ensurePackage(input) {
|
||||||
@@ -32,12 +34,35 @@ export function createConversationPackageRegistry({ store, service } = {}) {
|
|||||||
return service.putConversationManifest({ packageRecord, artifacts });
|
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 }) {
|
async readManifestForSession({ userId, sessionId }) {
|
||||||
const packageRecord = await store.getPackageBySession(userId, sessionId);
|
const packageRecord = await store.getPackageBySession(userId, sessionId);
|
||||||
if (!packageRecord) return null;
|
if (!packageRecord) return null;
|
||||||
const artifacts = await store.listArtifacts(packageRecord.id);
|
const artifacts = await store.listArtifacts(packageRecord.id);
|
||||||
return service.buildConversationManifest({ packageRecord, artifacts });
|
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 };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
test('conversation package registry skips manifest writes for unknown sessions', async () => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-registry-'));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-registry-'));
|
||||||
const registry = createConversationPackageRegistry({
|
const registry = createConversationPackageRegistry({
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ function resolveRegistry(input) {
|
|||||||
return typeof input === 'function' ? input() : 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({
|
export function createGetConversationPackageHandler({
|
||||||
registry,
|
registry,
|
||||||
getRegistry,
|
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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { createGetConversationPackageHandler } from './mindspace-conversation-package-routes.mjs';
|
import {
|
||||||
|
createDownloadConversationPackageArtifactHandler,
|
||||||
|
createGetConversationPackageHandler,
|
||||||
|
} from './mindspace-conversation-package-routes.mjs';
|
||||||
|
|
||||||
function createResponseRecorder() {
|
function createResponseRecorder() {
|
||||||
return {
|
return {
|
||||||
@@ -16,6 +19,20 @@ function createResponseRecorder() {
|
|||||||
this.headersSent = true;
|
this.headersSent = true;
|
||||||
return this;
|
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.equal(res.statusCode, 403);
|
||||||
assert.deepEqual(res.body, { message: 'MindSpace 已关闭' });
|
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: '无权访问该会话' });
|
||||||
|
});
|
||||||
|
|||||||
+75
-2
@@ -43,7 +43,10 @@ import { ensureMindSpaceConfig } from './mindspace-config.mjs';
|
|||||||
import { createAssetService } from './mindspace-assets.mjs';
|
import { createAssetService } from './mindspace-assets.mjs';
|
||||||
import { createPageService, pageInternals, inlinePrivateAssetsInHtml, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
|
import { createPageService, pageInternals, inlinePrivateAssetsInHtml, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
|
||||||
import { createConversationPackageRegistry } from './mindspace-conversation-package-registry.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 { createConversationPackageStore } from './mindspace-conversation-package-store.mjs';
|
||||||
import { createMindSpaceServiceFacade } from './mindspace-service.mjs';
|
import { createMindSpaceServiceFacade } from './mindspace-service.mjs';
|
||||||
import { createLocalMindSpaceStorageAdapter } from './mindspace-storage-adapter.mjs';
|
import { createLocalMindSpaceStorageAdapter } from './mindspace-storage-adapter.mjs';
|
||||||
@@ -2496,6 +2499,16 @@ api.get('/mindspace/v1/conversation-packages/:sessionId', createGetConversationP
|
|||||||
mindSpaceError,
|
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) => {
|
api.post('/mindspace/v1/uploads', async (req, res) => {
|
||||||
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req, { upload: true })) return;
|
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req, { upload: true })) return;
|
||||||
try {
|
try {
|
||||||
@@ -2874,6 +2887,7 @@ async function resolveExistingSavedPage(userId, { sessionId, messageId, relative
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'public']);
|
const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'public']);
|
||||||
|
const DOCX_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||||
|
|
||||||
const pageSyncInFlight = new Map();
|
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) => {
|
api.get('/mindspace/v1/pages/chat-save-preview', async (req, res) => {
|
||||||
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||||||
try {
|
try {
|
||||||
@@ -3177,7 +3243,14 @@ async function handleChatSaveDocx(req, res) {
|
|||||||
.replace(/^-+|-+$/g, '')
|
.replace(/^-+|-+$/g, '')
|
||||||
.slice(0, 60) || 'mindspace-document';
|
.slice(0, 60) || 'mindspace-document';
|
||||||
const filename = `${filenameBase}.docx`;
|
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(
|
res.set(
|
||||||
'Content-Disposition',
|
'Content-Disposition',
|
||||||
`attachment; filename="${encodeURIComponent(filename)}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
`attachment; filename="${encodeURIComponent(filename)}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user