From 0f48792133ed76009cc94d70bf59a141a8e2d273 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 2 Jul 2026 21:28:51 +0800 Subject: [PATCH] Record uploaded assets in conversation packages --- db.mjs | 16 ++++++ mindspace-assets.mjs | 64 ++++++++++++++++++++++- mindspace-assets.test.mjs | 101 ++++++++++++++++++++++++++++++++++++- schema.sql | 3 ++ server.mjs | 3 ++ src/api/client.ts | 9 +++- src/hooks/useTKMindChat.ts | 1 + 7 files changed, 193 insertions(+), 4 deletions(-) diff --git a/db.mjs b/db.mjs index 8b6f8a2..2f41476 100644 --- a/db.mjs +++ b/db.mjs @@ -346,6 +346,22 @@ export async function migrateSchema(pool) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); + const uploadSessionColumns = [ + ['source_session_id', 'VARCHAR(128) NULL AFTER completed_asset_id'], + ['source_message_id', 'VARCHAR(128) NULL AFTER source_session_id'], + ]; + for (const [column, definition] of uploadSessionColumns) { + if (!(await columnExists(pool, 'h5_upload_sessions', column))) { + await pool.query(`ALTER TABLE h5_upload_sessions ADD COLUMN \`${column}\` ${definition}`); + } + } + if (!(await indexExists(pool, 'h5_upload_sessions', 'idx_h5_upload_source_session'))) { + await pool.query( + `ALTER TABLE h5_upload_sessions + ADD KEY idx_h5_upload_source_session (user_id, source_session_id)`, + ); + } + await pool.query(` CREATE TABLE IF NOT EXISTS h5_user_memory_items ( id CHAR(64) PRIMARY KEY, diff --git a/mindspace-assets.mjs b/mindspace-assets.mjs index b21c94c..cb22e25 100644 --- a/mindspace-assets.mjs +++ b/mindspace-assets.mjs @@ -294,6 +294,7 @@ export function createAssetService(pool, options = {}) { const uploadTtlMs = Number(options.uploadTtlMs ?? 30 * 60 * 1000); const idFactory = options.idFactory ?? (() => crypto.randomUUID()); const normalizeStoredImage = options.normalizeStoredImage ?? normalizeImageForStorage; + const conversationPackageRegistry = options.conversationPackageRegistry ?? null; const workspaceSync = createWorkspaceAssetSync({ pool, storageRoot, @@ -464,8 +465,9 @@ export function createAssetService(pool, options = {}) { await conn.query( `INSERT INTO h5_upload_sessions (id, user_id, space_id, category_id, filename, expected_size, declared_mime_type, - reserved_bytes, temporary_storage_key, status, expires_at, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'reserved', ?, ?)`, + reserved_bytes, temporary_storage_key, status, expires_at, created_at, + source_session_id, source_message_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'reserved', ?, ?, ?, ?)`, [ uploadId, userId, @@ -478,6 +480,8 @@ export function createAssetService(pool, options = {}) { temporaryStorageKey, now + uploadTtlMs, now, + input.sourceSessionId ?? input.source_session_id ?? null, + input.sourceMessageId ?? input.source_message_id ?? null, ], ); await conn.commit(); @@ -733,6 +737,16 @@ export function createAssetService(pool, options = {}) { if (finalStorageKey !== upload.temporary_storage_key) { await fs.rm(temporaryPath, { force: true }).catch(() => {}); } + await registerUploadArtifactForConversation({ + registry: conversationPackageRegistry, + userId, + upload, + asset: result, + assetId, + storageKey: finalStorageKey, + canonicalUrl: result.publicUrl, + now, + }); return result; } catch (error) { await conn.rollback(); @@ -1277,9 +1291,55 @@ export function createAssetService(pool, options = {}) { }; } +async function registerUploadArtifactForConversation({ + registry, + userId, + upload, + asset, + assetId, + storageKey, + canonicalUrl, + now, +}) { + const sessionId = String(upload?.source_session_id ?? '').trim(); + if (!registry || !sessionId || !assetId) return null; + try { + const packageRecord = await registry.ensurePackage({ + userId, + sessionId, + now, + }); + const isImage = String(asset?.mimeType ?? '').startsWith('image/'); + const artifact = await registry.recordArtifact({ + id: `ca_${assetId}`, + packageId: packageRecord.id, + artifactKind: isImage ? 'input_image' : 'input_file', + role: 'user', + assetId, + messageId: upload.source_message_id ?? null, + displayName: asset?.displayName ?? asset?.filename ?? upload.filename, + mimeType: asset?.mimeType ?? upload.detected_mime_type ?? null, + sizeBytes: asset?.sizeBytes ?? upload.actual_size ?? null, + storageKey, + canonicalUrl, + sortOrder: Number.isFinite(Number(now)) ? Number(now) : 0, + now, + }); + await registry.writeManifestForSession({ userId, sessionId }); + return artifact; + } catch (error) { + console.warn( + '[MindSpace] conversation package upload artifact registration failed:', + error instanceof Error ? error.message : error, + ); + return null; + } +} + export const assetInternals = { detectMimeType, normalizeFilename, expectedMimeType, assetTypeForMime, + registerUploadArtifactForConversation, }; diff --git a/mindspace-assets.test.mjs b/mindspace-assets.test.mjs index c76e2f0..9bff418 100644 --- a/mindspace-assets.test.mjs +++ b/mindspace-assets.test.mjs @@ -4,7 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import sharp from 'sharp'; -import { createAssetService, validateUploadRequest } from './mindspace-assets.mjs'; +import { assetInternals, createAssetService, validateUploadRequest } from './mindspace-assets.mjs'; import { DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs'; function createMockPool(state) { @@ -40,6 +40,8 @@ function createMockPool(state) { temporary_storage_key: params[8], status: 'reserved', expires_at: params[9], + source_session_id: params[11] ?? null, + source_message_id: params[12] ?? null, }); return [[]]; } @@ -242,6 +244,103 @@ test('completeUpload marks safe files ready and blocks script payloads', async ( assert.equal(asset.scanStatus, 'blocked'); }); +test('createUpload stores optional source session metadata', async () => { + const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-assets-')); + const state = { + categories: [{ id: 'cat-1', user_id: 'user-1', space_id: 'space-1', category_code: 'oa' }], + spaces: [ + { + id: 'space-1', + user_id: 'user-1', + quota_bytes: 5 * 1024 * 1024, + used_bytes: 0, + reserved_bytes: 0, + status: 'active', + }, + ], + uploads: [], + assets: [], + versions: [], + }; + const service = createAssetService(createMockPool(state), { + storageRoot, + idFactory: () => 'upload-1', + }); + + await service.createUpload('user-1', { + categoryId: 'cat-1', + filename: 'note.txt', + sizeBytes: 12, + sourceSessionId: 'session-1', + sourceMessageId: 'message-1', + }); + + assert.equal(state.uploads[0].source_session_id, 'session-1'); + assert.equal(state.uploads[0].source_message_id, 'message-1'); +}); + +test('registerUploadArtifactForConversation records uploaded input artifacts', async () => { + 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 { storageKey: 'users/user-1/conversations/session-1/manifest.json' }; + }, + }; + + const result = await assetInternals.registerUploadArtifactForConversation({ + registry, + userId: 'user-1', + upload: { + filename: 'cover.png', + source_session_id: 'session-1', + source_message_id: 'message-1', + }, + asset: { + displayName: 'cover.png', + filename: 'cover.png', + mimeType: 'image/png', + sizeBytes: 123, + publicUrl: 'https://m.tkmind.cn/MindSpace/user-1/images/cover.png', + }, + assetId: 'asset-1', + storageKey: 'users/user-1/images/2026-07-02/cover.png', + canonicalUrl: 'https://m.tkmind.cn/MindSpace/user-1/images/cover.png', + now: 1000, + }); + + assert.equal(result.artifactKind, 'input_image'); + assert.equal(result.role, 'user'); + assert.equal(result.messageId, 'message-1'); + assert.equal(result.canonicalUrl, 'https://m.tkmind.cn/MindSpace/user-1/images/cover.png'); + assert.deepEqual(calls[2], [ + 'writeManifestForSession', + { userId: 'user-1', sessionId: 'session-1' }, + ]); +}); + +test('registerUploadArtifactForConversation skips uploads without source session', async () => { + assert.equal( + await assetInternals.registerUploadArtifactForConversation({ + registry: { ensurePackage: async () => assert.fail('should not be called') }, + userId: 'user-1', + upload: { filename: 'note.txt' }, + asset: { mimeType: 'text/plain' }, + assetId: 'asset-1', + now: 1000, + }), + null, + ); +}); + 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/schema.sql b/schema.sql index 21bfa7c..984e3cd 100644 --- a/schema.sql +++ b/schema.sql @@ -123,11 +123,14 @@ CREATE TABLE IF NOT EXISTS h5_upload_sessions ( temporary_storage_key VARCHAR(512) NOT NULL, checksum CHAR(64) NULL, completed_asset_id CHAR(36) NULL, + source_session_id VARCHAR(128) NULL, + source_message_id VARCHAR(128) NULL, status ENUM('reserved', 'uploaded', 'completed', 'cancelled', 'expired', 'failed') NOT NULL DEFAULT 'reserved', expires_at BIGINT NOT NULL, created_at BIGINT NOT NULL, completed_at BIGINT NULL, KEY idx_h5_upload_user_status (user_id, status, expires_at), + KEY idx_h5_upload_source_session (user_id, source_session_id), CONSTRAINT fk_h5_upload_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, CONSTRAINT fk_h5_upload_space FOREIGN KEY (space_id) REFERENCES h5_user_spaces(id) ON DELETE CASCADE, CONSTRAINT fk_h5_upload_category FOREIGN KEY (category_id) REFERENCES h5_space_categories(id) ON DELETE CASCADE diff --git a/server.mjs b/server.mjs index ef5a9ff..4d78b4b 100644 --- a/server.mjs +++ b/server.mjs @@ -314,6 +314,7 @@ async function bootstrapUserAuth() { h5Root: __dirname, storageRoot: mindSpaceStorageRoot, maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES), + conversationPackageRegistry, }); mindSpacePages = createPageService(pool, { h5Root: __dirname, @@ -2493,6 +2494,8 @@ api.post('/mindspace/v1/uploads', async (req, res) => { filename: req.body?.filename, sizeBytes: req.body?.size_bytes, declaredMimeType: req.body?.declared_mime_type, + sourceSessionId: req.body?.session_id, + sourceMessageId: req.body?.message_id, }); return sendData(res, req, upload, 201); } catch (error) { diff --git a/src/api/client.ts b/src/api/client.ts index 7d32b91..425adeb 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -693,7 +693,12 @@ export async function listMindSpaceAssets( export async function uploadMindSpaceAsset( categoryId: string, file: File, - options: { maxImageBytes?: number; onProgress?: (progress: number) => void } = {}, + options: { + maxImageBytes?: number; + onProgress?: (progress: number) => void; + sessionId?: string | null; + messageId?: string | null; + } = {}, ): Promise { const maxImageBytes = options.maxImageBytes ?? CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES; if (file.type.startsWith('image/') && file.size > maxImageBytes) { @@ -710,6 +715,8 @@ export async function uploadMindSpaceAsset( filename: file.name, size_bytes: file.size, declared_mime_type: file.type || null, + ...(options.sessionId ? { session_id: options.sessionId } : {}), + ...(options.messageId ? { message_id: options.messageId } : {}), }), }); diff --git a/src/hooks/useTKMindChat.ts b/src/hooks/useTKMindChat.ts index d7f57d1..7286420 100644 --- a/src/hooks/useTKMindChat.ts +++ b/src/hooks/useTKMindChat.ts @@ -580,6 +580,7 @@ export function useTKMindChat( const asset = await uploadMindSpaceAsset(categoryId, file, { maxImageBytes: CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, onProgress, + sessionId: sessionRef.current?.id ?? null, }); return buildAbsoluteAssetImageUrl({ id: asset.id,