diff --git a/mindspace-assets.mjs b/mindspace-assets.mjs index cb22e25..7fc24a3 100644 --- a/mindspace-assets.mjs +++ b/mindspace-assets.mjs @@ -301,6 +301,7 @@ export function createAssetService(pool, options = {}) { h5Root, maxFileBytes, idFactory, + conversationPackageRegistry, }); const mirrorToUserWorkspace = async (userId, { categoryCode, filename, sourcePath }) => { diff --git a/mindspace-public-finish-sync.mjs b/mindspace-public-finish-sync.mjs index 37958e7..a2ae5fc 100644 --- a/mindspace-public-finish-sync.mjs +++ b/mindspace-public-finish-sync.mjs @@ -386,6 +386,7 @@ export async function syncPublicHtmlAfterFinish({ currentUser, publishDir, syncWorkspaceAssets, + sessionId = null, } = {}) { if (!hasRecentOwnPublicHtmlReference(messages, currentUser, { publishDir })) { return { materialized: [], skipped: [], synced: false }; @@ -395,7 +396,7 @@ export async function syncPublicHtmlAfterFinish({ const docxSync = syncPublicDocxDownloads({ publishDir }); let synced = false; if (typeof syncWorkspaceAssets === 'function' && currentUser?.id) { - await syncWorkspaceAssets(currentUser.id, { categoryCode: 'public' }); + await syncWorkspaceAssets(currentUser.id, { categoryCode: 'public', sourceSessionId: sessionId }); synced = true; } return { materialized, skipped, synced, docxSync }; diff --git a/mindspace-public-finish-sync.test.mjs b/mindspace-public-finish-sync.test.mjs index dd258b0..75b03e0 100644 --- a/mindspace-public-finish-sync.test.mjs +++ b/mindspace-public-finish-sync.test.mjs @@ -10,6 +10,7 @@ import { materializeMissingPublicHtmlWrites, materializePublicHtmlWritesFromSessionEvent, syncPublicDocxDownloads, + syncPublicHtmlAfterFinish, } from './mindspace-public-finish-sync.mjs'; const CURRENT_USER = { id: 'a6fb1e97-2b0f-447b-b138-4561d8e5c53e', username: 'john' }; @@ -151,6 +152,46 @@ test('materializeMissingPublicHtmlWrites writes html from sandbox write_file too } }); +test('syncPublicHtmlAfterFinish forwards session source to workspace sync', async () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-')); + try { + const calls = []; + const messages = [ + { + role: 'assistant', + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'write_file', + arguments: { + path: 'public/session-page.html', + content: 'Session page', + }, + }, + }, + }, + ], + }, + ]; + const result = await syncPublicHtmlAfterFinish({ + messages, + currentUser: CURRENT_USER, + publishDir, + sessionId: 'session-1', + syncWorkspaceAssets: async (...args) => { + calls.push(args); + }, + }); + + assert.equal(result.synced, true); + assert.deepEqual(calls, [[CURRENT_USER.id, { categoryCode: 'public', sourceSessionId: 'session-1' }]]); + } finally { + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); + test('materializeMissingPublicHtmlWrites writes html from developer write tool calls', () => { const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-')); try { diff --git a/mindspace-workspace-sync.mjs b/mindspace-workspace-sync.mjs index d47df32..2812d14 100644 --- a/mindspace-workspace-sync.mjs +++ b/mindspace-workspace-sync.mjs @@ -39,6 +39,19 @@ function asNumber(value) { return Number(value ?? 0); } +function generatedArtifactKindForMime(mimeType, filename) { + const normalizedMime = String(mimeType ?? '').toLowerCase(); + const normalizedName = String(filename ?? '').toLowerCase(); + if (normalizedMime.startsWith('image/')) return 'generated_image'; + if (normalizedName.endsWith('.docx') || normalizedName.endsWith('.doc')) return 'docx'; + if (normalizedName.endsWith('.pdf')) return 'pdf'; + return 'generated_file'; +} + +function workspaceAssetDownloadUrl(assetId) { + return `/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download`; +} + export function normalizeWorkspaceRelativePath(relativePath) { const normalized = String(relativePath ?? '') .normalize('NFKC') @@ -100,7 +113,14 @@ export async function listWorkspaceZoneFiles(workspaceRoot, categoryCode) { return files; } -export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes, idFactory }) { +export function createWorkspaceAssetSync({ + pool, + storageRoot, + h5Root, + maxFileBytes, + idFactory, + conversationPackageRegistry = null, +}) { void storageRoot; const loadExistingAssets = async (userId, categoryId) => { const [rows] = await pool.query( @@ -221,7 +241,16 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt [buffer.length, now, category.space_id, userId], ); await conn.commit(); - return { action: 'imported', assetId, filename: file.filename, checksum }; + return { + action: 'imported', + assetId, + filename: file.filename, + checksum, + mimeType: detectedMimeType, + sizeBytes: buffer.length, + storageKey: finalStorageKey, + categoryCode: category.category_code, + }; } catch (error) { await conn.rollback(); throw error; @@ -323,7 +352,16 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt ); } await conn.commit(); - return { action: 'updated', assetId: existing.id, filename: file.filename, checksum }; + return { + action: 'updated', + assetId: existing.id, + filename: file.filename, + checksum, + mimeType: detectedMimeType, + sizeBytes: buffer.length, + storageKey: finalStorageKey, + categoryCode: category.category_code, + }; } catch (error) { await conn.rollback(); throw error; @@ -332,7 +370,40 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt } }; - const syncCategory = async (userId, categoryCode) => { + const registerWorkspaceArtifactForConversation = async (userId, source, result, now = Date.now()) => { + const sessionId = String(source?.sessionId ?? source?.sourceSessionId ?? '').trim(); + if (!conversationPackageRegistry || !sessionId || !result?.assetId) return null; + try { + const packageRecord = await conversationPackageRegistry.ensurePackage({ + userId, + sessionId, + title: source?.title ?? null, + now, + }); + const artifact = await conversationPackageRegistry.recordArtifact({ + id: `ca_workspace_${result.assetId}`, + packageId: packageRecord.id, + artifactKind: generatedArtifactKindForMime(result.mimeType, result.filename), + role: 'assistant', + assetId: result.assetId, + messageId: source?.messageId ?? source?.sourceMessageId ?? null, + displayName: result.filename, + mimeType: result.mimeType, + sizeBytes: result.sizeBytes, + storageKey: result.storageKey, + canonicalUrl: workspaceAssetDownloadUrl(result.assetId), + sortOrder: now, + now, + }); + await conversationPackageRegistry.writeManifestForSession({ userId, sessionId }); + return artifact; + } catch (error) { + console.warn('[MindSpace] workspace conversation artifact registration failed:', error?.message ?? error); + return null; + } + }; + + const syncCategory = async (userId, categoryCode, source = {}) => { if (!h5Root) return { imported: 0, updated: 0, skipped: 0 }; const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId }); const files = await listWorkspaceZoneFiles(workspaceRoot, categoryCode); @@ -369,11 +440,13 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt continue; } if (existing) { - await updateWorkspaceFile(userId, category, existing, file, buffer); + const result = await updateWorkspaceFile(userId, category, existing, file, buffer); + await registerWorkspaceArtifactForConversation(userId, source, result); existing.checksum = checksum; updated += 1; } else { - await importWorkspaceFile(userId, category, file, buffer); + const result = await importWorkspaceFile(userId, category, file, buffer); + await registerWorkspaceArtifactForConversation(userId, source, result); existingByName.set(file.filename, { checksum }); imported += 1; } @@ -381,13 +454,14 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt return { imported, updated, skipped }; }; - const syncUserWorkspace = async (userId, { categoryCode } = {}) => { + const syncUserWorkspace = async (userId, { categoryCode, sourceSessionId, sourceMessageId, title } = {}) => { const codes = categoryCode ? [categoryCode] : UPLOAD_ZONE_CODES; let imported = 0; let updated = 0; let skipped = 0; + const source = { sessionId: sourceSessionId, messageId: sourceMessageId, title }; for (const code of codes) { - const result = await syncCategory(userId, code); + const result = await syncCategory(userId, code, source); imported += result.imported; updated += result.updated; skipped += result.skipped; @@ -398,6 +472,11 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt return { syncUserWorkspace, syncCategory, listWorkspaceZoneFiles }; } +export const workspaceSyncInternals = { + generatedArtifactKindForMime, + workspaceAssetDownloadUrl, +}; + export function startWorkspaceAssetSyncWatcher({ publishRoot, syncUserWorkspaceByDirKey }) { if (!publishRoot || !syncUserWorkspaceByDirKey) return () => {}; diff --git a/mindspace-workspace-sync.test.mjs b/mindspace-workspace-sync.test.mjs index 09f19c5..260a0c3 100644 --- a/mindspace-workspace-sync.test.mjs +++ b/mindspace-workspace-sync.test.mjs @@ -142,21 +142,65 @@ test('syncUserWorkspace imports new workspace files into asset library', async ( }, }; + const packageCalls = []; + const conversationPackageRegistry = { + async ensurePackage(input) { + packageCalls.push(['ensurePackage', input]); + return { id: 'cp-session-1' }; + }, + async recordArtifact(input) { + packageCalls.push(['recordArtifact', input]); + return input; + }, + async writeManifestForSession(input) { + packageCalls.push(['writeManifestForSession', input]); + return input; + }, + }; const sync = createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes: 1024 * 1024, idFactory: () => `id-${++nextId}`, + conversationPackageRegistry, }); - const result = await sync.syncUserWorkspace('user-1', { categoryCode: 'oa' }); + const result = await sync.syncUserWorkspace('user-1', { + categoryCode: 'oa', + sourceSessionId: 'session-1', + sourceMessageId: 'message-1', + title: 'Workspace chat', + }); assert.equal(result.imported, 1); assert.equal(state.assets.length, 1); assert.equal(state.assets[0].original_filename, 'memo.txt'); assert.ok(state.versions.length === 1); assert.match(state.versions[0].storage_key, /^workspace:\/\/user-1\/oa\/memo\.txt$/); assert.equal(await fs.readFile(path.join(workspace, 'oa', 'memo.txt'), 'utf8'), 'hello workspace\n'); + assert.deepEqual(packageCalls[0], [ + 'ensurePackage', + { + userId: 'user-1', + sessionId: 'session-1', + title: 'Workspace chat', + now: packageCalls[0][1].now, + }, + ]); + const artifactCall = packageCalls.find(([name]) => name === 'recordArtifact')?.[1]; + assert.equal(artifactCall.packageId, 'cp-session-1'); + assert.equal(artifactCall.artifactKind, 'generated_file'); + assert.equal(artifactCall.role, 'assistant'); + assert.equal(artifactCall.assetId, 'id-1'); + assert.equal(artifactCall.messageId, 'message-1'); + assert.equal(artifactCall.displayName, 'memo.txt'); + assert.equal(artifactCall.mimeType, 'text/plain'); + assert.equal(artifactCall.storageKey, 'workspace://user-1/oa/memo.txt'); + assert.equal(artifactCall.canonicalUrl, '/api/mindspace/v1/assets/id-1/download'); + assert.deepEqual(packageCalls.at(-1), [ + 'writeManifestForSession', + { userId: 'user-1', sessionId: 'session-1' }, + ]); }); test('syncUserWorkspace imports nested workspace files into asset library', async () => { diff --git a/server.mjs b/server.mjs index c9752af..6e5d92a 100644 --- a/server.mjs +++ b/server.mjs @@ -4508,6 +4508,7 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => { messages, currentUser: req.currentUser, publishDir, + sessionId: sid, syncWorkspaceAssets: WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets ? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options)