diff --git a/mindspace-docx-export.mjs b/mindspace-docx-export.mjs new file mode 100644 index 0000000..7be3c67 --- /dev/null +++ b/mindspace-docx-export.mjs @@ -0,0 +1,167 @@ +const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; +const R_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'; + +const CONTENT_TYPES = ` + + + + +`; + +const ROOT_RELS = ` + + +`; + +const DOCUMENT_RELS = ` +`; + +function escapeXml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function decodeHtmlEntities(value) { + return String(value ?? '') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code))) + .replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(Number.parseInt(code, 16))); +} + +export function extractPlainTextFromHtml(html) { + return decodeHtmlEntities( + String(html ?? '') + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(/<\/(?:p|div|section|article|header|footer|h[1-6]|li|tr|blockquote)>/gi, '\n') + .replace(//gi, '\n') + .replace(/<[^>]+>/g, ' ') + .replace(/[ \t\r\f\v]+/g, ' ') + .replace(/\n[ \t]+/g, '\n') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(), + ); +} + +function normalizeParagraphs(content) { + return String(content ?? '') + .split(/\n{1,}/) + .map((line) => line.replace(/^#{1,6}\s+/, '').replace(/[*_`~]/g, '').trim()) + .filter(Boolean) + .slice(0, 500); +} + +function paragraphXml(text, { heading = false } = {}) { + const style = heading ? '' : ''; + const runStyle = heading ? '' : ''; + return `${style}${runStyle}${escapeXml(text)}`; +} + +function documentXml({ title, paragraphs }) { + const body = [ + title ? paragraphXml(title, { heading: true }) : '', + ...paragraphs.map((paragraph) => paragraphXml(paragraph)), + ].join(''); + return ` + + ${body} +`; +} + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c >>> 0; + } + return table; +})(); + +function crc32(buffer) { + let crc = 0xffffffff; + for (const byte of buffer) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function dosDateTime(date = new Date()) { + const year = Math.max(1980, date.getFullYear()); + const dosTime = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2); + const dosDate = ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(); + return { dosTime, dosDate }; +} + +function writeZipEntry(name, content, offset) { + const nameBuffer = Buffer.from(name); + const data = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8'); + const checksum = crc32(data); + const { dosTime, dosDate } = dosDateTime(); + const local = Buffer.alloc(30 + nameBuffer.length); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0, 6); + local.writeUInt16LE(0, 8); + local.writeUInt16LE(dosTime, 10); + local.writeUInt16LE(dosDate, 12); + local.writeUInt32LE(checksum, 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(nameBuffer.length, 26); + nameBuffer.copy(local, 30); + + const central = Buffer.alloc(46 + nameBuffer.length); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0, 8); + central.writeUInt16LE(0, 10); + central.writeUInt16LE(dosTime, 12); + central.writeUInt16LE(dosDate, 14); + central.writeUInt32LE(checksum, 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBuffer.length, 28); + central.writeUInt32LE(offset, 42); + nameBuffer.copy(central, 46); + return { local, data, central }; +} + +function zip(entries) { + let offset = 0; + const locals = []; + const centrals = []; + for (const [name, content] of entries) { + const entry = writeZipEntry(name, content, offset); + locals.push(entry.local, entry.data); + centrals.push(entry.central); + offset += entry.local.length + entry.data.length; + } + const centralSize = centrals.reduce((sum, item) => sum + item.length, 0); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(centralSize, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, ...centrals, end]); +} + +export function generateDocxBuffer({ title = '文档', content = '', contentFormat = 'markdown' } = {}) { + const plain = contentFormat === 'html' ? extractPlainTextFromHtml(content) : String(content ?? ''); + const paragraphs = normalizeParagraphs(plain); + return zip([ + ['[Content_Types].xml', CONTENT_TYPES], + ['_rels/.rels', ROOT_RELS], + ['word/document.xml', documentXml({ title, paragraphs: paragraphs.length > 0 ? paragraphs : [''] })], + ['word/_rels/document.xml.rels', DOCUMENT_RELS], + ]); +} diff --git a/server.mjs b/server.mjs index a935215..9e4c168 100644 --- a/server.mjs +++ b/server.mjs @@ -109,6 +109,7 @@ import { renderLongImage, renderLongImageBuffer, } from './mindspace-long-image.mjs'; +import { generateDocxBuffer } from './mindspace-docx-export.mjs'; import { scanContent } from './mindspace-content-scan.mjs'; import { renderImageAssetViewerHtml, wantsInlineImageViewer } from './mindspace-asset-preview.mjs'; import { createRechargeService } from './billing-recharge.mjs'; @@ -219,6 +220,7 @@ function csrfOriginCheck(req, res, next) { app.use('/api', csrfOriginCheck); app.use('/auth', csrfOriginCheck); +app.use('/mindspace', csrfOriginCheck); const jsonBody = express.json({ limit: '1mb' }); const jsonUnlessMultipart = (req, res, next) => { @@ -3132,6 +3134,40 @@ api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => { } }); +async function handleChatSaveDocx(req, res) { + if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); + try { + const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, 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 = generateDocxBuffer({ title, content, contentFormat }); + const filenameBase = String(title) + .replace(/[\\/:*?"<>|]+/g, '-') + .replace(/\s+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) || 'mindspace-document'; + const filename = `${filenameBase}.docx`; + res.set('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + 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 mindSpaceError(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) => { if (!mindSpacePages || !mindSpaceAssets) { return res.status(503).json({ message: 'MindSpace 未启用' }); @@ -4458,6 +4494,7 @@ api.use( ); app.use('/api', api); +app.use('/mindspace', api); function scriptSrcDirective({ inline = false, urls = [], hashes = [] } = {}) { const parts = []; diff --git a/src/api/client.ts b/src/api/client.ts index 54b1e73..7d32b91 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -958,6 +958,41 @@ export async function quickShareFromChat(input: { return result.data; } +export async function downloadChatMessageDocx(input: { + sessionId: string; + messageId: string; + selectedLinkIndex?: number; +}): Promise<{ blob: Blob; filename: string }> { + let res: Response; + try { + res = await fetch(`${API}/mindspace/v1/pages/chat-save-docx`, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: input.sessionId, + message_id: input.messageId, + selected_link_index: input.selectedLinkIndex ?? 0, + }), + }); + } catch (err) { + throw new ApiError(0, formatNetworkError(err)); + } + if (res.status === 401) { + notifyUnauthorized(); + throw new ApiError(401, '未授权,请重新登录'); + } + if (!res.ok) { + const parsed = await parseErrorResponse(res); + throw new ApiError(res.status, parsed.message || `${res.status} ${res.statusText}`, parsed.code); + } + const disposition = res.headers.get('Content-Disposition') ?? ''; + const filenameMatch = + disposition.match(/filename\*=UTF-8''([^;]+)/i) || disposition.match(/filename="?([^";]+)"?/i); + const filename = filenameMatch ? decodeURIComponent(filenameMatch[1]) : 'mindspace-document.docx'; + return { blob: await res.blob(), filename }; +} + export async function fetchPreviewAsset(path: string): Promise { let res: Response; try { diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 04b3237..6cf082b 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -2,6 +2,9 @@ import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, import { useNetworkStatus } from '../hooks/useNetworkStatus'; import { openAvatarPicker } from '../utils/userAvatar'; import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills'; +import { getMessageSaveActions } from '../utils/messageSave'; +import { getDisplayText } from '../utils/message'; +import { downloadChatMessageDocx } from '../api/client'; import { CHAT_IMAGE_UPLOAD_MAX_COUNT, CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, @@ -10,6 +13,7 @@ import { import { AvatarPicker } from './AvatarPicker'; import { ChatSkillPicker } from './ChatSkillPicker'; import { ChatLoadingSpinner } from './ChatLoadingSpinner'; +import { ChatSharePreviewModal } from './ChatSharePreviewModal'; import { MessageList } from './MessageList'; import { PageSaveDialog } from './PageSaveDialog'; import { VoiceInputButton } from './VoiceInputButton'; @@ -76,6 +80,32 @@ function FileIcon() { ); } +function appendLongImageDownloadParam(url: string) { + const next = new URL(url, window.location.href); + next.searchParams.set('download', 'long-image'); + return next.toString(); +} + +function triggerUrlDownload(url: string) { + const link = document.createElement('a'); + link.href = url; + link.download = ''; + document.body.appendChild(link); + link.click(); + link.remove(); +} + +function downloadBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 60_000); +} + export function ChatPanel({ variant, user, @@ -123,6 +153,7 @@ export function ChatPanel({ const [input, setInput] = useState(''); const [voiceRecording, setVoiceRecording] = useState(false); const [pageSource, setPageSource] = useState(null); + const [sharePreviewSource, setSharePreviewSource] = useState(null); const [voiceNotice, setVoiceNotice] = useState(null); const [pendingImages, setPendingImages] = useState([]); const [uploadingImage, setUploadingImage] = useState(false); @@ -531,6 +562,35 @@ export function ChatPanel({ }); }; + const openSaveActions = (message: Message) => { + const actions = getMessageSaveActions(getDisplayText(message), { + userId: user?.id, + username: user?.username, + }); + if (actions.kind === 'page') { + setSharePreviewSource(message); + return; + } + setPageSource(message); + }; + + const downloadLongImage = (_message: Message, publicUrl: string) => { + triggerUrlDownload(appendLongImageDownloadParam(publicUrl)); + }; + + const downloadDocx = async (message: Message) => { + if (!session?.id || !message.id) return; + try { + const result = await downloadChatMessageDocx({ + sessionId: session.id, + messageId: message.id, + }); + downloadBlob(result.blob, result.filename); + } catch (err) { + setVoiceNotice(err instanceof Error ? err.message : '文档下载失败,请重试'); + } + }; + return ( <>
setPageSource(message)} + onSaveAsPage={openSaveActions} + onDownloadLongImage={downloadLongImage} + onDownloadDocx={(message) => void downloadDocx(message)} publishUserId={user?.id} publishUsername={user?.username} - sessionId={session?.id} compact={compact} /> {!compact && ( @@ -587,11 +648,24 @@ export function ChatPanel({ onClose={() => setPageSource(null)} onSaved={(result) => { setPageSource(null); + setSharePreviewSource(null); onPageSaved?.(result); }} /> )} + {sharePreviewSource?.id && session?.id && ( + setSharePreviewSource(null)} + onSave={() => { + setPageSource(sharePreviewSource); + setSharePreviewSource(null); + }} + /> + )} +