From b258004dadf69e19aa5cf99c0a519f40b60cfd79 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 2 Jul 2026 23:46:05 +0800 Subject: [PATCH] feat: complete mindspace conversation packages --- ...d-control-execution-split-plan-20260702.md | 21 ++ mindspace-assets.mjs | 98 +++++++ mindspace-assets.test.mjs | 144 +++++++++- mindspace-chat-docx-package.mjs | 54 ++++ mindspace-chat-docx-package.test.mjs | 83 ++++++ mindspace-conversation-package-backfill.mjs | 157 +++++++++++ ...ace-conversation-package-backfill.test.mjs | 111 ++++++++ mindspace-conversation-package-routes.mjs | 8 + ...space-conversation-package-routes.test.mjs | 15 +- mindspace-conversation-package-verify.mjs | 83 ++++++ ...space-conversation-package-verify.test.mjs | 86 ++++++ mindspace-conversation-package.mjs | 42 ++- mindspace-conversation-package.test.mjs | 49 ++++ mindspace-public-finish-sync.mjs | 117 +++++++- mindspace-public-finish-sync.test.mjs | 114 +++++++- mindspace-publications.mjs | 11 +- mindspace-publications.test.mjs | 166 +++++++++++- mindspace-service.mjs | 20 +- mindspace-service.test.mjs | 35 +++ package.json | 3 +- .../check-conversation-package-manifest.mjs | 58 ++++ server.mjs | 256 +++++++++++++++--- src/api/client.ts | 14 + src/components/ChatPanel.tsx | 238 ++++++++++++---- src/components/ChatView.tsx | 8 +- src/components/SpaceChatPanel.tsx | 10 +- src/hooks/usePageEditSubChat.ts | 6 +- src/hooks/useTKMindChat.ts | 9 +- src/index.css | 68 +++++ src/types.ts | 14 + src/utils/message.ts | 10 +- 31 files changed, 1990 insertions(+), 118 deletions(-) create mode 100644 mindspace-chat-docx-package.mjs create mode 100644 mindspace-chat-docx-package.test.mjs create mode 100644 mindspace-conversation-package-backfill.mjs create mode 100644 mindspace-conversation-package-backfill.test.mjs create mode 100644 mindspace-conversation-package-verify.mjs create mode 100644 mindspace-conversation-package-verify.test.mjs create mode 100644 scripts/check-conversation-package-manifest.mjs 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]) => ( + + ))} +
+ )} + + )}