diff --git a/mindspace-conversation-package-store.mjs b/mindspace-conversation-package-store.mjs new file mode 100644 index 0000000..2508dca --- /dev/null +++ b/mindspace-conversation-package-store.mjs @@ -0,0 +1,166 @@ +import { + buildConversationPackageManifest, + createConversationArtifact, + createConversationPackageRecord, +} from './mindspace-conversation-package.mjs'; + +function storeError(message, code) { + return Object.assign(new Error(message), { code }); +} + +function requirePool(pool) { + if (!pool || typeof pool.query !== 'function') { + throw storeError('MindSpace conversation package store requires a queryable pool', 'missing_query_pool'); + } + return pool; +} + +function packageFromRow(row) { + if (!row) return null; + return createConversationPackageRecord({ + id: row.id, + userId: row.user_id, + sessionId: row.session_id, + title: row.title, + status: row.status, + storagePrefix: row.storage_prefix, + manifestAssetId: row.manifest_asset_id, + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + }); +} + +function artifactFromRow(row) { + if (!row) return null; + return createConversationArtifact({ + id: row.id, + packageId: row.package_id, + artifactKind: row.artifact_kind, + role: row.role, + assetId: row.asset_id, + pageId: row.page_id, + publicationId: row.publication_id, + agentRunId: row.agent_run_id, + messageId: row.message_id, + displayName: row.display_name, + mimeType: row.mime_type, + sizeBytes: row.size_bytes == null ? null : Number(row.size_bytes), + storageKey: row.storage_key, + canonicalUrl: row.canonical_url, + sortOrder: Number(row.sort_order ?? 0), + createdAt: Number(row.created_at), + }); +} + +export function createConversationPackageStore(pool) { + const db = requirePool(pool); + + return { + async upsertPackage(input) { + const record = createConversationPackageRecord(input); + await db.query( + `INSERT INTO h5_conversation_packages + (id, user_id, session_id, title, status, storage_prefix, manifest_asset_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + title = VALUES(title), + status = VALUES(status), + storage_prefix = VALUES(storage_prefix), + manifest_asset_id = VALUES(manifest_asset_id), + updated_at = VALUES(updated_at)`, + [ + record.id, + record.userId, + record.sessionId, + record.title, + record.status, + record.storagePrefix, + record.manifestAssetId, + record.createdAt, + record.updatedAt, + ], + ); + return record; + }, + + async getPackageBySession(userId, sessionId) { + const [rows] = await db.query( + `SELECT id, user_id, session_id, title, status, storage_prefix, manifest_asset_id, created_at, updated_at + FROM h5_conversation_packages + WHERE user_id = ? AND session_id = ? + LIMIT 1`, + [userId, sessionId], + ); + return packageFromRow(rows?.[0]); + }, + + async upsertArtifact(input) { + const artifact = createConversationArtifact(input); + await db.query( + `INSERT INTO h5_conversation_artifacts + (id, package_id, artifact_kind, role, asset_id, page_id, publication_id, agent_run_id, + message_id, display_name, mime_type, size_bytes, storage_key, canonical_url, sort_order, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + artifact_kind = VALUES(artifact_kind), + role = VALUES(role), + asset_id = VALUES(asset_id), + page_id = VALUES(page_id), + publication_id = VALUES(publication_id), + agent_run_id = VALUES(agent_run_id), + message_id = VALUES(message_id), + display_name = VALUES(display_name), + mime_type = VALUES(mime_type), + size_bytes = VALUES(size_bytes), + storage_key = VALUES(storage_key), + canonical_url = VALUES(canonical_url), + sort_order = VALUES(sort_order)`, + [ + artifact.id, + artifact.packageId, + artifact.artifactKind, + artifact.role, + artifact.assetId, + artifact.pageId, + artifact.publicationId, + artifact.agentRunId, + artifact.messageId, + artifact.displayName, + artifact.mimeType, + artifact.sizeBytes, + artifact.storageKey, + artifact.canonicalUrl, + artifact.sortOrder, + artifact.createdAt, + ], + ); + return artifact; + }, + + async listArtifacts(packageId) { + const [rows] = await db.query( + `SELECT id, package_id, artifact_kind, role, asset_id, page_id, publication_id, agent_run_id, + message_id, display_name, mime_type, size_bytes, storage_key, canonical_url, sort_order, created_at + FROM h5_conversation_artifacts + WHERE package_id = ? + ORDER BY sort_order ASC, created_at ASC, id ASC`, + [packageId], + ); + return (rows ?? []).map(artifactFromRow); + }, + + async buildManifestForSession({ userId, sessionId }) { + const packageRecord = await this.getPackageBySession(userId, sessionId); + if (!packageRecord) return null; + const artifacts = await this.listArtifacts(packageRecord.id); + return buildConversationPackageManifest({ packageRecord, artifacts }); + }, + }; +} + +export const conversationPackageStoreInternals = { + artifactFromRow, + packageFromRow, + requirePool, + storeError, +}; diff --git a/mindspace-conversation-package-store.test.mjs b/mindspace-conversation-package-store.test.mjs new file mode 100644 index 0000000..4aee3f8 --- /dev/null +++ b/mindspace-conversation-package-store.test.mjs @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createConversationPackageStore } from './mindspace-conversation-package-store.mjs'; + +function createFakePool() { + const packages = new Map(); + const artifacts = new Map(); + const queries = []; + return { + queries, + async query(sql, params = []) { + queries.push({ sql, params }); + if (/INSERT INTO h5_conversation_packages/i.test(sql)) { + const [ + id, + user_id, + session_id, + title, + status, + storage_prefix, + manifest_asset_id, + created_at, + updated_at, + ] = params; + packages.set(`${user_id}:${session_id}`, { + id, + user_id, + session_id, + title, + status, + storage_prefix, + manifest_asset_id, + created_at, + updated_at, + }); + return [{ affectedRows: 1 }]; + } + if (/FROM h5_conversation_packages/i.test(sql)) { + return [[packages.get(`${params[0]}:${params[1]}`)].filter(Boolean)]; + } + if (/INSERT INTO h5_conversation_artifacts/i.test(sql)) { + const [ + id, + package_id, + artifact_kind, + role, + asset_id, + page_id, + publication_id, + agent_run_id, + message_id, + display_name, + mime_type, + size_bytes, + storage_key, + canonical_url, + sort_order, + created_at, + ] = params; + artifacts.set(id, { + id, + package_id, + artifact_kind, + role, + asset_id, + page_id, + publication_id, + agent_run_id, + message_id, + display_name, + mime_type, + size_bytes, + storage_key, + canonical_url, + sort_order, + created_at, + }); + return [{ affectedRows: 1 }]; + } + if (/FROM h5_conversation_artifacts/i.test(sql)) { + const packageId = params[0]; + const rows = [...artifacts.values()] + .filter((row) => row.package_id === packageId) + .sort( + (a, b) => + Number(a.sort_order) - Number(b.sort_order) || + Number(a.created_at) - Number(b.created_at) || + String(a.id).localeCompare(String(b.id)), + ); + return [rows]; + } + throw new Error(`unexpected query: ${sql}`); + }, + }; +} + +test('createConversationPackageStore requires a queryable pool', () => { + assert.throws( + () => createConversationPackageStore({}), + (error) => error.code === 'missing_query_pool', + ); +}); + +test('conversation package store upserts package metadata and builds manifest', async () => { + const pool = createFakePool(); + const store = createConversationPackageStore(pool); + const packageRecord = await store.upsertPackage({ + userId: 'user-1', + sessionId: 'session-1', + title: '旅行计划', + now: 1000, + }); + + assert.equal(packageRecord.id, 'cp_session-1'); + assert.equal(packageRecord.storagePrefix, 'users/user-1/conversations/session-1'); + assert.match(pool.queries[0].sql, /ON DUPLICATE KEY UPDATE/); + + await store.upsertArtifact({ + id: 'ca_late', + packageId: packageRecord.id, + artifactKind: 'public_html', + displayName: 'plan.html', + storageKey: 'users/user-1/conversations/session-1/public/plan.html', + canonicalUrl: 'https://m.tkmind.cn/MindSpace/user-1/public/plan.html', + sortOrder: 2, + now: 3000, + }); + await store.upsertArtifact({ + id: 'ca_first', + packageId: packageRecord.id, + artifactKind: 'input_image', + displayName: 'cover.png', + sortOrder: 1, + now: 2000, + }); + + const manifest = await store.buildManifestForSession({ + userId: 'user-1', + sessionId: 'session-1', + }); + + assert.equal(manifest.packageId, 'cp_session-1'); + assert.equal(manifest.uri, 'mindspace://users/user-1/conversations/session-1'); + assert.deepEqual( + manifest.artifacts.map((artifact) => artifact.artifactId), + ['ca_first', 'ca_late'], + ); + assert.equal( + manifest.artifacts[1].canonicalUrl, + 'https://m.tkmind.cn/MindSpace/user-1/public/plan.html', + ); +}); + +test('conversation package store returns null manifest for missing session', async () => { + const store = createConversationPackageStore(createFakePool()); + assert.equal(await store.buildManifestForSession({ userId: 'user-1', sessionId: 'missing' }), null); +}); diff --git a/package.json b/package.json index c550db1..e9a0b20 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "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-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", + "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-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",