Files
memind/server/portal-mindspace-chat-save-routes.mjs

534 lines
16 KiB
JavaScript

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);
}
});
}