diff --git a/docs/memind-control-execution-split-plan-20260702.md b/docs/memind-control-execution-split-plan-20260702.md
index 4755fa5..46c2237 100644
--- a/docs/memind-control-execution-split-plan-20260702.md
+++ b/docs/memind-control-execution-split-plan-20260702.md
@@ -351,6 +351,27 @@ MindSpace Domain
- H5 增加 package view: 图片、文件、页面、公开链接按对话聚合展示。
- 对历史数据做 best-effort 回填。
+当前开发分支进展:
+
+- 已新增 conversation package 表/注册层/API/manifest 导出和 manifest 校验脚本。
+- H5 已增加“对话包”查看入口,隐藏内部 message UUID,只展示用户可理解的产物信息。
+- `public/*.html` 已接入 Finish 后登记和历史会话懒回填,可在 package 中看到公开页面。
+- 上传图片/文件已支持 `sessionId/messageId` provenance;全新会话首条图片上传会在 agent run 返回真实 `sessionId` 后按 `messageId` 后置认领进 package。
+- 公开页长图下载已登记为 `long_image` artifact,PNG 同步写入 conversation package 私有对象,canonical URL 保持公开页长图下载入口。
+- Finish 后 `public` workspace 同步已携带同轮 `messageId`,让生成文件、图片、docx companion 进入 package 时具备消息级 provenance。
+- 聊天 docx 下载已抽出 package 登记模块并加入测试,确保 docx 私有对象、artifact、manifest 刷新一致。
+- 发布入口已覆盖 chat-sourced page 的 publication companion 流程,确认 `public_html`、同目录 `docx`、`long_image` 会在真实 `publish()` 调用中进入同一 package。
+- H5 package view 已增加按产物类型筛选、计数、空包引导和加载失败重试提示。
+- H5 package view 已增加按消息维度筛选,用户可从某一轮请求反查对应产物,且不展示内部 message UUID。
+- Manifest 已包含 `messages[]` / `agentRuns[]` provenance 聚合,便于追溯某条消息或某次 agent run 产生了哪些 artifact。
+- 已增加 package manifest 一致性校验脚本,覆盖 artifact URL / storage object 的可读性。
+- 读取 package 前已增加历史会话 best-effort 扫描回填,覆盖 `h5_page_records`、`h5_publish_records`、`h5_upload_sessions` 中已有 `source_session_id` 的页面、发布页、companion 文件和已完成上传。
+- Package 读取前的 backfill/hydrate 编排已收拢进 `MindSpaceService` facade,`server.mjs` 路由层只负责鉴权后调用 facade。
+
+剩余开发项:
+
+- P2 当前分支实现已完成;进入提交前需保持 guard、build、全量测试和回归 smoke 通过。
+
验收:
- 一次对话中上传图片、生成 HTML、发布页面后,package 页面能看到所有产物。
diff --git a/mindspace-assets.mjs b/mindspace-assets.mjs
index 7fc24a3..e6779a2 100644
--- a/mindspace-assets.mjs
+++ b/mindspace-assets.mjs
@@ -759,6 +759,103 @@ export function createAssetService(pool, options = {}) {
}
};
+ const claimUploadArtifactsForConversation = async (userId, { sessionId, messageId } = {}) => {
+ const normalizedSessionId = String(sessionId ?? '').trim();
+ const normalizedMessageId = String(messageId ?? '').trim();
+ if (!normalizedSessionId || !normalizedMessageId) return { claimedCount: 0 };
+
+ const conn = await pool.getConnection();
+ try {
+ await conn.beginTransaction();
+ const [rows] = await conn.query(
+ `SELECT
+ u.*,
+ a.id AS asset_id,
+ a.mime_type AS asset_mime_type,
+ a.original_filename AS asset_original_filename,
+ a.display_name AS asset_display_name,
+ a.size_bytes AS asset_size_bytes,
+ a.updated_at AS asset_updated_at,
+ v.storage_key AS asset_storage_key,
+ c.category_code
+ FROM h5_upload_sessions u
+ JOIN h5_assets a
+ ON a.id = u.completed_asset_id AND a.user_id = u.user_id
+ LEFT JOIN h5_asset_versions v
+ ON v.id = a.current_version_id AND v.asset_id = a.id
+ LEFT JOIN h5_space_categories c
+ ON c.id = a.category_id
+ WHERE u.user_id = ?
+ AND u.source_message_id = ?
+ AND u.status = 'completed'
+ AND (u.source_session_id IS NULL OR u.source_session_id = '')
+ LIMIT 20
+ FOR UPDATE`,
+ [userId, normalizedMessageId],
+ );
+ if (rows.length === 0) {
+ await conn.commit();
+ return { claimedCount: 0 };
+ }
+ await conn.query(
+ `UPDATE h5_upload_sessions
+ SET source_session_id = ?
+ WHERE user_id = ?
+ AND source_message_id = ?
+ AND status = 'completed'
+ AND (source_session_id IS NULL OR source_session_id = '')`,
+ [normalizedSessionId, userId, normalizedMessageId],
+ );
+ await conn.commit();
+
+ const now = Date.now();
+ let claimedCount = 0;
+ for (const row of rows) {
+ const upload = {
+ ...row,
+ source_session_id: normalizedSessionId,
+ source_message_id: normalizedMessageId,
+ detected_mime_type: row.asset_mime_type,
+ actual_size: row.asset_size_bytes,
+ };
+ const asset = {
+ id: row.asset_id,
+ user_id: userId,
+ categoryCode: row.category_code,
+ mimeType: row.asset_mime_type,
+ filename: row.asset_original_filename,
+ displayName: row.asset_display_name,
+ sizeBytes: asNumber(row.asset_size_bytes),
+ updatedAt: asNumber(row.asset_updated_at),
+ };
+ const artifact = await registerUploadArtifactForConversation({
+ registry: conversationPackageRegistry,
+ userId,
+ upload,
+ asset,
+ assetId: row.asset_id,
+ storageKey: row.asset_storage_key,
+ canonicalUrl: publicUrlForAsset({
+ id: row.asset_id,
+ user_id: userId,
+ category_code: row.category_code,
+ mime_type: row.asset_mime_type,
+ storage_key: row.asset_storage_key,
+ original_filename: row.asset_original_filename,
+ }),
+ now,
+ });
+ if (artifact) claimedCount += 1;
+ }
+ return { claimedCount };
+ } catch (error) {
+ await conn.rollback();
+ throw error;
+ } finally {
+ conn.release();
+ }
+ };
+
const cancelUpload = async (userId, uploadId) => {
const conn = await pool.getConnection();
let storageKey;
@@ -1278,6 +1375,7 @@ export function createAssetService(pool, options = {}) {
createUpload,
writeUploadContent,
completeUpload,
+ claimUploadArtifactsForConversation,
cancelUpload,
createChatAsset,
listAssets,
diff --git a/mindspace-assets.test.mjs b/mindspace-assets.test.mjs
index 9bff418..325f1a2 100644
--- a/mindspace-assets.test.mjs
+++ b/mindspace-assets.test.mjs
@@ -67,6 +67,33 @@ function createMockPool(state) {
return [[]];
}
if (sql.includes('FROM h5_upload_sessions u') && sql.includes('FOR UPDATE')) {
+ if (sql.includes('u.source_message_id = ?')) {
+ const rows = state.uploads
+ .filter(
+ (item) =>
+ item.user_id === params[0] &&
+ item.source_message_id === params[1] &&
+ item.status === 'completed' &&
+ !item.source_session_id,
+ )
+ .map((upload) => {
+ const asset = state.assets.find((item) => item.id === upload.completed_asset_id);
+ const version = state.versions.find((item) => item.id === asset?.current_version_id);
+ const category = state.categories.find((item) => item.id === asset?.category_id);
+ return {
+ ...upload,
+ asset_id: asset.id,
+ asset_mime_type: asset.mime_type,
+ asset_original_filename: asset.original_filename,
+ asset_display_name: asset.display_name,
+ asset_size_bytes: asset.size_bytes,
+ asset_updated_at: asset.updated_at ?? 1,
+ asset_storage_key: version.storage_key,
+ category_code: category?.category_code ?? 'public',
+ };
+ });
+ return [rows];
+ }
const upload = state.uploads.find(
(item) => item.id === params[0] && item.user_id === params[1],
);
@@ -78,10 +105,12 @@ function createMockPool(state) {
state.assets.push({
id: params[0],
user_id: params[1],
+ category_id: params[3],
status: params[13],
risk_level: params[11],
current_version_id: params[8],
original_filename: params[6],
+ display_name: params[7],
mime_type: params[5],
size_bytes: params[9],
});
@@ -102,7 +131,7 @@ function createMockPool(state) {
space.used_bytes += params[1];
return [[]];
}
- if (sql.includes("status = 'completed'")) {
+ if (sql.includes("SET status = 'completed'")) {
const upload = state.uploads.find(
(item) => item.id === params[2] && item.user_id === params[3],
);
@@ -111,6 +140,33 @@ function createMockPool(state) {
return [[]];
}
if (sql.includes('JOIN h5_asset_versions v')) {
+ if (sql.includes('u.source_message_id = ?')) {
+ const rows = state.uploads
+ .filter(
+ (item) =>
+ item.user_id === params[0] &&
+ item.source_message_id === params[1] &&
+ item.status === 'completed' &&
+ !item.source_session_id,
+ )
+ .map((upload) => {
+ const asset = state.assets.find((item) => item.id === upload.completed_asset_id);
+ const version = state.versions.find((item) => item.id === asset?.current_version_id);
+ const category = state.categories.find((item) => item.id === asset?.category_id);
+ return {
+ ...upload,
+ asset_id: asset.id,
+ asset_mime_type: asset.mime_type,
+ asset_original_filename: asset.original_filename,
+ asset_display_name: asset.display_name,
+ asset_size_bytes: asset.size_bytes,
+ asset_updated_at: asset.updated_at ?? 1,
+ asset_storage_key: version.storage_key,
+ category_code: category?.category_code ?? 'public',
+ };
+ });
+ return [rows];
+ }
const asset = state.assets.find(
(item) => item.id === params[0] && item.user_id === params[1],
);
@@ -134,6 +190,20 @@ function createMockPool(state) {
},
]];
}
+ if (sql.includes('SET source_session_id = ?')) {
+ state.uploads
+ .filter(
+ (item) =>
+ item.user_id === params[1] &&
+ item.source_message_id === params[2] &&
+ item.status === 'completed' &&
+ !item.source_session_id,
+ )
+ .forEach((item) => {
+ item.source_session_id = params[0];
+ });
+ return [[]];
+ }
if (sql.includes("status IN ('reserved', 'uploaded') AND expires_at")) {
return [state.uploads.filter((item) => item.expires_at <= params[0])];
}
@@ -341,6 +411,78 @@ test('registerUploadArtifactForConversation skips uploads without source session
);
});
+test('claimUploadArtifactsForConversation attaches pre-session uploads to package', async () => {
+ const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-assets-'));
+ const calls = [];
+ const state = {
+ categories: [{ id: 'cat-public', user_id: 'user-1', space_id: 'space-1', category_code: 'public' }],
+ spaces: [{ id: 'space-1', user_id: 'user-1', quota_bytes: 1000, used_bytes: 0, reserved_bytes: 0 }],
+ uploads: [
+ {
+ id: 'upload-1',
+ user_id: 'user-1',
+ space_id: 'space-1',
+ category_id: 'cat-public',
+ filename: 'first.png',
+ status: 'completed',
+ completed_asset_id: 'asset-1',
+ source_session_id: null,
+ source_message_id: 'message-1',
+ },
+ ],
+ assets: [
+ {
+ id: 'asset-1',
+ user_id: 'user-1',
+ category_id: 'cat-public',
+ current_version_id: 'version-1',
+ original_filename: 'users/user-1/public/images/first.png',
+ display_name: 'first.png',
+ mime_type: 'image/png',
+ size_bytes: 123,
+ updated_at: 1000,
+ },
+ ],
+ versions: [
+ {
+ id: 'version-1',
+ asset_id: 'asset-1',
+ storage_key: 'users/user-1/public/images/first.png',
+ scan_status: 'passed',
+ },
+ ],
+ };
+ const service = createAssetService(createMockPool(state), {
+ storageRoot,
+ conversationPackageRegistry: {
+ async ensurePackage(input) {
+ calls.push(['ensurePackage', input]);
+ return { id: 'cp_session-1' };
+ },
+ async recordArtifact(input) {
+ calls.push(['recordArtifact', input]);
+ return input;
+ },
+ async writeManifestForSession(input) {
+ calls.push(['writeManifestForSession', input]);
+ return {};
+ },
+ },
+ });
+
+ const result = await service.claimUploadArtifactsForConversation('user-1', {
+ sessionId: 'session-1',
+ messageId: 'message-1',
+ });
+
+ assert.equal(result.claimedCount, 1);
+ assert.equal(state.uploads[0].source_session_id, 'session-1');
+ assert.deepEqual(calls[0], ['ensurePackage', { userId: 'user-1', sessionId: 'session-1', now: calls[0][1].now }]);
+ assert.equal(calls[1][1].artifactKind, 'input_image');
+ assert.equal(calls[1][1].messageId, 'message-1');
+ assert.equal(calls[1][1].assetId, 'asset-1');
+});
+
test('completeUpload publishes public images to the public temp image library', async () => {
const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-assets-'));
const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-h5-'));
diff --git a/mindspace-chat-docx-package.mjs b/mindspace-chat-docx-package.mjs
new file mode 100644
index 0000000..805da02
--- /dev/null
+++ b/mindspace-chat-docx-package.mjs
@@ -0,0 +1,54 @@
+import crypto from 'node:crypto';
+
+export const DOCX_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
+
+export async function registerChatDocxArtifactForConversation({
+ registry,
+ user,
+ bundle,
+ requestBody = {},
+ filename,
+ buffer,
+ now = Date.now(),
+}) {
+ if (!registry || !user?.id || !filename || !Buffer.isBuffer(buffer)) return null;
+ const sessionId = String(requestBody.session_id ?? requestBody.sessionId ?? '').trim();
+ const messageId = String(requestBody.message_id ?? requestBody.messageId ?? '').trim();
+ if (!sessionId || !messageId) return null;
+ 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 registry.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`;
+ const artifact = await registry.recordArtifact({
+ id: artifactId,
+ packageId: packageRecord.id,
+ artifactKind: 'docx',
+ messageId,
+ displayName: filename,
+ mimeType: DOCX_MIME_TYPE,
+ sizeBytes: buffer.length,
+ storageKey: writeResult.key,
+ canonicalUrl,
+ sortOrder: now,
+ now,
+ });
+ await registry.writeManifestForSession({
+ userId: user.id,
+ sessionId,
+ });
+ return artifact;
+}
diff --git a/mindspace-chat-docx-package.test.mjs b/mindspace-chat-docx-package.test.mjs
new file mode 100644
index 0000000..10c0ed2
--- /dev/null
+++ b/mindspace-chat-docx-package.test.mjs
@@ -0,0 +1,83 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ DOCX_MIME_TYPE,
+ registerChatDocxArtifactForConversation,
+} from './mindspace-chat-docx-package.mjs';
+
+test('registerChatDocxArtifactForConversation writes docx object and artifact manifest entry', async () => {
+ const calls = [];
+ const buffer = Buffer.from('docx-body');
+ const registry = {
+ async putObjectForSession(input) {
+ calls.push(['putObjectForSession', input]);
+ return {
+ packageRecord: { id: 'cp_session-1' },
+ writeResult: {
+ key: `users/user-1/conversations/session-1/${input.relativePath}`,
+ },
+ };
+ },
+ async recordArtifact(input) {
+ calls.push(['recordArtifact', input]);
+ return input;
+ },
+ async writeManifestForSession(input) {
+ calls.push(['writeManifestForSession', input]);
+ return input;
+ },
+ };
+
+ const artifact = await registerChatDocxArtifactForConversation({
+ registry,
+ user: { id: 'user-1' },
+ bundle: { source: { session: { name: 'Docx session' } } },
+ requestBody: {
+ session_id: 'session-1',
+ message_id: 'message-1',
+ selected_link_index: 2,
+ },
+ filename: '研究报告.docx',
+ buffer,
+ now: 1000,
+ });
+
+ assert.equal(artifact.artifactKind, 'docx');
+ assert.equal(artifact.messageId, 'message-1');
+ assert.equal(artifact.mimeType, DOCX_MIME_TYPE);
+ assert.equal(artifact.sizeBytes, buffer.length);
+ assert.match(artifact.id, /^ca_docx_[a-f0-9]{16}$/);
+ assert.match(
+ artifact.canonicalUrl,
+ /^\/api\/mindspace\/v1\/conversation-packages\/session-1\/artifacts\/ca_docx_[a-f0-9]{16}\/download$/,
+ );
+ assert.deepEqual(calls[0][1], {
+ userId: 'user-1',
+ sessionId: 'session-1',
+ title: 'Docx session',
+ relativePath: `artifacts/${artifact.id}/研究报告.docx`,
+ body: buffer,
+ });
+ assert.deepEqual(calls.at(-1), [
+ 'writeManifestForSession',
+ { userId: 'user-1', sessionId: 'session-1' },
+ ]);
+});
+
+test('registerChatDocxArtifactForConversation skips missing session or message source', async () => {
+ const registry = {
+ async putObjectForSession() {
+ assert.fail('should not write object without source');
+ },
+ };
+ assert.equal(
+ await registerChatDocxArtifactForConversation({
+ registry,
+ user: { id: 'user-1' },
+ requestBody: { session_id: 'session-1' },
+ filename: 'report.docx',
+ buffer: Buffer.from('docx'),
+ }),
+ null,
+ );
+});
diff --git a/mindspace-conversation-package-backfill.mjs b/mindspace-conversation-package-backfill.mjs
new file mode 100644
index 0000000..19ef04b
--- /dev/null
+++ b/mindspace-conversation-package-backfill.mjs
@@ -0,0 +1,157 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { assetInternals } from './mindspace-assets.mjs';
+import { pageInternals } from './mindspace-pages.mjs';
+import { publicationInternals } from './mindspace-publications.mjs';
+import { resolvePublishDir } from './user-publish.mjs';
+
+const BACKFILL_LIMIT = 200;
+
+function asNumber(value) {
+ const number = Number(value);
+ return Number.isFinite(number) ? number : null;
+}
+
+async function readUtf8IfExists(storageRoot, storageKey) {
+ if (!storageRoot || !storageKey) return '';
+ try {
+ return await fs.readFile(path.resolve(storageRoot, storageKey), 'utf8');
+ } catch {
+ return '';
+ }
+}
+
+export async function backfillConversationPackageArtifacts({
+ pool,
+ registry,
+ storageRoot,
+ h5Root,
+ user,
+ sessionId,
+ now = Date.now(),
+} = {}) {
+ const userId = String(user?.id ?? '').trim();
+ const normalizedSessionId = String(sessionId ?? '').trim();
+ if (!pool || !registry || !userId || !normalizedSessionId) {
+ return { pages: 0, publications: 0, uploads: 0 };
+ }
+
+ let pages = 0;
+ const [pageRows] = await pool.query(
+ `SELECT p.id, p.title, p.source_message_id, p.current_version_id,
+ pv.version_no, pv.content_asset_id,
+ av.mime_type, av.size_bytes, av.storage_key
+ FROM h5_page_records p
+ LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id AND pv.page_id = p.id
+ LEFT JOIN h5_asset_versions av ON av.asset_id = pv.content_asset_id AND av.version_no = 1
+ WHERE p.user_id = ? AND p.source_session_id = ? AND p.status <> 'deleted'
+ ORDER BY p.updated_at DESC
+ LIMIT ?`,
+ [userId, normalizedSessionId, BACKFILL_LIMIT],
+ );
+ for (const row of pageRows) {
+ const artifact = await pageInternals.registerPageArtifactForConversation({
+ registry,
+ userId,
+ source: { sessionId: normalizedSessionId, messageId: row.source_message_id ?? null },
+ page: {
+ id: row.id,
+ title: row.title,
+ currentVersionId: row.current_version_id,
+ sourceMessageId: row.source_message_id ?? null,
+ },
+ contentAssetId: row.content_asset_id ?? null,
+ contentMimeType: row.mime_type ?? null,
+ contentBytes: asNumber(row.size_bytes),
+ storageKey: row.storage_key ?? null,
+ versionNo: row.version_no,
+ now,
+ });
+ if (artifact) pages += 1;
+ }
+
+ let publications = 0;
+ const [publicationRows] = await pool.query(
+ `SELECT pr.id, pr.page_id, pr.public_url, pr.published_at,
+ p.title, p.source_message_id,
+ pv.bundle_asset_id,
+ JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) AS source_relative_path,
+ av.storage_key, av.size_bytes
+ FROM h5_publish_records pr
+ JOIN h5_page_records p ON p.id = pr.page_id AND p.user_id = pr.user_id
+ JOIN h5_page_versions pv ON pv.id = pr.page_version_id AND pv.page_id = p.id
+ LEFT JOIN h5_asset_versions av ON av.asset_id = pv.bundle_asset_id AND av.version_no = 1
+ WHERE pr.user_id = ? AND p.source_session_id = ? AND pr.status IN ('online', 'offline')
+ ORDER BY pr.published_at DESC
+ LIMIT ?`,
+ [userId, normalizedSessionId, BACKFILL_LIMIT],
+ );
+ const publishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null;
+ for (const row of publicationRows) {
+ const artifact = await publicationInternals.registerPublicationArtifactForConversation({
+ registry,
+ userId,
+ page: {
+ page_id: row.page_id,
+ title: row.title,
+ source_session_id: normalizedSessionId,
+ source_message_id: row.source_message_id ?? null,
+ source_relative_path: row.source_relative_path ?? null,
+ },
+ publication: {
+ id: row.id,
+ publicUrl: row.public_url,
+ publishedAt: row.published_at,
+ },
+ bundleAssetId: row.bundle_asset_id ?? null,
+ storageKey: row.storage_key ?? null,
+ htmlBytes: asNumber(row.size_bytes),
+ html: await readUtf8IfExists(storageRoot, row.storage_key),
+ publishDir,
+ now,
+ });
+ if (artifact) publications += 1;
+ }
+
+ let uploads = 0;
+ const [uploadRows] = await pool.query(
+ `SELECT u.id, u.filename, u.source_message_id, u.completed_asset_id,
+ u.detected_mime_type, u.actual_size,
+ a.mime_type, a.original_filename, a.display_name, a.size_bytes,
+ v.storage_key
+ FROM h5_upload_sessions u
+ JOIN h5_assets a ON a.id = u.completed_asset_id AND a.user_id = u.user_id
+ LEFT JOIN h5_asset_versions v ON v.id = a.current_version_id AND v.asset_id = a.id
+ WHERE u.user_id = ? AND u.source_session_id = ? AND u.status = 'completed'
+ ORDER BY COALESCE(u.completed_at, u.created_at) DESC
+ LIMIT ?`,
+ [userId, normalizedSessionId, BACKFILL_LIMIT],
+ );
+ for (const row of uploadRows) {
+ const artifact = await assetInternals.registerUploadArtifactForConversation({
+ registry,
+ userId,
+ upload: {
+ filename: row.filename,
+ source_session_id: normalizedSessionId,
+ source_message_id: row.source_message_id ?? null,
+ detected_mime_type: row.detected_mime_type ?? row.mime_type ?? null,
+ actual_size: row.actual_size ?? row.size_bytes ?? null,
+ },
+ asset: {
+ id: row.completed_asset_id,
+ mimeType: row.mime_type ?? row.detected_mime_type ?? null,
+ filename: row.original_filename ?? row.filename,
+ displayName: row.display_name ?? row.filename,
+ sizeBytes: asNumber(row.size_bytes ?? row.actual_size),
+ },
+ assetId: row.completed_asset_id,
+ storageKey: row.storage_key ?? null,
+ canonicalUrl: `/api/mindspace/v1/assets/${encodeURIComponent(row.completed_asset_id)}/download`,
+ now,
+ });
+ if (artifact) uploads += 1;
+ }
+
+ return { pages, publications, uploads };
+}
diff --git a/mindspace-conversation-package-backfill.test.mjs b/mindspace-conversation-package-backfill.test.mjs
new file mode 100644
index 0000000..1ec4331
--- /dev/null
+++ b/mindspace-conversation-package-backfill.test.mjs
@@ -0,0 +1,111 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import test from 'node:test';
+import { backfillConversationPackageArtifacts } from './mindspace-conversation-package-backfill.mjs';
+
+test('backfillConversationPackageArtifacts registers pages publications and completed uploads', async () => {
+ const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'conversation-backfill-storage-'));
+ const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'conversation-backfill-h5-'));
+ const publicationStorageKey = 'users/user-1/publications/pub-1/index.html';
+ await fs.mkdir(path.dirname(path.join(storageRoot, publicationStorageKey)), { recursive: true });
+ await fs.writeFile(path.join(storageRoot, publicationStorageKey), '
Published');
+ await fs.mkdir(path.join(h5Root, 'MindSpace', 'user-1', 'public'), { recursive: true });
+ await fs.writeFile(path.join(h5Root, 'MindSpace', 'user-1', 'public', 'published.docx'), 'docx');
+ const calls = [];
+ const registry = {
+ async ensurePackage(input) {
+ calls.push(['ensurePackage', input]);
+ return { id: 'cp-session-1' };
+ },
+ async recordArtifact(input) {
+ calls.push(['recordArtifact', input]);
+ return input;
+ },
+ async writeManifestForSession(input) {
+ calls.push(['writeManifestForSession', input]);
+ return input;
+ },
+ };
+ const pool = {
+ async query(sql, params) {
+ assert.equal(params[0], 'user-1');
+ assert.equal(params[1], 'session-1');
+ if (sql.includes('FROM h5_page_records p') && sql.includes('LEFT JOIN h5_page_versions')) {
+ return [[
+ {
+ id: 'page-1',
+ title: 'Draft page',
+ source_message_id: 'message-1',
+ current_version_id: 'version-1',
+ version_no: 3,
+ content_asset_id: 'asset-page',
+ mime_type: 'text/html',
+ size_bytes: 456,
+ storage_key: 'users/user-1/pages/page-1/versions/v1.html',
+ },
+ ]];
+ }
+ if (sql.includes('FROM h5_publish_records pr')) {
+ return [[
+ {
+ id: 'pub-1',
+ page_id: 'page-1',
+ public_url: 'https://m.tkmind.cn/u/john/pages/published',
+ published_at: 3000,
+ title: 'Published',
+ source_message_id: 'message-1',
+ bundle_asset_id: 'asset-pub',
+ source_relative_path: 'public/published.html',
+ storage_key: publicationStorageKey,
+ size_bytes: 789,
+ },
+ ]];
+ }
+ if (sql.includes('FROM h5_upload_sessions u')) {
+ return [[
+ {
+ id: 'upload-1',
+ filename: 'input.png',
+ source_message_id: 'message-2',
+ completed_asset_id: 'asset-upload',
+ detected_mime_type: 'image/png',
+ actual_size: 123,
+ mime_type: 'image/png',
+ original_filename: 'input.png',
+ display_name: 'input.png',
+ size_bytes: 123,
+ storage_key: 'users/user-1/public/images/input.png',
+ },
+ ]];
+ }
+ return [[]];
+ },
+ };
+
+ try {
+ const result = await backfillConversationPackageArtifacts({
+ pool,
+ registry,
+ storageRoot,
+ h5Root,
+ user: { id: 'user-1' },
+ sessionId: 'session-1',
+ now: 1000,
+ });
+
+ assert.deepEqual(result, { pages: 1, publications: 1, uploads: 1 });
+ const kinds = calls
+ .filter(([name]) => name === 'recordArtifact')
+ .map(([, input]) => input.artifactKind)
+ .sort();
+ assert.deepEqual(kinds, ['docx', 'input_image', 'page', 'public_html']);
+ const upload = calls.find(([, input]) => input?.artifactKind === 'input_image')?.[1];
+ assert.equal(upload.messageId, 'message-2');
+ assert.equal(upload.canonicalUrl, '/api/mindspace/v1/assets/asset-upload/download');
+ } finally {
+ await fs.rm(storageRoot, { recursive: true, force: true });
+ await fs.rm(h5Root, { recursive: true, force: true });
+ }
+});
diff --git a/mindspace-conversation-package-routes.mjs b/mindspace-conversation-package-routes.mjs
index 7ed02ae..b6780bb 100644
--- a/mindspace-conversation-package-routes.mjs
+++ b/mindspace-conversation-package-routes.mjs
@@ -23,6 +23,7 @@ export function createGetConversationPackageHandler({
ensureMindSpaceEnabled = () => true,
sendData = defaultSendData,
mindSpaceError = defaultRouteError,
+ beforeReadManifest,
} = {}) {
return async function getConversationPackage(req, res) {
const activeRegistry = resolveRegistry(getRegistry ?? registry);
@@ -38,6 +39,9 @@ export function createGetConversationPackageHandler({
if (!(await activeUserAuth.ownsSession(req.currentUser.id, sessionId))) {
return res.status(403).json({ message: '无权访问该会话' });
}
+ if (typeof beforeReadManifest === 'function') {
+ await beforeReadManifest({ req, userId: req.currentUser.id, sessionId });
+ }
const manifest = await activeRegistry.readManifestForSession({
userId: req.currentUser.id,
sessionId,
@@ -57,6 +61,7 @@ export function createDownloadConversationPackageManifestHandler({
getUserAuth,
ensureMindSpaceEnabled = () => true,
mindSpaceError = defaultRouteError,
+ beforeReadManifest,
} = {}) {
return async function downloadConversationPackageManifest(req, res) {
const activeRegistry = resolveRegistry(getRegistry ?? registry);
@@ -72,6 +77,9 @@ export function createDownloadConversationPackageManifestHandler({
if (!(await activeUserAuth.ownsSession(req.currentUser.id, sessionId))) {
return res.status(403).json({ message: '无权访问该会话' });
}
+ if (typeof beforeReadManifest === 'function') {
+ await beforeReadManifest({ req, userId: req.currentUser.id, sessionId });
+ }
const manifest = await activeRegistry.readManifestForSession({
userId: req.currentUser.id,
sessionId,
diff --git a/mindspace-conversation-package-routes.test.mjs b/mindspace-conversation-package-routes.test.mjs
index d1cdb4b..43d342d 100644
--- a/mindspace-conversation-package-routes.test.mjs
+++ b/mindspace-conversation-package-routes.test.mjs
@@ -38,6 +38,7 @@ function createResponseRecorder() {
}
test('GET conversation package returns manifest for owned sessions', async () => {
+ const hookCalls = [];
const handler = createGetConversationPackageHandler({
registry: {
async readManifestForSession(input) {
@@ -52,15 +53,24 @@ test('GET conversation package returns manifest for owned sessions', async () =>
return true;
},
},
+ beforeReadManifest(input) {
+ hookCalls.push({
+ userId: input.userId,
+ sessionId: input.sessionId,
+ req: input.req,
+ });
+ },
});
+ const req = { currentUser: { id: 'user-1' }, params: { sessionId: 'session-1' } };
const res = createResponseRecorder();
- await handler({ currentUser: { id: 'user-1' }, params: { sessionId: 'session-1' } }, res);
+ await handler(req, res);
assert.equal(res.statusCode, 200);
assert.deepEqual(res.body, {
data: { packageId: 'cp_session-1', artifacts: [{ artifactId: 'ca_1', kind: 'public_html' }] },
});
+ assert.deepEqual(hookCalls, [{ userId: 'user-1', sessionId: 'session-1', req }]);
});
test('GET conversation package blocks sessions owned by another user', async () => {
@@ -75,6 +85,9 @@ test('GET conversation package blocks sessions owned by another user', async ()
return false;
},
},
+ beforeReadManifest() {
+ assert.fail('beforeReadManifest should not run for unauthorized sessions');
+ },
});
const res = createResponseRecorder();
diff --git a/mindspace-conversation-package-verify.mjs b/mindspace-conversation-package-verify.mjs
new file mode 100644
index 0000000..f1f505f
--- /dev/null
+++ b/mindspace-conversation-package-verify.mjs
@@ -0,0 +1,83 @@
+function issue(code, message, details = {}) {
+ return { code, message, ...details };
+}
+
+function isReadableUrl(value) {
+ const text = String(value ?? '').trim();
+ return (
+ text.startsWith('http://') ||
+ text.startsWith('https://') ||
+ text.startsWith('/api/') ||
+ text.startsWith('/MindSpace/')
+ );
+}
+
+function normalizeStorageKey(value) {
+ return String(value ?? '').trim().replace(/^\/+/, '');
+}
+
+export async function verifyConversationPackageManifest(manifest, options = {}) {
+ const issues = [];
+ const statObject = typeof options.statObject === 'function' ? options.statObject : null;
+ const packageId = String(manifest?.packageId ?? '').trim();
+ const storagePrefix = normalizeStorageKey(manifest?.storagePrefix);
+ const artifacts = Array.isArray(manifest?.artifacts) ? manifest.artifacts : null;
+
+ if (!packageId) issues.push(issue('missing_package_id', 'manifest.packageId is required'));
+ if (!String(manifest?.userId ?? '').trim()) issues.push(issue('missing_user_id', 'manifest.userId is required'));
+ if (!String(manifest?.sessionId ?? '').trim()) {
+ issues.push(issue('missing_session_id', 'manifest.sessionId is required'));
+ }
+ if (!storagePrefix) issues.push(issue('missing_storage_prefix', 'manifest.storagePrefix is required'));
+ if (!artifacts) issues.push(issue('invalid_artifacts', 'manifest.artifacts must be an array'));
+
+ const seenArtifactIds = new Set();
+ for (const artifact of artifacts ?? []) {
+ const artifactId = String(artifact?.artifactId ?? '').trim();
+ if (!artifactId) {
+ issues.push(issue('missing_artifact_id', 'artifact.artifactId is required'));
+ continue;
+ }
+ if (seenArtifactIds.has(artifactId)) {
+ issues.push(issue('duplicate_artifact_id', `duplicate artifact id: ${artifactId}`, { artifactId }));
+ }
+ seenArtifactIds.add(artifactId);
+
+ const canonicalUrl = String(artifact?.canonicalUrl ?? '').trim();
+ const storageKey = normalizeStorageKey(artifact?.storageKey);
+ if (!canonicalUrl && !storageKey) {
+ issues.push(
+ issue('artifact_without_backing_reference', 'artifact must have canonicalUrl or storageKey', { artifactId }),
+ );
+ }
+ if (canonicalUrl && !isReadableUrl(canonicalUrl)) {
+ issues.push(issue('invalid_canonical_url', `artifact canonicalUrl is not readable: ${canonicalUrl}`, {
+ artifactId,
+ canonicalUrl,
+ }));
+ }
+ if (storageKey && storagePrefix && !storageKey.startsWith(`${storagePrefix}/`)) {
+ issues.push(issue('storage_key_outside_package', 'artifact storageKey is outside manifest.storagePrefix', {
+ artifactId,
+ storageKey,
+ storagePrefix,
+ }));
+ }
+ if (storageKey && statObject) {
+ const stat = await statObject(storageKey);
+ if (!stat?.exists) {
+ issues.push(issue('missing_backing_object', `artifact backing object is missing: ${storageKey}`, {
+ artifactId,
+ storageKey,
+ }));
+ }
+ }
+ }
+
+ return { ok: issues.length === 0, issues };
+}
+
+export const conversationPackageVerifyInternals = {
+ isReadableUrl,
+ normalizeStorageKey,
+};
diff --git a/mindspace-conversation-package-verify.test.mjs b/mindspace-conversation-package-verify.test.mjs
new file mode 100644
index 0000000..ebaf85b
--- /dev/null
+++ b/mindspace-conversation-package-verify.test.mjs
@@ -0,0 +1,86 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ conversationPackageVerifyInternals,
+ verifyConversationPackageManifest,
+} from './mindspace-conversation-package-verify.mjs';
+
+test('verifyConversationPackageManifest accepts readable urls and package storage objects', async () => {
+ const result = await verifyConversationPackageManifest(
+ {
+ packageId: 'cp_session-1',
+ userId: 'user-1',
+ sessionId: 'session-1',
+ storagePrefix: 'users/user-1/conversations/session-1',
+ artifacts: [
+ {
+ artifactId: 'ca_page',
+ kind: 'page',
+ canonicalUrl: '/api/mindspace/v1/pages/page-1/preview',
+ },
+ {
+ artifactId: 'ca_docx',
+ kind: 'docx',
+ storageKey: 'users/user-1/conversations/session-1/artifacts/ca_docx/report.docx',
+ },
+ ],
+ },
+ {
+ async statObject(key) {
+ return { key, exists: true };
+ },
+ },
+ );
+
+ assert.equal(result.ok, true);
+ assert.deepEqual(result.issues, []);
+});
+
+test('verifyConversationPackageManifest reports missing or unsafe artifact references', async () => {
+ const result = await verifyConversationPackageManifest(
+ {
+ packageId: 'cp_session-1',
+ userId: 'user-1',
+ sessionId: 'session-1',
+ storagePrefix: 'users/user-1/conversations/session-1',
+ artifacts: [
+ { artifactId: 'ca_missing', kind: 'generated_file' },
+ { artifactId: 'ca_missing', kind: 'generated_file', canonicalUrl: 'file:///tmp/secret.txt' },
+ {
+ artifactId: 'ca_escape',
+ kind: 'docx',
+ storageKey: 'users/user-1/other/session-1/report.docx',
+ },
+ {
+ artifactId: 'ca_gone',
+ kind: 'docx',
+ storageKey: 'users/user-1/conversations/session-1/report.docx',
+ },
+ ],
+ },
+ {
+ async statObject(key) {
+ return { key, exists: key !== 'users/user-1/conversations/session-1/report.docx' };
+ },
+ },
+ );
+
+ assert.equal(result.ok, false);
+ assert.deepEqual(
+ result.issues.map((item) => item.code),
+ [
+ 'artifact_without_backing_reference',
+ 'duplicate_artifact_id',
+ 'invalid_canonical_url',
+ 'storage_key_outside_package',
+ 'missing_backing_object',
+ ],
+ );
+});
+
+test('conversation package verify internals classify supported URLs', () => {
+ assert.equal(conversationPackageVerifyInternals.isReadableUrl('https://m.tkmind.cn/MindSpace/u/public/a.html'), true);
+ assert.equal(conversationPackageVerifyInternals.isReadableUrl('/api/mindspace/v1/assets/a/download'), true);
+ assert.equal(conversationPackageVerifyInternals.isReadableUrl('/MindSpace/u/public/a.html'), true);
+ assert.equal(conversationPackageVerifyInternals.isReadableUrl('file:///tmp/a.html'), false);
+});
diff --git a/mindspace-conversation-package.mjs b/mindspace-conversation-package.mjs
index d7432c3..83e7864 100644
--- a/mindspace-conversation-package.mjs
+++ b/mindspace-conversation-package.mjs
@@ -16,6 +16,9 @@ export const CONVERSATION_ARTIFACT_KINDS = Object.freeze([
'pdf',
]);
+const MYSQL_SIGNED_INT_MAX = 2147483647;
+const MYSQL_SIGNED_INT_MIN = -2147483648;
+
function requiredString(value, field) {
const normalized = String(value ?? '').trim();
if (!normalized) {
@@ -38,6 +41,18 @@ function optionalNumber(value) {
return Number.isFinite(number) ? number : null;
}
+function safeSortOrder(value) {
+ const number = Number(value);
+ if (!Number.isFinite(number)) return 0;
+ let normalized = Math.trunc(number);
+ if (normalized > MYSQL_SIGNED_INT_MAX && Math.abs(normalized) >= 1_000_000_000_000) {
+ normalized = Math.trunc(normalized / 1000);
+ }
+ if (normalized > MYSQL_SIGNED_INT_MAX) return MYSQL_SIGNED_INT_MAX;
+ if (normalized < MYSQL_SIGNED_INT_MIN) return MYSQL_SIGNED_INT_MIN;
+ return normalized;
+}
+
function safeStorageSegment(value, field) {
const normalized = requiredString(value, field);
const encoded = encodeURIComponent(normalized);
@@ -117,7 +132,7 @@ export function createConversationArtifact(input) {
sizeBytes: optionalNumber(input?.sizeBytes),
storageKey: optionalString(input?.storageKey),
canonicalUrl: optionalString(input?.canonicalUrl),
- sortOrder: Number.isFinite(Number(input?.sortOrder)) ? Number(input.sortOrder) : 0,
+ sortOrder: safeSortOrder(input?.sortOrder),
createdAt: Number.isFinite(Number(input?.createdAt)) ? Number(input.createdAt) : now,
};
}
@@ -163,6 +178,27 @@ function groupedManifestEntries(artifacts, keyField, idField) {
return [...groups.values()].sort((a, b) => a.createdAt - b.createdAt || a[idField].localeCompare(b[idField]));
}
+function provenanceManifestEntries(artifacts, keyField, idField) {
+ const groups = new Map();
+ for (const artifact of artifacts) {
+ const key = artifact[keyField];
+ if (!key) continue;
+ const existing = groups.get(key) ?? {
+ [idField]: key,
+ artifactIds: [],
+ kinds: [],
+ createdAt: artifact.createdAt,
+ updatedAt: artifact.createdAt,
+ };
+ existing.artifactIds.push(artifact.id);
+ if (!existing.kinds.includes(artifact.artifactKind)) existing.kinds.push(artifact.artifactKind);
+ existing.createdAt = Math.min(existing.createdAt, artifact.createdAt);
+ existing.updatedAt = Math.max(existing.updatedAt, artifact.createdAt);
+ groups.set(key, existing);
+ }
+ return [...groups.values()].sort((a, b) => a.createdAt - b.createdAt || a[idField].localeCompare(b[idField]));
+}
+
export function buildConversationPackageManifest({ packageRecord, artifacts = [] }) {
const record = createConversationPackageRecord(packageRecord);
const normalizedArtifacts = artifacts
@@ -184,6 +220,8 @@ export function buildConversationPackageManifest({ packageRecord, artifacts = []
artifacts: normalizedArtifacts.map(artifactManifestEntry),
pages: groupedManifestEntries(normalizedArtifacts, 'pageId', 'pageId'),
publications: groupedManifestEntries(normalizedArtifacts, 'publicationId', 'publicationId'),
+ messages: provenanceManifestEntries(normalizedArtifacts, 'messageId', 'messageId'),
+ agentRuns: provenanceManifestEntries(normalizedArtifacts, 'agentRunId', 'agentRunId'),
};
}
@@ -191,5 +229,7 @@ export const mindspaceConversationPackageInternals = {
requiredString,
optionalNumber,
optionalString,
+ provenanceManifestEntries,
safeStorageSegment,
+ safeSortOrder,
};
diff --git a/mindspace-conversation-package.test.mjs b/mindspace-conversation-package.test.mjs
index fafb35d..249cf4d 100644
--- a/mindspace-conversation-package.test.mjs
+++ b/mindspace-conversation-package.test.mjs
@@ -98,6 +98,24 @@ test('createConversationArtifact preserves unknown size as null', () => {
assert.equal(artifact.sizeBytes, null);
});
+test('createConversationArtifact keeps sort order inside MySQL INT range', () => {
+ const artifact = createConversationArtifact({
+ packageId: 'cp_1',
+ artifactKind: 'public_html',
+ sortOrder: 1783003000123,
+ now: 1000,
+ });
+ assert.equal(artifact.sortOrder, 1783003000);
+
+ const clamped = createConversationArtifact({
+ packageId: 'cp_1',
+ artifactKind: 'public_html',
+ sortOrder: Number.MAX_SAFE_INTEGER,
+ now: 1000,
+ });
+ assert.equal(clamped.sortOrder, 2147483647);
+});
+
test('buildConversationPackageManifest sorts artifacts and emits stable public shape', () => {
const manifest = buildConversationPackageManifest({
packageRecord: {
@@ -113,6 +131,8 @@ test('buildConversationPackageManifest sorts artifacts and emits stable public s
artifactKind: 'page',
displayName: '项目周报',
pageId: 'page-1',
+ messageId: 'msg-2',
+ agentRunId: 'run-1',
sortOrder: 2,
createdAt: 3000,
},
@@ -120,6 +140,7 @@ test('buildConversationPackageManifest sorts artifacts and emits stable public s
id: 'ca_first',
artifactKind: 'input_image',
displayName: 'cover.png',
+ messageId: 'msg-1',
sortOrder: 1,
createdAt: 2000,
},
@@ -129,6 +150,8 @@ test('buildConversationPackageManifest sorts artifacts and emits stable public s
displayName: 'report.html',
pageId: 'page-1',
publicationId: 'pub-1',
+ messageId: 'msg-2',
+ agentRunId: 'run-1',
canonicalUrl: 'https://m.tkmind.cn/MindSpace/user-1/public/report.html',
sortOrder: 3,
createdAt: 4000,
@@ -146,6 +169,7 @@ test('buildConversationPackageManifest sorts artifacts and emits stable public s
assert.deepEqual(manifest.artifacts[0], {
artifactId: 'ca_first',
kind: 'input_image',
+ messageId: 'msg-1',
displayName: 'cover.png',
sortOrder: 1,
createdAt: 2000,
@@ -170,4 +194,29 @@ test('buildConversationPackageManifest sorts artifacts and emits stable public s
updatedAt: 4000,
},
]);
+ assert.deepEqual(manifest.messages, [
+ {
+ messageId: 'msg-1',
+ artifactIds: ['ca_first'],
+ kinds: ['input_image'],
+ createdAt: 2000,
+ updatedAt: 2000,
+ },
+ {
+ messageId: 'msg-2',
+ artifactIds: ['ca_late', 'ca_public'],
+ kinds: ['page', 'public_html'],
+ createdAt: 3000,
+ updatedAt: 4000,
+ },
+ ]);
+ assert.deepEqual(manifest.agentRuns, [
+ {
+ agentRunId: 'run-1',
+ artifactIds: ['ca_late', 'ca_public'],
+ kinds: ['page', 'public_html'],
+ createdAt: 3000,
+ updatedAt: 4000,
+ },
+ ]);
});
diff --git a/mindspace-public-finish-sync.mjs b/mindspace-public-finish-sync.mjs
index a2ae5fc..d0d970b 100644
--- a/mindspace-public-finish-sync.mjs
+++ b/mindspace-public-finish-sync.mjs
@@ -11,6 +11,7 @@ import { DOWNLOADABLE_FILE_PATTERN } from './mindspace-html-download-links.mjs';
*/
const PUBLIC_HTML_PATH_PATTERN = /(?:^|[^a-z0-9_./-])(public\/[a-z0-9][a-z0-9._/-]{0,255}\.html)\b/i;
+const PUBLIC_HTML_PATH_PATTERN_GLOBAL = /(?:^|[^a-z0-9_./-])(public\/[a-z0-9][a-z0-9._/-]{0,255}\.html)\b/gi;
const PUBLIC_DOWNLOAD_ATTR_PATTERN = /(?:href|src)=["']([^"'#?\s]+)["']/gi;
const DEFAULT_DOWNLOAD_SYNC_MIN_SIZE = 1024;
@@ -181,6 +182,17 @@ function messageText(message) {
return [...textParts, displayText].filter(Boolean).join('\n');
}
+function messageId(message) {
+ const id =
+ message?.id ??
+ message?.message_id ??
+ message?.metadata?.messageId ??
+ message?.metadata?.message_id ??
+ null;
+ const normalized = String(id ?? '').trim();
+ return normalized || null;
+}
+
export function normalizePublicHtmlRelativePath(relativePath) {
const parts = String(relativePath ?? '')
.replace(/^\/+/, '')
@@ -192,6 +204,83 @@ export function normalizePublicHtmlRelativePath(relativePath) {
return parts.join('/');
}
+function publicHtmlFileExists(publishDir, relativePath) {
+ if (!publishDir) return false;
+ const root = path.resolve(String(publishDir ?? ''));
+ const normalized = normalizePublicHtmlRelativePath(relativePath);
+ if (!root || !normalized.toLowerCase().endsWith('.html')) return false;
+ const target = path.resolve(root, normalized);
+ if (target !== root && !target.startsWith(`${root}${path.sep}`)) return false;
+ return fs.existsSync(target) && fs.statSync(target).isFile();
+}
+
+export function collectOwnPublicHtmlRelativePaths({
+ messages,
+ currentUser,
+ publishDir,
+ materialized = [],
+ skipped = [],
+} = {}) {
+ const paths = new Set();
+ const add = (relativePath) => {
+ const normalized = normalizePublicHtmlRelativePath(relativePath);
+ if (normalized && publicHtmlFileExists(publishDir, normalized)) paths.add(normalized);
+ };
+ for (const relativePath of [...materialized, ...skipped]) add(relativePath);
+ for (const message of Array.isArray(messages) ? messages : []) {
+ const text = messageText(message);
+ if (!text) continue;
+ for (const link of extractStaticPageLinks(text, {
+ userId: currentUser?.id,
+ username: currentUser?.username ?? null,
+ })) {
+ add(link.relativePath);
+ }
+ for (const match of text.matchAll(PUBLIC_HTML_PATH_PATTERN_GLOBAL)) {
+ add(match[1]);
+ }
+ }
+ return [...paths].sort();
+}
+
+export function collectOwnPublicHtmlArtifactRefs({
+ messages,
+ currentUser,
+ publishDir,
+ materialized = [],
+ skipped = [],
+} = {}) {
+ const refs = new Map();
+ const add = (relativePath, sourceMessageId = null) => {
+ const normalized = normalizePublicHtmlRelativePath(relativePath);
+ if (!normalized || !publicHtmlFileExists(publishDir, normalized)) return;
+ const existing = refs.get(normalized);
+ refs.set(normalized, {
+ relativePath: normalized,
+ messageId: existing?.messageId ?? sourceMessageId ?? null,
+ });
+ };
+ for (const relativePath of [...materialized, ...skipped]) add(relativePath);
+ for (const artifact of extractPublicHtmlWriteArtifacts(messages, { publishDir })) {
+ add(artifact.relativePath, artifact.messageId ?? null);
+ }
+ for (const message of Array.isArray(messages) ? messages : []) {
+ const sourceMessageId = messageId(message);
+ const text = messageText(message);
+ if (!text) continue;
+ for (const link of extractStaticPageLinks(text, {
+ userId: currentUser?.id,
+ username: currentUser?.username ?? null,
+ })) {
+ add(link.relativePath, sourceMessageId);
+ }
+ for (const match of text.matchAll(PUBLIC_HTML_PATH_PATTERN_GLOBAL)) {
+ add(match[1], sourceMessageId);
+ }
+ }
+ return [...refs.values()].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
+}
+
function isWriteLikeHtmlTool(name, args) {
const action = String(args?.action ?? '').toLowerCase();
const normalizedName = String(name ?? '').trim();
@@ -241,6 +330,7 @@ function applyPublicHtmlEdit(existing, args) {
export function extractPublicHtmlWriteArtifacts(messages = [], { publishDir = null } = {}) {
const artifacts = new Map();
for (const message of messages) {
+ const sourceMessageId = messageId(message);
for (const item of message?.content ?? []) {
if (item?.type !== 'toolRequest') continue;
const toolCall = item.toolCall?.value;
@@ -262,7 +352,7 @@ export function extractPublicHtmlWriteArtifacts(messages = [], { publishDir = nu
content = args.new_str;
}
if (content == null) continue;
- artifacts.set(relativePath, { relativePath, content });
+ artifacts.set(relativePath, { relativePath, content, messageId: sourceMessageId });
}
}
return [...artifacts.values()];
@@ -386,6 +476,7 @@ export async function syncPublicHtmlAfterFinish({
currentUser,
publishDir,
syncWorkspaceAssets,
+ registerPublicHtmlArtifacts,
sessionId = null,
} = {}) {
if (!hasRecentOwnPublicHtmlReference(messages, currentUser, { publishDir })) {
@@ -394,10 +485,30 @@ export async function syncPublicHtmlAfterFinish({
const { materialized, skipped } = materializeMissingPublicHtmlWrites({ messages, publishDir });
const docxSync = syncPublicDocxDownloads({ publishDir });
+ const publicHtmlArtifactRefs = collectOwnPublicHtmlArtifactRefs({
+ messages,
+ currentUser,
+ publishDir,
+ materialized,
+ skipped,
+ });
+ const publicHtmlRelativePaths = publicHtmlArtifactRefs.map((ref) => ref.relativePath);
+ const sourceMessageId = publicHtmlArtifactRefs.find((ref) => ref.messageId)?.messageId ?? null;
let synced = false;
if (typeof syncWorkspaceAssets === 'function' && currentUser?.id) {
- await syncWorkspaceAssets(currentUser.id, { categoryCode: 'public', sourceSessionId: sessionId });
+ await syncWorkspaceAssets(currentUser.id, {
+ categoryCode: 'public',
+ sourceSessionId: sessionId,
+ sourceMessageId,
+ });
synced = true;
}
- return { materialized, skipped, synced, docxSync };
+ if (typeof registerPublicHtmlArtifacts === 'function' && currentUser?.id && sessionId) {
+ await registerPublicHtmlArtifacts(currentUser.id, {
+ sessionId,
+ relativePaths: publicHtmlRelativePaths,
+ artifactRefs: publicHtmlArtifactRefs,
+ });
+ }
+ return { materialized, skipped, synced, docxSync, publicHtmlRelativePaths, publicHtmlArtifactRefs };
}
diff --git a/mindspace-public-finish-sync.test.mjs b/mindspace-public-finish-sync.test.mjs
index 75b03e0..0ab490d 100644
--- a/mindspace-public-finish-sync.test.mjs
+++ b/mindspace-public-finish-sync.test.mjs
@@ -7,6 +7,8 @@ import path from 'node:path';
import {
hasRecentOwnPublicHtmlReference,
isStubPublicHtmlContent,
+ collectOwnPublicHtmlArtifactRefs,
+ collectOwnPublicHtmlRelativePaths,
materializeMissingPublicHtmlWrites,
materializePublicHtmlWritesFromSessionEvent,
syncPublicDocxDownloads,
@@ -123,6 +125,7 @@ test('materializeMissingPublicHtmlWrites writes html from sandbox write_file too
try {
const messages = [
{
+ id: 'msg-public-1',
role: 'assistant',
content: [
{
@@ -158,6 +161,7 @@ test('syncPublicHtmlAfterFinish forwards session source to workspace sync', asyn
const calls = [];
const messages = [
{
+ id: 'msg-public-1',
role: 'assistant',
content: [
{
@@ -186,7 +190,115 @@ test('syncPublicHtmlAfterFinish forwards session source to workspace sync', asyn
});
assert.equal(result.synced, true);
- assert.deepEqual(calls, [[CURRENT_USER.id, { categoryCode: 'public', sourceSessionId: 'session-1' }]]);
+ assert.deepEqual(calls, [[
+ CURRENT_USER.id,
+ {
+ categoryCode: 'public',
+ sourceSessionId: 'session-1',
+ sourceMessageId: 'msg-public-1',
+ },
+ ]]);
+ } finally {
+ fs.rmSync(publishDir, { recursive: true, force: true });
+ }
+});
+
+test('syncPublicHtmlAfterFinish registers existing public html artifacts for the session', async () => {
+ const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
+ try {
+ fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
+ fs.writeFileSync(path.join(publishDir, 'public/123.html'), '一二三');
+ const calls = [];
+ const messages = [
+ {
+ id: 'msg-public-1',
+ role: 'assistant',
+ content: [
+ {
+ type: 'text',
+ text: `[一二三](https://m.tkmind.cn/MindSpace/${CURRENT_USER.id}/public/123.html)`,
+ },
+ ],
+ },
+ ];
+
+ const result = await syncPublicHtmlAfterFinish({
+ messages,
+ currentUser: CURRENT_USER,
+ publishDir,
+ sessionId: 'session-1',
+ syncWorkspaceAssets: async () => {},
+ registerPublicHtmlArtifacts: async (...args) => {
+ calls.push(args);
+ },
+ });
+
+ assert.deepEqual(result.publicHtmlRelativePaths, ['public/123.html']);
+ assert.deepEqual(calls, [
+ [
+ CURRENT_USER.id,
+ {
+ sessionId: 'session-1',
+ relativePaths: ['public/123.html'],
+ artifactRefs: [{ relativePath: 'public/123.html', messageId: 'msg-public-1' }],
+ },
+ ],
+ ]);
+ } finally {
+ fs.rmSync(publishDir, { recursive: true, force: true });
+ }
+});
+
+test('collectOwnPublicHtmlArtifactRefs includes source message ids when available', () => {
+ const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
+ try {
+ fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
+ fs.writeFileSync(path.join(publishDir, 'public/own.html'), 'Own');
+ const refs = collectOwnPublicHtmlArtifactRefs({
+ publishDir,
+ currentUser: CURRENT_USER,
+ messages: [
+ {
+ id: 'assistant-msg-1',
+ content: [
+ {
+ type: 'text',
+ text: `https://m.tkmind.cn/MindSpace/${CURRENT_USER.id}/public/own.html`,
+ },
+ ],
+ },
+ ],
+ });
+ assert.deepEqual(refs, [{ relativePath: 'public/own.html', messageId: 'assistant-msg-1' }]);
+ } finally {
+ fs.rmSync(publishDir, { recursive: true, force: true });
+ }
+});
+
+test('collectOwnPublicHtmlRelativePaths ignores missing and other-user public html links', () => {
+ const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
+ try {
+ fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
+ fs.writeFileSync(path.join(publishDir, 'public/own.html'), 'Own');
+ const paths = collectOwnPublicHtmlRelativePaths({
+ publishDir,
+ currentUser: CURRENT_USER,
+ messages: [
+ {
+ content: [
+ {
+ type: 'text',
+ text: [
+ `https://m.tkmind.cn/MindSpace/${CURRENT_USER.id}/public/own.html`,
+ `https://m.tkmind.cn/MindSpace/${CURRENT_USER.id}/public/missing.html`,
+ 'https://m.tkmind.cn/MindSpace/other-user/public/other.html',
+ ].join('\n'),
+ },
+ ],
+ },
+ ],
+ });
+ assert.deepEqual(paths, ['public/own.html']);
} finally {
fs.rmSync(publishDir, { recursive: true, force: true });
}
diff --git a/mindspace-publications.mjs b/mindspace-publications.mjs
index 8da1b12..3045e1b 100644
--- a/mindspace-publications.mjs
+++ b/mindspace-publications.mjs
@@ -980,6 +980,13 @@ export function createPublicationService(pool, options = {}) {
return {
html: await refreshPublishedHtmlDownloadLinks(row, html),
publication: publicationResponse({ ...row, view_count: Number(row.view_count) + 1 }),
+ ownerId: row.owner_id,
+ pageSource: {
+ pageId: row.page_id,
+ title: row.title,
+ sourceSessionId: row.source_session_id ?? null,
+ sourceMessageId: row.source_message_id ?? null,
+ },
};
};
@@ -1005,7 +1012,7 @@ export function createPublicationService(pool, options = {}) {
const resolvePublic = async (ownerSlug, urlSlug, viewerId, password, requestMeta) => {
const [rows] = await pool.query(
- `SELECT pr.*, u.id AS owner_id, p.title, av.storage_key
+ `SELECT pr.*, u.id AS owner_id, p.title, p.source_session_id, p.source_message_id, av.storage_key
FROM h5_publish_records pr
JOIN h5_users u ON u.id = pr.user_id
JOIN h5_page_records p ON p.id = pr.page_id
@@ -1022,7 +1029,7 @@ export function createPublicationService(pool, options = {}) {
const resolvePrivateLink = async (token, viewerId, requestMeta) => {
const tokenHash = crypto.createHash('sha256').update(String(token)).digest('hex');
const [rows] = await pool.query(
- `SELECT pr.*, u.id AS owner_id, p.title, av.storage_key
+ `SELECT pr.*, u.id AS owner_id, p.title, p.source_session_id, p.source_message_id, av.storage_key
FROM h5_publish_records pr
JOIN h5_users u ON u.id = pr.user_id
JOIN h5_page_records p ON p.id = pr.page_id
diff --git a/mindspace-publications.test.mjs b/mindspace-publications.test.mjs
index b6c0a99..7ba3524 100644
--- a/mindspace-publications.test.mjs
+++ b/mindspace-publications.test.mjs
@@ -3,7 +3,7 @@ import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
-import { publicationInternals } from './mindspace-publications.mjs';
+import { createPublicationService, publicationInternals } from './mindspace-publications.mjs';
test('normalizes safe long-page slugs', () => {
assert.equal(publicationInternals.normalizeSlug(' product-intro-2026 '), 'product-intro-2026');
@@ -150,6 +150,55 @@ test('buildPublicationThumbnailFallback points private resources at the public p
);
});
+test('resolvePublic returns page provenance for package artifact registration', async () => {
+ const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-public-resolve-'));
+ const storageKey = 'users/user-1/publications/pub-1/index.html';
+ await fs.mkdir(path.dirname(path.join(storageRoot, storageKey)), { recursive: true });
+ await fs.writeFile(path.join(storageRoot, storageKey), '发布页');
+ const service = createPublicationService(
+ {
+ async query(sql) {
+ if (sql.includes('FROM h5_publish_records pr') && sql.includes('COALESCE(u.slug, u.username)')) {
+ return [[
+ {
+ id: 'pub-1',
+ user_id: 'user-1',
+ owner_id: 'user-1',
+ page_id: 'page-1',
+ page_version_id: 'version-1',
+ title: '发布页',
+ source_session_id: 'session-1',
+ source_message_id: 'message-1',
+ url_slug: 'published',
+ public_url: 'https://m.tkmind.cn/u/john/pages/published',
+ access_mode: 'public',
+ status: 'online',
+ view_count: 0,
+ published_at: 3000,
+ storage_key: storageKey,
+ },
+ ]];
+ }
+ return [[]];
+ },
+ },
+ {
+ storageRoot,
+ idFactory: () => 'view-1',
+ },
+ );
+
+ const result = await service.resolvePublic('john', 'published', null, null, {});
+
+ assert.equal(result.ownerId, 'user-1');
+ assert.deepEqual(result.pageSource, {
+ pageId: 'page-1',
+ title: '发布页',
+ sourceSessionId: 'session-1',
+ sourceMessageId: 'message-1',
+ });
+});
+
test('prepareHtmlPublishContent inlines owned private image assets before scanning', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-publish-'));
const storageKey = 'users/user-1/assets/asset-1/current.png';
@@ -348,6 +397,121 @@ test('registerPublicationArtifactForConversation records public companions', asy
assert.match(docx.canonicalUrl, /\/MindSpace\/user-1\/public\/report\.docx$/);
});
+test('publish registers public html and companion artifacts for chat-sourced pages', async () => {
+ const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-publish-flow-'));
+ const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-h5-root-'));
+ const pageStorageKey = 'users/user-1/pages/page-1/versions/v1.html';
+ const pageHtml = 'Report发布内容';
+ await fs.mkdir(path.dirname(path.join(storageRoot, pageStorageKey)), { recursive: true });
+ await fs.writeFile(path.join(storageRoot, pageStorageKey), pageHtml);
+ const publishDir = path.join(h5Root, 'MindSpace', 'user-1');
+ await fs.mkdir(path.join(publishDir, 'public'), { recursive: true });
+ await fs.writeFile(path.join(publishDir, 'public', 'report.docx'), 'docx');
+ await fs.writeFile(path.join(publishDir, 'public', 'report.long.png'), 'png');
+ const registryCalls = [];
+ let idCounter = 0;
+ const pageRow = {
+ page_id: 'page-1',
+ title: 'Report',
+ summary: '',
+ page_type: 'html',
+ template_id: 'static-html',
+ current_version_id: 'version-1',
+ source_session_id: 'session-1',
+ source_message_id: 'message-1',
+ user_id: 'user-1',
+ space_id: 'space-1',
+ page_version_id: 'version-1',
+ version_no: 1,
+ bundle_asset_id: null,
+ storage_key: pageStorageKey,
+ source_relative_path: 'public/report.html',
+ };
+ const pool = {
+ async query(sql, params = []) {
+ if (sql.includes('FROM h5_page_records p') && sql.includes('JOIN h5_page_versions')) {
+ assert.deepEqual(params, ['page-1', 'user-1', 'version-1']);
+ return [[pageRow]];
+ }
+ if (sql.includes('FROM h5_publish_records pr') && sql.includes('pr.page_id <>')) {
+ return [[]];
+ }
+ if (sql.includes('FROM h5_users')) {
+ return [[{ public_slug: 'john' }]];
+ }
+ if (sql.includes('FROM h5_assets') || sql.includes('FROM h5_asset_versions')) {
+ return [[]];
+ }
+ return [[]];
+ },
+ async getConnection() {
+ return {
+ async beginTransaction() {},
+ async commit() {},
+ async rollback() {},
+ release() {},
+ async query(sql) {
+ if (sql.includes('FROM h5_user_spaces') && sql.includes('FOR UPDATE')) {
+ return [[{ quota_bytes: 1_000_000, used_bytes: 0, reserved_bytes: 0 }]];
+ }
+ if (sql.includes('COUNT(DISTINCT page_id)')) {
+ return [[{ public_page_used: 0, page_already_online: 0 }]];
+ }
+ if (sql.includes('FROM h5_space_categories')) {
+ return [[{ id: 'cat-public' }]];
+ }
+ if (sql.includes('SELECT id, page_version_id FROM h5_publish_records')) {
+ return [[]];
+ }
+ return [[]];
+ },
+ };
+ },
+ };
+ const service = createPublicationService(pool, {
+ storageRoot,
+ h5Root,
+ idFactory: () => `id-${++idCounter}`,
+ conversationPackageRegistry: {
+ async ensurePackage(input) {
+ registryCalls.push(['ensurePackage', input]);
+ return { id: 'cp-session-1' };
+ },
+ async recordArtifact(input) {
+ registryCalls.push(['recordArtifact', input]);
+ return input;
+ },
+ async writeManifestForSession(input) {
+ registryCalls.push(['writeManifestForSession', input]);
+ return input;
+ },
+ },
+ });
+
+ try {
+ const publication = await service.publish('user-1', 'page-1', {
+ pageVersionId: 'version-1',
+ accessMode: 'public',
+ urlSlug: 'report',
+ acknowledgedFindingIds: [],
+ });
+
+ assert.equal(publication.id, 'id-4');
+ const artifactKinds = registryCalls
+ .filter(([name]) => name === 'recordArtifact')
+ .map(([, input]) => input.artifactKind)
+ .sort();
+ assert.deepEqual(artifactKinds, ['docx', 'long_image', 'public_html']);
+ const companion = registryCalls.find(([, input]) => input?.artifactKind === 'docx')?.[1];
+ assert.equal(companion.messageId, 'message-1');
+ assert.equal(companion.publicationId, 'id-4');
+ assert.equal(registryCalls.at(-1)[0], 'writeManifestForSession');
+ } finally {
+ await fs.rm(storageRoot, { recursive: true, force: true });
+ await fs.rm(h5Root, { recursive: true, force: true });
+ }
+});
+
test('registerPublicationArtifactForConversation skips pages without source session', async () => {
assert.equal(
await publicationInternals.registerPublicationArtifactForConversation({
diff --git a/mindspace-service.mjs b/mindspace-service.mjs
index 16c9097..c8dd884 100644
--- a/mindspace-service.mjs
+++ b/mindspace-service.mjs
@@ -28,7 +28,13 @@ function packageStorageKey(packageRecord, relativePath) {
return `${record.storagePrefix}/${relative}`;
}
-export function createMindSpaceServiceFacade({ storageAdapter, publicBaseUrl } = {}) {
+export function createMindSpaceServiceFacade({
+ storageAdapter,
+ publicBaseUrl,
+ conversationPackageBackfill,
+ conversationPackagePublicHtmlHydrator,
+ logger = console,
+} = {}) {
const storage = requireStorageAdapter(storageAdapter);
return {
@@ -93,6 +99,18 @@ export function createMindSpaceServiceFacade({ storageAdapter, publicBaseUrl } =
const storagePrefix = cleanPrefix ? `${record.storagePrefix}/${cleanPrefix}` : record.storagePrefix;
return storage.listObjects(storagePrefix);
},
+
+ async prepareConversationPackageRead({ user, sessionId } = {}) {
+ if (typeof conversationPackageBackfill === 'function') {
+ await conversationPackageBackfill({ user, sessionId }).catch((error) => {
+ logger?.warn?.('[MindSpace] conversation package DB backfill failed:', error?.message ?? error);
+ });
+ }
+ if (typeof conversationPackagePublicHtmlHydrator === 'function') {
+ return conversationPackagePublicHtmlHydrator({ user, sessionId });
+ }
+ return [];
+ },
};
}
diff --git a/mindspace-service.test.mjs b/mindspace-service.test.mjs
index 7716707..8d31048 100644
--- a/mindspace-service.test.mjs
+++ b/mindspace-service.test.mjs
@@ -125,3 +125,38 @@ test('MindSpace service facade writes manifest.json inside the conversation pack
['ca_report'],
);
});
+
+test('MindSpace service facade prepares conversation package reads through injected hooks', async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-service-'));
+ const calls = [];
+ const warnings = [];
+ const service = createMindSpaceServiceFacade({
+ storageAdapter: createLocalMindSpaceStorageAdapter(root),
+ publicBaseUrl: 'https://m.tkmind.cn',
+ conversationPackageBackfill: async (input) => {
+ calls.push(['backfill', input]);
+ throw new Error('transient db issue');
+ },
+ conversationPackagePublicHtmlHydrator: async (input) => {
+ calls.push(['hydrate', input]);
+ return ['artifact-1'];
+ },
+ logger: {
+ warn: (...args) => warnings.push(args),
+ },
+ });
+
+ const result = await service.prepareConversationPackageRead({
+ user: { id: 'user-1' },
+ sessionId: 'session-1',
+ });
+
+ assert.deepEqual(result, ['artifact-1']);
+ assert.deepEqual(
+ calls.map(([name]) => name),
+ ['backfill', 'hydrate'],
+ );
+ assert.equal(calls[0][1].sessionId, 'session-1');
+ assert.equal(warnings.length, 1);
+ assert.equal(warnings[0][0], '[MindSpace] conversation package DB backfill failed:');
+});
diff --git a/package.json b/package.json
index de8a02b..5bfc591 100644
--- a/package.json
+++ b/package.json
@@ -38,7 +38,8 @@
"build:portal-runtime": "node scripts/build-portal-runtime.mjs",
"check:mindspace-public-links": "node scripts/check-mindspace-public-links.mjs --downloads-only",
"check:mindspace-public-links:all": "node scripts/check-mindspace-public-links.mjs --all-links",
- "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs",
+ "check:conversation-package-manifest": "node scripts/check-conversation-package-manifest.mjs",
+ "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs",
"verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs",
"verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs",
"verify:mindspace-publish-guards": "node scripts/verify-mindspace-publish-guards.mjs",
diff --git a/scripts/check-conversation-package-manifest.mjs b/scripts/check-conversation-package-manifest.mjs
new file mode 100644
index 0000000..7fef625
--- /dev/null
+++ b/scripts/check-conversation-package-manifest.mjs
@@ -0,0 +1,58 @@
+#!/usr/bin/env node
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { verifyConversationPackageManifest } from '../mindspace-conversation-package-verify.mjs';
+import { createLocalMindSpaceStorageAdapter } from '../mindspace-storage-adapter.mjs';
+
+const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
+
+function usage() {
+ return [
+ 'Usage: node scripts/check-conversation-package-manifest.mjs --manifest [--storage-root ]',
+ '',
+ 'Validates that a conversation package manifest has required metadata and every artifact has',
+ 'a readable canonicalUrl or a storageKey under manifest.storagePrefix.',
+ ].join('\n');
+}
+
+function parseArgs(argv) {
+ let manifestPath = null;
+ let storageRoot = null;
+ for (let i = 2; i < argv.length; i += 1) {
+ if (argv[i] === '--manifest' && argv[i + 1]) {
+ manifestPath = path.resolve(argv[i + 1]);
+ i += 1;
+ } else if (argv[i] === '--storage-root' && argv[i + 1]) {
+ storageRoot = path.resolve(argv[i + 1]);
+ i += 1;
+ } else if (argv[i] === '--help' || argv[i] === '-h') {
+ console.log(usage());
+ process.exit(0);
+ }
+ }
+ if (!manifestPath) {
+ console.error(usage());
+ process.exit(2);
+ }
+ return { manifestPath, storageRoot };
+}
+
+const { manifestPath, storageRoot } = parseArgs(process.argv);
+const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
+const storageAdapter = storageRoot ? createLocalMindSpaceStorageAdapter(storageRoot) : null;
+const result = await verifyConversationPackageManifest(manifest, {
+ statObject: storageAdapter ? (key) => storageAdapter.statObject(key) : null,
+});
+
+if (result.ok) {
+ console.log(`OK: conversation package manifest ${path.relative(repoRoot, manifestPath) || manifestPath}`);
+ process.exit(0);
+}
+
+console.error(`Found ${result.issues.length} conversation package manifest issue(s):`);
+for (const item of result.issues) {
+ const suffix = item.artifactId ? ` [artifact ${item.artifactId}]` : '';
+ console.error(`- ${item.code}${suffix}: ${item.message}`);
+}
+process.exit(1);
diff --git a/server.mjs b/server.mjs
index 32f38dd..01b072d 100644
--- a/server.mjs
+++ b/server.mjs
@@ -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}`);
});
diff --git a/src/api/client.ts b/src/api/client.ts
index ec82a0e..6216639 100644
--- a/src/api/client.ts
+++ b/src/api/client.ts
@@ -749,6 +749,20 @@ export async function uploadMindSpaceAsset(
}
}
+export async function claimMindSpaceConversationUploads(
+ sessionId: string,
+ messageId: string,
+): Promise<{ claimedCount: number }> {
+ const result = await apiFetch<{ data: { claimedCount: number } }>(
+ `/mindspace/v1/conversation-packages/${encodeURIComponent(sessionId)}/claim-uploads`,
+ {
+ method: 'POST',
+ body: JSON.stringify({ message_id: messageId }),
+ },
+ );
+ return result.data;
+}
+
function uploadFileContent(
url: string,
file: File,
diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx
index 2adb06b..bb0c8c8 100644
--- a/src/components/ChatPanel.tsx
+++ b/src/components/ChatPanel.tsx
@@ -73,6 +73,7 @@ type PendingChatImage = {
previewUrl: string;
sizeBytes: number;
uploadedUrl: string | null;
+ sourceMessageId: string | null;
uploadProgress: number | null;
uploadStatus: 'queued' | 'uploading' | 'uploaded' | 'error';
};
@@ -195,8 +196,17 @@ export function ChatPanel({
session: Session | null;
capabilities?: CapabilityMap;
grantedSkills?: string[];
- onSubmit: (text: string, imageUrls?: string[], previewImageUrls?: string[]) => void | Promise;
- onUploadImage?: (file: File, onProgress?: (progress: number) => void) => Promise;
+ onSubmit: (
+ text: string,
+ imageUrls?: string[],
+ previewImageUrls?: string[],
+ options?: { messageId?: string },
+ ) => void | Promise;
+ onUploadImage?: (
+ file: File,
+ onProgress?: (progress: number) => void,
+ options?: { messageId?: string },
+ ) => Promise;
onLoadOlderMessages?: () => void | Promise;
onStop: () => void | Promise;
onApproveTool: (allow: boolean) => void | Promise;
@@ -221,6 +231,8 @@ export function ChatPanel({
const [packageLoading, setPackageLoading] = useState(false);
const [conversationPackage, setConversationPackage] = useState(null);
const [packageError, setPackageError] = useState(null);
+ const [packageFilter, setPackageFilter] = useState('all');
+ const [packageMessageFilter, setPackageMessageFilter] = useState('all');
const [randomPrompt] = useState(
() => CHAT_PLACEHOLDER_PROMPTS[Math.floor(Math.random() * CHAT_PLACEHOLDER_PROMPTS.length)],
);
@@ -291,6 +303,8 @@ export function ChatPanel({
setPackageLoading(false);
setConversationPackage(null);
setPackageError(null);
+ setPackageFilter('all');
+ setPackageMessageFilter('all');
}, [session?.id]);
const loadConversationPackage = useCallback(async () => {
@@ -300,6 +314,12 @@ export function ChatPanel({
try {
const data = await getMindSpaceConversationPackage(session.id);
setConversationPackage(data);
+ setPackageFilter((current) =>
+ current === 'all' || data.artifacts.some((artifact) => artifact.kind === current) ? current : 'all',
+ );
+ setPackageMessageFilter((current) =>
+ current === 'all' || (data.messages ?? []).some((message) => message.messageId === current) ? current : 'all',
+ );
} catch (err) {
setConversationPackage(null);
if (err instanceof ApiError && err.status === 404) {
@@ -437,6 +457,32 @@ export function ChatPanel({
const voiceDisabled = busy || !!pendingTool || chatState === 'error' || offlineBlocked;
const canSubmit = input.trim() || pendingImages.length > 0;
const imageAttachmentDisabled = !onUploadImage || busy || uploadingImage || offlineBlocked || !!pendingTool;
+ const packageArtifacts = conversationPackage?.artifacts ?? [];
+ const packageFilterOptions = Array.from(
+ packageArtifacts.reduce((counts, artifact) => {
+ counts.set(artifact.kind, (counts.get(artifact.kind) ?? 0) + 1);
+ return counts;
+ }, new Map()),
+ ).sort(([left], [right]) => packageArtifactLabel(left).localeCompare(packageArtifactLabel(right), 'zh-CN'));
+ const packageMessageOptions = (conversationPackage?.messages ?? [])
+ .filter((message) => message.artifactIds.length > 0)
+ .map((message, index) => ({
+ id: message.messageId,
+ label: `消息 ${index + 1}`,
+ count: message.artifactIds.length,
+ artifactIds: new Set(message.artifactIds),
+ }));
+ const selectedMessageArtifacts =
+ packageMessageFilter === 'all'
+ ? null
+ : packageMessageOptions.find((message) => message.id === packageMessageFilter)?.artifactIds ?? null;
+ const filteredPackageArtifacts =
+ packageArtifacts.filter((artifact) => {
+ const kindMatched = packageFilter === 'all' || artifact.kind === packageFilter;
+ const messageMatched = !selectedMessageArtifacts || selectedMessageArtifacts.has(artifact.artifactId);
+ return kindMatched && messageMatched;
+ });
+ const packageGroups = groupPackageArtifacts(filteredPackageArtifacts);
const revokePendingImage = (item: PendingChatImage) => {
if (item.previewUrl.startsWith('blob:')) URL.revokeObjectURL(item.previewUrl);
@@ -505,7 +551,12 @@ export function ChatPanel({
setImageError('当前会话暂不支持图片发送');
return;
}
- const sentImages = [...pendingImages];
+ const existingSourceMessageId = pendingImages.find((item) => item.sourceMessageId)?.sourceMessageId;
+ const outgoingMessageId = existingSourceMessageId ?? crypto.randomUUID();
+ const sentImages = pendingImages.map((item) => ({
+ ...item,
+ sourceMessageId: item.sourceMessageId ?? outgoingMessageId,
+ }));
let uploadedImages = sentImages;
suppressVoiceUpdateRef.current = true;
@@ -524,28 +575,39 @@ export function ChatPanel({
setPendingImages((current) =>
current.map((candidate) =>
candidate.id === item.id
- ? { ...candidate, uploadStatus: 'uploading', uploadProgress: 0 }
+ ? {
+ ...candidate,
+ sourceMessageId: item.sourceMessageId,
+ uploadStatus: 'uploading',
+ uploadProgress: 0,
+ }
: candidate,
),
);
- const uploadedUrl = await onUploadImage!(item.file, (progress) => {
- setPendingImages((current) =>
- current.map((candidate) =>
- candidate.id === item.id
- ? {
- ...candidate,
- uploadStatus: 'uploading',
- uploadProgress: Math.round(progress * 100),
- }
- : candidate,
- ),
- );
- });
+ const uploadedUrl = await onUploadImage!(
+ item.file,
+ (progress) => {
+ setPendingImages((current) =>
+ current.map((candidate) =>
+ candidate.id === item.id
+ ? {
+ ...candidate,
+ sourceMessageId: item.sourceMessageId,
+ uploadStatus: 'uploading',
+ uploadProgress: Math.round(progress * 100),
+ }
+ : candidate,
+ ),
+ );
+ },
+ { messageId: item.sourceMessageId ?? outgoingMessageId },
+ );
setPendingImages((current) =>
current.map((candidate) =>
candidate.id === item.id
? {
...candidate,
+ sourceMessageId: item.sourceMessageId,
uploadedUrl,
uploadStatus: 'uploaded',
uploadProgress: 100,
@@ -564,7 +626,7 @@ export function ChatPanel({
}));
const imagesToSend = uploadedUrls.filter(Boolean);
const previewImagesToSend = imagesToSend;
- await onSubmit(trimmed, imagesToSend, previewImagesToSend);
+ await onSubmit(trimmed, imagesToSend, previewImagesToSend, { messageId: outgoingMessageId });
uploadedImages.forEach(revokePendingImage);
setPendingImages([]);
} catch (err) {
@@ -607,6 +669,7 @@ export function ChatPanel({
previewUrl: URL.createObjectURL(file),
sizeBytes: file.size,
uploadedUrl: null,
+ sourceMessageId: null,
uploadProgress: null,
uploadStatus: 'queued' as const,
}));
@@ -639,6 +702,9 @@ export function ChatPanel({
previewUrl: URL.createObjectURL(file),
sizeBytes: file.size,
uploadedUrl: null,
+ sourceMessageId: null,
+ uploadProgress: null,
+ uploadStatus: 'queued' as const,
}));
setPendingImages((prev) => [...prev, ...placeholders]);
};
@@ -774,55 +840,111 @@ export function ChatPanel({
- {conversationPackage?.artifacts.length ?? 0} 个项目
+ {packageArtifacts.length} 个项目
{conversationPackage?.uri && {conversationPackage.uri}}
{packageLoading && 正在整理这个对话里的文件…
}
{!packageLoading && packageError && (
- {packageError}
- )}
- {!packageLoading && !packageError && conversationPackage && conversationPackage.artifacts.length === 0 && (
- 这个对话暂时没有图片、文件或页面。
+ 对话包暂时打不开
+ {packageError}
+
)}
- {!packageLoading && !packageError && conversationPackage && conversationPackage.artifacts.length > 0 && (
-
- {groupPackageArtifacts(conversationPackage.artifacts).map(([label, artifacts]) => (
-
- ))}
+ {!packageLoading && !packageError && conversationPackage && packageArtifacts.length === 0 && (
+
+ 这个对话还没有文件
+ 发送图片、保存页面、生成公开页或下载文档后,这里会自动聚合成一个对话文件夹。
)}
+ {!packageLoading && !packageError && conversationPackage && packageArtifacts.length > 0 && (
+ <>
+
+
+ {packageFilterOptions.map(([kind, count]) => (
+
+ ))}
+
+ {packageMessageOptions.length > 0 && (
+
+ 按消息
+
+ {packageMessageOptions.map((message) => (
+
+ ))}
+
+ )}
+ {filteredPackageArtifacts.length === 0 ? (
+
+ 当前筛选没有项目
+ 切换到“全部”或“全部消息”可以查看当前对话包里的其它产物。
+
+ ) : (
+
+ {packageGroups.map(([label, artifacts]) => (
+
+ ))}
+
+ )}
+ >
+ )}