534 lines
16 KiB
JavaScript
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);
|
|
}
|
|
});
|
|
}
|