Record uploaded assets in conversation packages

This commit is contained in:
john
2026-07-02 21:28:51 +08:00
parent c2308a6ead
commit 0f48792133
7 changed files with 193 additions and 4 deletions
+16
View File
@@ -346,6 +346,22 @@ export async function migrateSchema(pool) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) 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(` await pool.query(`
CREATE TABLE IF NOT EXISTS h5_user_memory_items ( CREATE TABLE IF NOT EXISTS h5_user_memory_items (
id CHAR(64) PRIMARY KEY, id CHAR(64) PRIMARY KEY,
+62 -2
View File
@@ -294,6 +294,7 @@ export function createAssetService(pool, options = {}) {
const uploadTtlMs = Number(options.uploadTtlMs ?? 30 * 60 * 1000); const uploadTtlMs = Number(options.uploadTtlMs ?? 30 * 60 * 1000);
const idFactory = options.idFactory ?? (() => crypto.randomUUID()); const idFactory = options.idFactory ?? (() => crypto.randomUUID());
const normalizeStoredImage = options.normalizeStoredImage ?? normalizeImageForStorage; const normalizeStoredImage = options.normalizeStoredImage ?? normalizeImageForStorage;
const conversationPackageRegistry = options.conversationPackageRegistry ?? null;
const workspaceSync = createWorkspaceAssetSync({ const workspaceSync = createWorkspaceAssetSync({
pool, pool,
storageRoot, storageRoot,
@@ -464,8 +465,9 @@ export function createAssetService(pool, options = {}) {
await conn.query( await conn.query(
`INSERT INTO h5_upload_sessions `INSERT INTO h5_upload_sessions
(id, user_id, space_id, category_id, filename, expected_size, declared_mime_type, (id, user_id, space_id, category_id, filename, expected_size, declared_mime_type,
reserved_bytes, temporary_storage_key, status, expires_at, created_at) reserved_bytes, temporary_storage_key, status, expires_at, created_at,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'reserved', ?, ?)`, source_session_id, source_message_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'reserved', ?, ?, ?, ?)`,
[ [
uploadId, uploadId,
userId, userId,
@@ -478,6 +480,8 @@ export function createAssetService(pool, options = {}) {
temporaryStorageKey, temporaryStorageKey,
now + uploadTtlMs, now + uploadTtlMs,
now, now,
input.sourceSessionId ?? input.source_session_id ?? null,
input.sourceMessageId ?? input.source_message_id ?? null,
], ],
); );
await conn.commit(); await conn.commit();
@@ -733,6 +737,16 @@ export function createAssetService(pool, options = {}) {
if (finalStorageKey !== upload.temporary_storage_key) { if (finalStorageKey !== upload.temporary_storage_key) {
await fs.rm(temporaryPath, { force: true }).catch(() => {}); await fs.rm(temporaryPath, { force: true }).catch(() => {});
} }
await registerUploadArtifactForConversation({
registry: conversationPackageRegistry,
userId,
upload,
asset: result,
assetId,
storageKey: finalStorageKey,
canonicalUrl: result.publicUrl,
now,
});
return result; return result;
} catch (error) { } catch (error) {
await conn.rollback(); 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 = { export const assetInternals = {
detectMimeType, detectMimeType,
normalizeFilename, normalizeFilename,
expectedMimeType, expectedMimeType,
assetTypeForMime, assetTypeForMime,
registerUploadArtifactForConversation,
}; };
+100 -1
View File
@@ -4,7 +4,7 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import sharp from 'sharp'; 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'; import { DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs';
function createMockPool(state) { function createMockPool(state) {
@@ -40,6 +40,8 @@ function createMockPool(state) {
temporary_storage_key: params[8], temporary_storage_key: params[8],
status: 'reserved', status: 'reserved',
expires_at: params[9], expires_at: params[9],
source_session_id: params[11] ?? null,
source_message_id: params[12] ?? null,
}); });
return [[]]; return [[]];
} }
@@ -242,6 +244,103 @@ test('completeUpload marks safe files ready and blocks script payloads', async (
assert.equal(asset.scanStatus, 'blocked'); 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 () => { test('completeUpload publishes public images to the public temp image library', async () => {
const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-assets-')); const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-assets-'));
const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-h5-')); const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-h5-'));
+3
View File
@@ -123,11 +123,14 @@ CREATE TABLE IF NOT EXISTS h5_upload_sessions (
temporary_storage_key VARCHAR(512) NOT NULL, temporary_storage_key VARCHAR(512) NOT NULL,
checksum CHAR(64) NULL, checksum CHAR(64) NULL,
completed_asset_id CHAR(36) 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', status ENUM('reserved', 'uploaded', 'completed', 'cancelled', 'expired', 'failed') NOT NULL DEFAULT 'reserved',
expires_at BIGINT NOT NULL, expires_at BIGINT NOT NULL,
created_at BIGINT NOT NULL, created_at BIGINT NOT NULL,
completed_at BIGINT NULL, completed_at BIGINT NULL,
KEY idx_h5_upload_user_status (user_id, status, expires_at), 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_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_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 CONSTRAINT fk_h5_upload_category FOREIGN KEY (category_id) REFERENCES h5_space_categories(id) ON DELETE CASCADE
+3
View File
@@ -314,6 +314,7 @@ async function bootstrapUserAuth() {
h5Root: __dirname, h5Root: __dirname,
storageRoot: mindSpaceStorageRoot, storageRoot: mindSpaceStorageRoot,
maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES), maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
conversationPackageRegistry,
}); });
mindSpacePages = createPageService(pool, { mindSpacePages = createPageService(pool, {
h5Root: __dirname, h5Root: __dirname,
@@ -2493,6 +2494,8 @@ api.post('/mindspace/v1/uploads', async (req, res) => {
filename: req.body?.filename, filename: req.body?.filename,
sizeBytes: req.body?.size_bytes, sizeBytes: req.body?.size_bytes,
declaredMimeType: req.body?.declared_mime_type, declaredMimeType: req.body?.declared_mime_type,
sourceSessionId: req.body?.session_id,
sourceMessageId: req.body?.message_id,
}); });
return sendData(res, req, upload, 201); return sendData(res, req, upload, 201);
} catch (error) { } catch (error) {
+8 -1
View File
@@ -693,7 +693,12 @@ export async function listMindSpaceAssets(
export async function uploadMindSpaceAsset( export async function uploadMindSpaceAsset(
categoryId: string, categoryId: string,
file: File, file: File,
options: { maxImageBytes?: number; onProgress?: (progress: number) => void } = {}, options: {
maxImageBytes?: number;
onProgress?: (progress: number) => void;
sessionId?: string | null;
messageId?: string | null;
} = {},
): Promise<MindSpaceAsset> { ): Promise<MindSpaceAsset> {
const maxImageBytes = options.maxImageBytes ?? CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES; const maxImageBytes = options.maxImageBytes ?? CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES;
if (file.type.startsWith('image/') && file.size > maxImageBytes) { if (file.type.startsWith('image/') && file.size > maxImageBytes) {
@@ -710,6 +715,8 @@ export async function uploadMindSpaceAsset(
filename: file.name, filename: file.name,
size_bytes: file.size, size_bytes: file.size,
declared_mime_type: file.type || null, declared_mime_type: file.type || null,
...(options.sessionId ? { session_id: options.sessionId } : {}),
...(options.messageId ? { message_id: options.messageId } : {}),
}), }),
}); });
+1
View File
@@ -580,6 +580,7 @@ export function useTKMindChat(
const asset = await uploadMindSpaceAsset(categoryId, file, { const asset = await uploadMindSpaceAsset(categoryId, file, {
maxImageBytes: CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, maxImageBytes: CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
onProgress, onProgress,
sessionId: sessionRef.current?.id ?? null,
}); });
return buildAbsoluteAssetImageUrl({ return buildAbsoluteAssetImageUrl({
id: asset.id, id: asset.id,