Record uploaded assets in conversation packages
This commit is contained in:
@@ -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,
|
||||
|
||||
+62
-2
@@ -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,
|
||||
};
|
||||
|
||||
+100
-1
@@ -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-'));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+8
-1
@@ -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<MindSpaceAsset> {
|
||||
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 } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user