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 && (
+
+ 下载清单
+
+ )}