From 49127d867525b008d315ebcea865d39853f5e487 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 2 Jul 2026 21:58:14 +0800 Subject: [PATCH] Add conversation package manifest download --- mindspace-conversation-package-routes.mjs | 39 +++++++++++++ ...space-conversation-package-routes.test.mjs | 57 +++++++++++++++++++ server.mjs | 11 ++++ src/api/client.ts | 4 ++ src/components/ChatPanel.tsx | 17 +++++- 5 files changed, 127 insertions(+), 1 deletion(-) diff --git a/mindspace-conversation-package-routes.mjs b/mindspace-conversation-package-routes.mjs index 21c43dc..7ed02ae 100644 --- a/mindspace-conversation-package-routes.mjs +++ b/mindspace-conversation-package-routes.mjs @@ -50,6 +50,45 @@ export function createGetConversationPackageHandler({ }; } +export function createDownloadConversationPackageManifestHandler({ + registry, + getRegistry, + userAuth, + getUserAuth, + ensureMindSpaceEnabled = () => true, + mindSpaceError = defaultRouteError, +} = {}) { + return async function downloadConversationPackageManifest(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(); + if (!sessionId) return res.status(400).json({ message: '缺少 sessionId' }); + + try { + const activeUserAuth = resolveRegistry(getUserAuth ?? userAuth); + if (!(await activeUserAuth.ownsSession(req.currentUser.id, sessionId))) { + return res.status(403).json({ message: '无权访问该会话' }); + } + const manifest = await activeRegistry.readManifestForSession({ + userId: req.currentUser.id, + sessionId, + }); + if (!manifest) return res.status(404).json({ message: '对话包不存在' }); + const filename = `${manifest.packageId || sessionId}-manifest.json`; + res.set('Content-Type', 'application/json; charset=utf-8'); + 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(`${JSON.stringify(manifest, null, 2)}\n`); + } catch (error) { + return mindSpaceError(res, req, error); + } + }; +} + export function createDownloadConversationPackageArtifactHandler({ registry, getRegistry, diff --git a/mindspace-conversation-package-routes.test.mjs b/mindspace-conversation-package-routes.test.mjs index 16060e6..d1cdb4b 100644 --- a/mindspace-conversation-package-routes.test.mjs +++ b/mindspace-conversation-package-routes.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createDownloadConversationPackageArtifactHandler, + createDownloadConversationPackageManifestHandler, createGetConversationPackageHandler, } from './mindspace-conversation-package-routes.mjs'; @@ -125,6 +126,62 @@ test('GET conversation package respects MindSpace feature gates', async () => { assert.deepEqual(res.body, { message: 'MindSpace 已关闭' }); }); +test('GET conversation package manifest downloads owned package metadata', async () => { + const handler = createDownloadConversationPackageManifestHandler({ + registry: { + async readManifestForSession(input) { + assert.deepEqual(input, { userId: 'user-1', sessionId: 'session-1' }); + return { packageId: 'cp_session-1', sessionId: 'session-1', artifacts: [] }; + }, + }, + userAuth: { + async ownsSession(userId, sessionId) { + assert.equal(userId, 'user-1'); + assert.equal(sessionId, 'session-1'); + return true; + }, + }, + }); + const res = createResponseRecorder(); + + await handler( + { currentUser: { id: 'user-1' }, params: { sessionId: 'session-1' }, requestId: 'req-1' }, + res, + ); + + assert.equal(res.statusCode, 200); + assert.equal(res.headers['content-type'], 'application/json; charset=utf-8'); + assert.match(res.headers['content-disposition'], /cp_session-1-manifest\.json/); + assert.equal(res.headers['cache-control'], 'private, no-store'); + assert.equal(res.headers['x-request-id'], 'req-1'); + assert.deepEqual(JSON.parse(res.body), { + packageId: 'cp_session-1', + sessionId: 'session-1', + artifacts: [], + }); +}); + +test('GET conversation package manifest blocks unauthorized sessions', async () => { + const handler = createDownloadConversationPackageManifestHandler({ + registry: { + async readManifestForSession() { + assert.fail('registry should not be called for unauthorized manifest downloads'); + }, + }, + userAuth: { + async ownsSession() { + return false; + }, + }, + }); + const res = createResponseRecorder(); + + await handler({ currentUser: { id: 'user-1' }, params: { sessionId: 'session-2' } }, res); + + assert.equal(res.statusCode, 403); + assert.deepEqual(res.body, { message: '无权访问该会话' }); +}); + test('GET conversation package artifact downloads owned private objects', async () => { const handler = createDownloadConversationPackageArtifactHandler({ registry: { diff --git a/server.mjs b/server.mjs index 6e5d92a..32f38dd 100644 --- a/server.mjs +++ b/server.mjs @@ -45,6 +45,7 @@ import { createPageService, pageInternals, inlinePrivateAssetsInHtml, normalizeW import { createConversationPackageRegistry } from './mindspace-conversation-package-registry.mjs'; import { createDownloadConversationPackageArtifactHandler, + createDownloadConversationPackageManifestHandler, createGetConversationPackageHandler, } from './mindspace-conversation-package-routes.mjs'; import { createConversationPackageStore } from './mindspace-conversation-package-store.mjs'; @@ -2499,6 +2500,16 @@ api.get('/mindspace/v1/conversation-packages/:sessionId', createGetConversationP mindSpaceError, })); +api.get( + '/mindspace/v1/conversation-packages/:sessionId/manifest.json', + createDownloadConversationPackageManifestHandler({ + getRegistry: () => mindSpaceConversationPackageRegistry, + getUserAuth: () => userAuth, + ensureMindSpaceEnabled, + mindSpaceError, + }), +); + api.get( '/mindspace/v1/conversation-packages/:sessionId/artifacts/:artifactId/download', createDownloadConversationPackageArtifactHandler({ diff --git a/src/api/client.ts b/src/api/client.ts index 8d89e33..ec82a0e 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -649,6 +649,10 @@ export async function getMindSpaceConversationPackage( return result.data; } +export function buildMindSpaceConversationPackageManifestDownloadUrl(sessionId: string): string { + return `${API}/mindspace/v1/conversation-packages/${encodeURIComponent(sessionId)}/manifest.json`; +} + export async function ignoreMindSpaceScheduleReminder(reminderId: string) { const result = await apiFetch<{ data: MindSpaceScheduleReminder }>( `/mindspace/v1/schedule/reminders/${encodeURIComponent(reminderId)}/ignore`, diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index ce19275..2adb06b 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -4,7 +4,12 @@ import { openAvatarPicker } from '../utils/userAvatar'; import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills'; import { getMessageSaveActions } from '../utils/messageSave'; import { getDisplayText } from '../utils/message'; -import { ApiError, downloadChatMessageDocx, getMindSpaceConversationPackage } from '../api/client'; +import { + ApiError, + buildMindSpaceConversationPackageManifestDownloadUrl, + downloadChatMessageDocx, + getMindSpaceConversationPackage, +} from '../api/client'; import { CHAT_IMAGE_UPLOAD_MAX_COUNT, CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, @@ -822,6 +827,16 @@ export function ChatPanel({ + {session?.id && conversationPackage && ( + + 下载清单 + + )}