import path from 'node:path'; import { analyzeChatMessageForSave, buildWorkspaceBaseHref, createAssetDataUriResolver, injectHtmlBaseHref, resolveStaticHtmlContent, } from '../mindspace-chat-save.mjs'; import { DOCX_MIME_TYPE, registerChatDocxArtifactForConversation, } from '../mindspace-chat-docx-package.mjs'; import { scanContent } from '../mindspace-content-scan.mjs'; import { generateDocxBuffer } from '../mindspace-docx-export.mjs'; import { normalizeWorkspaceRelativePath } from '../mindspace-pages.mjs'; import { resolveMindSpaceRuntimeConfig, resolveMindSpaceUserPublishDir, } from '../mindspace-runtime-config.mjs'; import { generateHtmlThumbnail } from '../mindspace-thumbnails.mjs'; import { ensureWorkspaceHtmlThumbnail, workspaceThumbnailRelativePath, } from '../mindspace-workspace-thumbnails.mjs'; const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'public']); function assertRouter(api) { if ( !api || typeof api.get !== 'function' || typeof api.post !== 'function' ) { throw new Error( 'attachPortalMindSpaceChatSaveRoutes requires an Express-compatible router', ); } } export function attachPortalMindSpaceChatSaveRoutes( api, { h5Root = '', env = process.env, getMindSpacePages = () => null, getMindSpaceAssets = () => null, getAuthPool = () => null, getConversationPackageRegistry = () => null, resolveChatSaveBundle, resolveExistingSavedPage, resolveOwnedAssistantMessage, sendData = (res, _req, data, status = 200) => res.status(status).json({ data }), handleMindSpaceError = (_res, _req, error) => { throw error; }, buildWorkspaceBaseHrefFn = buildWorkspaceBaseHref, injectHtmlBaseHrefFn = injectHtmlBaseHref, resolveMindSpaceUserPublishDirFn = resolveMindSpaceUserPublishDir, workspaceThumbnailRelativePathFn = workspaceThumbnailRelativePath, ensureWorkspaceHtmlThumbnailFn = ensureWorkspaceHtmlThumbnail, resolveMindSpaceRuntimeConfigFn = resolveMindSpaceRuntimeConfig, createAssetDataUriResolverFn = createAssetDataUriResolver, generateHtmlThumbnailFn = generateHtmlThumbnail, analyzeChatMessageForSaveFn = analyzeChatMessageForSave, resolveStaticHtmlContentFn = resolveStaticHtmlContent, normalizeWorkspaceRelativePathFn = normalizeWorkspaceRelativePath, scanContentFn = scanContent, generateDocxBufferFn = generateDocxBuffer, registerChatDocxArtifactFn = registerChatDocxArtifactForConversation, logger = console, } = {}, ) { assertRouter(api); if ( typeof resolveChatSaveBundle !== 'function' || typeof resolveExistingSavedPage !== 'function' || typeof resolveOwnedAssistantMessage !== 'function' ) { throw new Error( 'attachPortalMindSpaceChatSaveRoutes requires chat save resolvers', ); } api.get('/mindspace/v1/pages/chat-save-preview', async (req, res) => { if (!getMindSpacePages()) { return res.status(503).json({ message: 'MindSpace 未启用' }); } try { const bundle = await resolveChatSaveBundle( req.currentUser, h5Root, req.query, ); if (!bundle.resolvedHtml) { throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found', }); } const baseHref = buildWorkspaceBaseHrefFn( req.currentUser.id, bundle.resolvedHtml.relativePath, ); const html = injectHtmlBaseHrefFn( bundle.resolvedHtml.content, baseHref, ); res.set('Content-Type', 'text/html; charset=utf-8'); res.set('Cache-Control', 'private, no-store'); res.setHeader('X-Request-Id', req.requestId); return res.send(html); } catch (error) { return handleMindSpaceError(res, req, error); } }); api.get('/mindspace/v1/pages/chat-save-thumbnail', async (req, res) => { if (!getMindSpacePages()) { return res.status(503).json({ message: 'MindSpace 未启用' }); } try { const bundle = await resolveChatSaveBundle( req.currentUser, h5Root, req.query, ); if (!bundle.resolvedHtml) { throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found', }); } const publishDir = resolveMindSpaceUserPublishDirFn( h5Root, req.currentUser, ); const thumbRel = workspaceThumbnailRelativePathFn( bundle.resolvedHtml.relativePath, ); const title = bundle.previewTitle || bundle.resolvedHtml.suggestedTitle || bundle.analysis.suggestedTitle; const subtitle = bundle.previewSummary || bundle.resolvedHtml.suggestedSummary || bundle.analysis.suggestedSummary; await ensureWorkspaceHtmlThumbnailFn( publishDir, bundle.resolvedHtml.relativePath, bundle.resolvedHtml.content, { title, subtitle, force: true }, ).catch(() => {}); const { storageRoot } = resolveMindSpaceRuntimeConfigFn(h5Root, env); const resolveAssetDataUri = createAssetDataUriResolverFn( getAuthPool(), storageRoot, req.currentUser.id, ); const svg = await generateHtmlThumbnailFn( publishDir, thumbRel, bundle.resolvedHtml.content, { title, subtitle, contentBaseDir: path.dirname(bundle.resolvedHtml.absolute), resolveAssetDataUri, force: true, }, ); res.set('Content-Type', 'image/svg+xml; charset=utf-8'); res.set('Cache-Control', 'private, no-store'); res.setHeader('X-Request-Id', req.requestId); return res.send(svg); } catch (error) { return handleMindSpaceError(res, req, error); } }); api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => { const pages = getMindSpacePages(); if (!pages) { return res.status(503).json({ message: 'MindSpace 未启用' }); } try { const sessionId = req.body?.session_id; const messageId = req.body?.message_id; const bundle = await resolveChatSaveBundle( req.currentUser, h5Root, req.body, ); const { source, analysis, resolvedHtml, previewTitle, previewSummary, previewFrameUrl, thumbnailUrl, localPreviewUrl, localThumbnailUrl, } = bundle; let thumbnailReady = false; if (resolvedHtml?.content && resolvedHtml.relativePath) { const publishDir = resolveMindSpaceUserPublishDirFn( h5Root, req.currentUser, ); try { await ensureWorkspaceHtmlThumbnailFn( publishDir, resolvedHtml.relativePath, resolvedHtml.content, { title: previewTitle || resolvedHtml.suggestedTitle, subtitle: previewSummary || resolvedHtml.suggestedSummary, force: Boolean(previewTitle || previewSummary), }, ); thumbnailReady = true; } catch { thumbnailReady = false; } } const existingPage = await resolveExistingSavedPage( req.currentUser.id, { sessionId, messageId, relativePath: resolvedHtml?.relativePath ?? analysis.relativePath, }, ); return sendData(res, req, { contentMode: analysis.contentMode, links: analysis.links, selectedLinkIndex: analysis.selectedLink ? analysis.links.findIndex( (link) => link.publicUrl === analysis.selectedLink.publicUrl, ) : -1, suggestedTitle: resolvedHtml?.suggestedTitle ?? analysis.suggestedTitle, suggestedSummary: resolvedHtml?.suggestedSummary ?? analysis.suggestedSummary, previewUrl: analysis.previewUrl, previewFrameUrl, localPreviewUrl, localThumbnailUrl, thumbnailUrl: resolvedHtml ? thumbnailUrl : null, thumbnailReady, relativePath: resolvedHtml?.relativePath ?? analysis.relativePath, filename: resolvedHtml?.filename ?? analysis.filename, hasHtmlContent: Boolean(resolvedHtml), privacyScan: scanContentFn( resolvedHtml?.content ?? source.content, ), existingPage: existingPage ? { id: existingPage.id, title: existingPage.title, categoryCode: existingPage.categoryCode, } : null, }); } catch (error) { return handleMindSpaceError(res, req, error); } }); const handleChatSaveDocx = async (req, res) => { if (!getMindSpacePages()) { return res.status(503).json({ message: 'MindSpace 未启用' }); } try { const bundle = await resolveChatSaveBundle( req.currentUser, h5Root, req.body, ); const title = bundle.previewTitle || bundle.resolvedHtml?.suggestedTitle || bundle.analysis?.suggestedTitle || 'AI 创作文档'; const content = bundle.resolvedHtml?.content ?? bundle.source.content; const contentFormat = bundle.resolvedHtml?.content ? 'html' : 'markdown'; const buffer = generateDocxBufferFn({ title, content, contentFormat, }); const filenameBase = String(title) .replace(/[\\/:*?"<>|]+/g, '-') .replace(/\s+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 60) || 'mindspace-document'; const filename = `${filenameBase}.docx`; await registerChatDocxArtifactFn({ registry: getConversationPackageRegistry(), user: req.currentUser, bundle, requestBody: req.body, filename, buffer, }).catch((error) => { logger.warn( '[MindSpace] failed to record chat docx artifact:', error?.message ?? error, ); }); res.set('Content-Type', DOCX_MIME_TYPE); res.set( 'Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"; ` + `filename*=UTF-8''${encodeURIComponent(filename)}`, ); res.set('Cache-Control', 'no-store'); res.setHeader('X-Request-Id', req.requestId); return res.send(buffer); } catch (error) { return handleMindSpaceError(res, req, error); } }; api.post( '/mindspace/v1/pages/chat-save-docx', handleChatSaveDocx, ); api.post('/v1/pages/chat-save-docx', handleChatSaveDocx); api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => { const pages = getMindSpacePages(); const assets = getMindSpaceAssets(); if (!pages || !assets) { return res.status(503).json({ message: 'MindSpace 未启用' }); } try { const categoryCode = String( req.body?.category_code ?? 'draft', ); if (!SAVE_TARGET_CATEGORIES.has(categoryCode)) { throw Object.assign(new Error('无效的保存目标'), { code: 'invalid_category_code', }); } const source = await resolveOwnedAssistantMessage( req.currentUser, req.body?.session_id, req.body?.message_id, ); const analysis = analyzeChatMessageForSaveFn({ content: source.content, userId: req.currentUser.id, username: req.currentUser.username, h5Root, selectedLinkIndex: Number( req.body?.selected_link_index ?? 0, ), }); const snapshot = { session_name: source.session.name, message_created: source.message.created, role: source.message.role, content_mode: analysis.contentMode, public_url: analysis.previewUrl, relative_path: analysis.relativePath ? normalizeWorkspaceRelativePathFn(analysis.relativePath) : analysis.relativePath, }; let resolvedHtml = null; if (analysis.contentMode === 'static_html') { resolvedHtml = await resolveStaticHtmlContentFn(analysis).catch( () => null, ); } scanContentFn(resolvedHtml?.content ?? source.content); if (categoryCode !== 'draft') { let buffer; let filename; let displayName = req.body?.title; if (analysis.contentMode === 'static_html') { if (!resolvedHtml) { throw Object.assign( new Error('无法读取链接页面内容'), { code: 'static_page_not_found' }, ); } buffer = Buffer.from(resolvedHtml.content, 'utf8'); filename = resolvedHtml.filename; displayName = displayName || resolvedHtml.suggestedTitle; } else { buffer = Buffer.from(source.content, 'utf8'); filename = `${String(displayName || 'chat-export') .replace(/[^\w\u4e00-\u9fff-]+/g, '-') .slice(0, 48) || 'chat-export'}.md`; } const asset = await assets.createChatAsset( req.currentUser.id, { categoryCode, buffer, filename, displayName, sourceType: 'chat', }, ); return res.status(201).json({ data: { kind: 'asset', categoryCode, asset, }, }); } let pageInput = { title: req.body?.title, summary: req.body?.summary, templateId: req.body?.template_id, pageType: req.body?.page_type, categoryCode: 'draft', }; if (analysis.contentMode === 'static_html') { if (!resolvedHtml) { throw Object.assign( new Error('无法读取链接页面内容'), { code: 'static_page_not_found' }, ); } pageInput = { ...pageInput, title: req.body?.title || resolvedHtml.suggestedTitle, summary: req.body?.summary || resolvedHtml.suggestedSummary, content: resolvedHtml.content, contentFormat: 'html', pageType: 'html', }; } else { pageInput = { ...pageInput, content: source.content, contentFormat: 'markdown', }; } if ( analysis.contentMode === 'static_html' && resolvedHtml?.content && analysis.relativePath ) { const publishDir = resolveMindSpaceUserPublishDirFn( h5Root, req.currentUser, ); await ensureWorkspaceHtmlThumbnailFn( publishDir, analysis.relativePath, resolvedHtml.content, { title: pageInput.title, subtitle: pageInput.summary, }, ).catch(() => {}); } const saveAsNew = Boolean(req.body?.save_as_new); let replacePageId = req.body?.replace_page_id ? String(req.body.replace_page_id).trim() : null; if ( !replacePageId && !saveAsNew && analysis.contentMode === 'static_html' && analysis.relativePath ) { const existingByPath = await pages .findPageByRelativePath( req.currentUser.id, analysis.relativePath, ) .catch(() => null); if (existingByPath) replacePageId = existingByPath.id; } let page; if (replacePageId) { const existingPage = await pages.getPage( req.currentUser.id, replacePageId, ); page = await pages.updatePage( req.currentUser.id, replacePageId, { ...pageInput, expectedVersion: existingPage.versionNo, }, ); } else { page = await pages.createFromChat( req.currentUser.id, pageInput, { sessionId: req.body.session_id, messageId: req.body.message_id, snapshot, }, ); } return res.status(201).json({ data: { kind: 'page', categoryCode: 'draft', page, }, }); } catch (error) { return handleMindSpaceError(res, req, error); } }); }