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
|
) 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
@@ -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
@@ -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-'));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 } : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user