import { analyzeChatMessageForSave, buildWorkspaceBaseHref, injectHtmlBaseHref, resolveStaticHtmlContent, } from '../mindspace-chat-save.mjs'; import { DOCX_MIME_TYPE } 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'; 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, getMindSpaceChatSave = () => null, getConversationArtifactService = () => null, getAuthPool = () => 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, analyzeChatMessageForSaveFn = analyzeChatMessageForSave, resolveStaticHtmlContentFn = resolveStaticHtmlContent, normalizeWorkspaceRelativePathFn = normalizeWorkspaceRelativePath, scanContentFn = scanContent, generateDocxBufferFn = generateDocxBuffer, 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) => { const chatSave = getMindSpaceChatSave(); if (!getMindSpacePages() || !chatSave) { 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 title = bundle.previewTitle || bundle.resolvedHtml.suggestedTitle || bundle.analysis.suggestedTitle; const subtitle = bundle.previewSummary || bundle.resolvedHtml.suggestedSummary || bundle.analysis.suggestedSummary; const rendered = await chatSave.renderPreviewThumbnailSvg({ userId: req.currentUser.id, relativePath: bundle.resolvedHtml.relativePath, html: bundle.resolvedHtml.content, title, subtitle, force: true, }); res.set('Content-Type', rendered.contentType); res.set('Cache-Control', 'private, no-store'); res.setHeader('X-Request-Id', req.requestId); return res.send(rendered.svg); } catch (error) { return handleMindSpaceError(res, req, error); } }); api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => { const pages = getMindSpacePages(); const chatSave = getMindSpaceChatSave(); if (!pages || !chatSave) { 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) { try { await chatSave.ensurePreviewThumbnail({ userId: req.currentUser.id, relativePath: resolvedHtml.relativePath, html: 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) => { const conversationArtifacts = getConversationArtifactService(); if (!getMindSpacePages() || !conversationArtifacts) { 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 conversationArtifacts.registerChatDocxArtifact({ userId: req.currentUser.id, sessionId: req.body?.session_id ?? req.body?.sessionId, messageId: req.body?.message_id ?? req.body?.messageId, selectedLinkIndex: req.body?.selected_link_index ?? req.body?.selectedLinkIndex ?? 0, title: bundle?.source?.session?.name ?? title, filename, body: 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(); const chatSave = getMindSpaceChatSave(); if (!pages || !assets || !chatSave) { 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 ) { await chatSave .ensurePreviewThumbnail({ userId: req.currentUser.id, relativePath: analysis.relativePath, html: 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); } }); }