import fs from 'node:fs'; import path from 'node:path'; import { isDirectChatSessionId, isPortalDirectChatSnapshot, sendDirectChatSessionEvents, shouldExpirePortalDirectChatSnapshot, } from '../direct-chat-service.mjs'; import { resolveAnalyticsOwnerLabel, resolveAnalyticsOwnerSegment, resolveAnalyticsPlan, sendMindSpaceAnalyticsEvent, } from '../mindspace-analytics.mjs'; import { sendMindSpaceRybbitEvent, } from '../mindspace-rybbit.mjs'; import { markPageDeliveryContractReady, preparePageDeliveryContract, } from '../mindspace-delivery-contract.mjs'; import { maybeRepairH5HtmlAfterFinish } from '../mindspace-h5-html-finish-guard.mjs'; import { maybeRepairPageDataAfterFinish } from '../mindspace-page-data-finish-guard.mjs'; import { collectPublicHtmlWritePathsFromSessionEvent, materializePublicHtmlWritesFromSessionEvent, syncPublicHtmlAfterFinish, } from '../mindspace-public-finish-sync.mjs'; import { resolveMindSpaceRuntimeConfig, resolveMindSpaceUserPublishDir, } from '../mindspace-runtime-config.mjs'; import { repairSessionConversationFromDb, } from '../conversation-repair.mjs'; import { filterNonemptyUserVisibleMessages } from '../conversation-transcript-persist.mjs'; import { sanitizeSessionConversationPublicHtmlLinks } from '../tkmind-proxy.mjs'; import { PUBLISH_ROOT_DIR } from '../user-publish.mjs'; function assertRouter(api) { if ( !api || typeof api.get !== 'function' || typeof api.delete !== 'function' ) { throw new Error( 'attachPortalSessionRoutes requires an Express-compatible router', ); } } export function attachPortalSessionRoutes( api, { h5Root = '', env = process.env, waitForUserAuthReady = async () => {}, getUserAuth = () => null, getTkmindProxy = () => null, getSessionSnapshotService = () => null, getAuthPool = () => null, getMindSpaceAssets = () => null, getMemoryV2 = () => null, getMindSpaceAnalyticsConfig = () => null, getMindSpaceRybbitConfig = () => null, isWorkspaceMaintenanceEnabled = () => false, ownsAgentSession, unregisterAgentSessionForUser, beginSessionPageDelivery, endSessionPageDelivery, syncUserGeneratedPages, registerPublicHtmlArtifactsForConversation, isDirectChatSessionIdFn = isDirectChatSessionId, isPortalDirectChatSnapshotFn = isPortalDirectChatSnapshot, sendDirectChatSessionEventsFn = sendDirectChatSessionEvents, shouldExpirePortalDirectChatSnapshotFn = shouldExpirePortalDirectChatSnapshot, sanitizeSessionConversationFn = sanitizeSessionConversationPublicHtmlLinks, repairSessionConversationFromDbFn = repairSessionConversationFromDb, filterNonemptyUserVisibleMessagesFn = filterNonemptyUserVisibleMessages, resolveMindSpaceUserPublishDirFn = resolveMindSpaceUserPublishDir, collectPublicHtmlWritePathsFn = collectPublicHtmlWritePathsFromSessionEvent, preparePageDeliveryContractFn = preparePageDeliveryContract, materializePublicHtmlWritesFn = materializePublicHtmlWritesFromSessionEvent, sendMindSpaceAnalyticsEventFn = sendMindSpaceAnalyticsEvent, sendMindSpaceRybbitEventFn = sendMindSpaceRybbitEvent, resolveAnalyticsOwnerSegmentFn = resolveAnalyticsOwnerSegment, resolveAnalyticsOwnerLabelFn = resolveAnalyticsOwnerLabel, resolveAnalyticsPlanFn = resolveAnalyticsPlan, syncPublicHtmlAfterFinishFn = syncPublicHtmlAfterFinish, resolveMindSpaceRuntimeConfigFn = resolveMindSpaceRuntimeConfig, maybeRepairH5HtmlAfterFinishFn = maybeRepairH5HtmlAfterFinish, maybeRepairPageDataAfterFinishFn = maybeRepairPageDataAfterFinish, markPageDeliveryContractReadyFn = markPageDeliveryContractReady, fileExists = fs.existsSync, resolvePath = path.resolve, publishRootDir = PUBLISH_ROOT_DIR, logger = console, } = {}, ) { assertRouter(api); if ( typeof ownsAgentSession !== 'function' || typeof unregisterAgentSessionForUser !== 'function' || typeof beginSessionPageDelivery !== 'function' || typeof endSessionPageDelivery !== 'function' || typeof syncUserGeneratedPages !== 'function' || typeof registerPublicHtmlArtifactsForConversation !== 'function' ) { throw new Error( 'attachPortalSessionRoutes requires session route dependencies', ); } // Session detail — serve from DB snapshot cache when fresh, fall through to Goose on miss. api.get('/sessions/:sessionId', async (req, res, next) => { await waitForUserAuthReady(); const userAuth = getUserAuth(); const tkmindProxy = getTkmindProxy(); if (!userAuth || !tkmindProxy) return next(); const sessionId = req.params.sessionId; const owns = await ownsAgentSession( req.currentUser.id, sessionId, ); if (!owns) { return res.status(403).json({ message: '无权访问该会话' }); } // Hints from the client (session list already has these values). const hintMc = req.query.hint_mc ? Number(req.query.hint_mc) : null; const hintUa = req.query.hint_ua ? String(req.query.hint_ua) : null; const snapshotService = getSessionSnapshotService(); const authPool = getAuthPool(); try { if (snapshotService?.isEnabled()) { let snapshot = await snapshotService.get(sessionId); if (snapshot) { if ( authPool && (await shouldExpirePortalDirectChatSnapshotFn( authPool, sessionId, snapshot, )) ) { await snapshotService.remove(sessionId).catch(() => {}); snapshot = null; } } if (snapshot) { // REGRESSION GUARD: without both hints, stale snapshot can wipe mid-turn chat. const canUseSnapshotCache = hintMc != null && hintUa != null; const mcMatch = snapshot.meta.synced_msg_count === hintMc; const uaMatch = snapshot.meta.source_updated_at === hintUa; if ( isDirectChatSessionIdFn(sessionId) || isPortalDirectChatSnapshotFn(snapshot, { sessionId }) || (canUseSnapshotCache && mcMatch && uaMatch) ) { const sanitizedMessages = sanitizeSessionConversationFn( snapshot.messages, req.currentUser, ); // Cache hit — reconstruct a Goose-compatible session response. let cachedGooseSession = { ...snapshot.session, // Embed only userVisible messages so getSession callers still work. conversation: sanitizedMessages, }; if (authPool) { cachedGooseSession = await repairSessionConversationFromDbFn( authPool, cachedGooseSession, sessionId, req.currentUser.id, ); } return res.json(cachedGooseSession); } } } } catch { // Snapshot read error: fall through silently to Goose. } // Cache miss — proxy to Goose and write-through on success. try { const target = await tkmindProxy.resolveTarget(sessionId); const upstream = await tkmindProxy.apiFetchTo( target, `/sessions/${encodeURIComponent(sessionId)}`, { method: 'GET' }, ); if (!upstream.ok) { const text = await upstream.text().catch(() => ''); return res.status(upstream.status).send(text); } let gooseSession = await upstream.json(); if (Array.isArray(gooseSession.conversation)) { gooseSession.conversation = sanitizeSessionConversationFn( gooseSession.conversation, req.currentUser, ); } if (authPool) { gooseSession = await repairSessionConversationFromDbFn( authPool, gooseSession, sessionId, req.currentUser.id, ); } // Write-through: persist snapshot async, don't block the response. if (snapshotService?.isEnabled()) { const messages = filterNonemptyUserVisibleMessagesFn( gooseSession.conversation ?? [], ); if (messages.length > 0) { void snapshotService .save( sessionId, req.currentUser.id, gooseSession, messages, ) .catch(() => {}); } } return res.json(gooseSession); } catch (error) { return res.status(502).json({ message: error instanceof Error ? error.message : '读取会话失败', }); } }); api.delete('/sessions/:sessionId', async (req, res, next) => { await waitForUserAuthReady(); const userAuth = getUserAuth(); const tkmindProxy = getTkmindProxy(); if (!userAuth || !tkmindProxy) return next(); const sessionId = req.params.sessionId; const owns = await ownsAgentSession( req.currentUser.id, sessionId, ); if (!owns) { return res.status(403).json({ message: '无权访问该会话' }); } const snapshotService = getSessionSnapshotService(); try { if (isDirectChatSessionIdFn(sessionId)) { await unregisterAgentSessionForUser( req.currentUser.id, sessionId, ); void snapshotService?.remove(sessionId).catch(() => {}); return res.status(204).end(); } const deleteTarget = await tkmindProxy.resolveTarget(sessionId); const upstream = await tkmindProxy.apiFetchTo( deleteTarget, `/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' }, ); if (!upstream.ok && upstream.status !== 404) { const text = await upstream.text().catch(() => ''); return res .status(upstream.status) .send(text || '删除会话失败'); } await unregisterAgentSessionForUser( req.currentUser.id, sessionId, ); // Remove snapshot so it doesn't linger after deletion. void snapshotService?.remove(sessionId).catch(() => {}); return res.status(204).end(); } catch (error) { return res.status(500).json({ message: error instanceof Error ? error.message : '删除会话失败', }); } }); api.get( '/sessions/:sessionId/events', async (req, res, next) => { await waitForUserAuthReady(); const userAuth = getUserAuth(); const tkmindProxy = getTkmindProxy(); if (!userAuth || !tkmindProxy) return next(); const sessionId = req.params.sessionId; const owns = await ownsAgentSession( req.currentUser.id, sessionId, ); if (!owns) { return res .status(403) .json({ message: '无权访问该会话' }); } const snapshotService = getSessionSnapshotService(); if (isDirectChatSessionIdFn(sessionId)) { const snapshot = await snapshotService ?.get(sessionId) .catch(() => null); if (!snapshot) { return res.status(404).json({ message: '会话不存在' }); } return sendDirectChatSessionEventsFn(req, res, snapshot); } const portalDirectSnapshot = await snapshotService ?.get(sessionId) .catch(() => null); const authPool = getAuthPool(); if ( portalDirectSnapshot && authPool && (await shouldExpirePortalDirectChatSnapshotFn( authPool, sessionId, portalDirectSnapshot, )) ) { await snapshotService ?.remove(sessionId) .catch(() => {}); } else if ( portalDirectSnapshot && isPortalDirectChatSnapshotFn( portalDirectSnapshot, { sessionId }, ) ) { return sendDirectChatSessionEventsFn( req, res, portalDirectSnapshot, ); } const publishDir = resolveMindSpaceUserPublishDirFn( h5Root, { id: req.currentUser.id }, ); // `proxySessionEvents` deliberately invokes `onEvent` synchronously so an // async callback here would leave rejected database writes unhandled. // Retain every in-flight contract write and await it before marking files // deliverable after Finish. const deliveryContractWrites = new Map(); const generationAnalyticsEvents = new Set(); const syncPublicHtmlDuringStream = (event) => { const paths = collectPublicHtmlWritePathsFn(event, { publishDir, }); const eventMessages = event?.type === 'Message' && event.message ? [event.message] : event?.type === 'UpdateConversation' && Array.isArray(event.conversation) ? event.conversation : []; const pgRequired = eventMessages.some( (message) => message?.role === 'user' && message?.metadata?.memindRun?.pgRequired === true, ); for (const relativePath of paths) { if (deliveryContractWrites.has(relativePath)) continue; const write = preparePageDeliveryContractFn({ pool: authPool, userId: req.currentUser.id, requestId: sessionId, relativePath, pgRequired, }).catch((error) => { logger.warn( `[MindSpace] failed to prepare delivery contract for ${relativePath}: ${error?.message || error}`, ); return null; }); deliveryContractWrites.set(relativePath, write); } materializePublicHtmlWritesFn(event, { publishDir }); for (const relativePath of paths) { const absolutePath = resolvePath( publishDir, relativePath, ); if ( generationAnalyticsEvents.has(relativePath) || !fileExists(absolutePath) ) { continue; } generationAnalyticsEvents.add(relativePath); const analyticsPayload = { eventName: 'page_generated', ownerId: req.currentUser.id, ownerSegment: resolveAnalyticsOwnerSegmentFn(req.currentUser), ownerLabel: resolveAnalyticsOwnerLabelFn(req.currentUser), planType: resolveAnalyticsPlanFn(req.currentUser), generatedAt: new Date().toISOString(), pageId: relativePath, publicationId: sessionId, agentRunId: sessionId, channel: 'h5', url: `/${publishRootDir}/${req.currentUser.id}/${relativePath}`, }; void sendMindSpaceAnalyticsEventFn({ config: getMindSpaceAnalyticsConfig(), ...analyticsPayload, }); void sendMindSpaceRybbitEventFn({ config: getMindSpaceRybbitConfig(), ...analyticsPayload, }); } }; // After Finish, refresh the snapshot and persist any newly generated public // workspace HTML into the asset store before a later restart rebuilds the // workspace from DB-backed assets only. const onAfterFinish = async (sid, uid) => { beginSessionPageDelivery(sid); try { const apiFetchFn = async (pathname, init) => { const target = await tkmindProxy.resolveTarget(sid); return tkmindProxy.apiFetchTo( target, pathname, init, ); }; let messages = null; if (snapshotService?.isEnabled()) { await snapshotService.refresh( sid, uid, apiFetchFn, ); messages = (await snapshotService.get(sid))?.messages ?? null; } else { try { const upstream = await apiFetchFn( `/sessions/${encodeURIComponent(sid)}`, { method: 'GET' }, ); if (upstream.ok) { const payload = await upstream .json() .catch(() => null); messages = Array.isArray(payload?.conversation) ? payload.conversation.filter( (message) => message?.metadata?.userVisible, ) : null; } } catch { messages = null; } } const { storageRoot } = resolveMindSpaceRuntimeConfigFn(h5Root, env); const syncResult = await syncPublicHtmlAfterFinishFn({ messages, currentUser: req.currentUser, publishDir, sessionId: sid, pool: authPool, storageRoot, h5Root, syncWorkspaceAssets: isWorkspaceMaintenanceEnabled() && getMindSpaceAssets() ? (userId, options) => getMindSpaceAssets().syncWorkspaceAssets( userId, options, ) : null, registerPublicHtmlArtifacts: (_userId, options) => registerPublicHtmlArtifactsForConversation({ user: req.currentUser, publishDir, sessionId: options?.sessionId, relativePaths: options?.relativePaths, artifactRefs: options?.artifactRefs, }), }); if ( Array.isArray(syncResult?.docxSync?.missing) && syncResult.docxSync.missing.length > 0 ) { logger.warn( `[MindSpace] missing public download files after finish for user ${uid}: ${syncResult.docxSync.missing.join(', ')}`, ); } const htmlDelivery = await maybeRepairH5HtmlAfterFinishFn({ sessionId: sid, userId: uid, currentUser: req.currentUser, messages, publishDir, syncResult, tkmindProxy, }); const lastUserMessage = [ ...(Array.isArray(messages) ? messages : []), ] .reverse() .find((message) => message?.role === 'user'); const lastUserText = typeof lastUserMessage?.content === 'string' ? lastUserMessage.content : Array.isArray(lastUserMessage?.content) ? lastUserMessage.content .filter((item) => item?.type === 'text') .map((item) => String(item.text ?? '').trim(), ) .filter(Boolean) .join('\n') : ''; await syncUserGeneratedPages(uid, { sessionId: sid, }); const pageDataDelivery = await maybeRepairPageDataAfterFinishFn({ sessionId: sid, userId: uid, publishDir, messages, pool: authPool, h5Root, storageRoot, tkmindProxy, userText: lastUserText, }); const htmlReady = htmlDelivery?.skipped === 'ok'; const pageDataReady = [ 'ok', 'not_page_data', ].includes( String(pageDataDelivery?.skipped ?? ''), ); if (htmlReady && pageDataReady) { const publicHtmlRelativePaths = [ ...new Set([ ...(syncResult?.publicHtmlRelativePaths ?? []), ...deliveryContractWrites.keys(), ]), ].sort(); const pgRequired = [ ...(Array.isArray(messages) ? messages : []), ].some( (message) => message?.role === 'user' && message?.metadata?.memindRun?.pgRequired === true, ); for (const relativePath of publicHtmlRelativePaths) { // A Finish-only write may not have reached the stream callback. This // also upgrades an early partial stream contract with the definitive // user delivery choice before it becomes ready. await ( deliveryContractWrites.get(relativePath) ?? preparePageDeliveryContractFn({ pool: authPool, userId: uid, requestId: sid, relativePath, pgRequired, }) ); if ( deliveryContractWrites.has(relativePath) ) { await preparePageDeliveryContractFn({ pool: authPool, userId: uid, requestId: sid, relativePath, pgRequired, }); } await markPageDeliveryContractReadyFn({ pool: authPool, userId: uid, relativePath, }).catch(() => false); } } const memoryV2 = getMemoryV2(); if ( lastUserMessage && memoryV2?.observePersonalMemory ) { await memoryV2 .observePersonalMemory({ userId: uid, sessionId: sid, messages: [lastUserMessage], }) .catch((error) => { logger.warn( `[memory-v2] finish shadow observation skipped for session ${sid}: ${error instanceof Error ? error.message : error}`, ); }); } } finally { endSessionPageDelivery(sid); } }; return tkmindProxy.proxySessionEvents( req, res, sessionId, { onAfterFinish, onEvent: syncPublicHtmlDuringStream, }, ); }, ); }