diff --git a/mindspace-assets.test.mjs b/mindspace-assets.test.mjs index 325f1a2..02200b0 100644 --- a/mindspace-assets.test.mjs +++ b/mindspace-assets.test.mjs @@ -6,6 +6,7 @@ import test from 'node:test'; import sharp from 'sharp'; import { assetInternals, createAssetService, validateUploadRequest } from './mindspace-assets.mjs'; import { DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs'; +import { DEFAULT_IMAGE_UPLOAD_MAX_BYTES } from './user-image-normalize.mjs'; function createMockPool(state) { return { @@ -240,7 +241,7 @@ test('validateUploadRequest rejects traversal filenames', () => { ); }); -test('validateUploadRequest allows 5MB uploads by default', () => { +test('validateUploadRequest allows 30MB document uploads by default', () => { assert.doesNotThrow(() => validateUploadRequest({ filename: 'handbook.pdf', @@ -261,14 +262,14 @@ test('validateUploadRequest keeps image uploads below the image-specific limit', assert.doesNotThrow(() => validateUploadRequest({ filename: 'cover.jpg', - sizeBytes: 4 * 1024 * 1024, + sizeBytes: DEFAULT_IMAGE_UPLOAD_MAX_BYTES, }), ); assert.throws( () => validateUploadRequest({ filename: 'cover.jpg', - sizeBytes: 4 * 1024 * 1024 + 1, + sizeBytes: DEFAULT_IMAGE_UPLOAD_MAX_BYTES + 1, }), (error) => error.code === 'file_too_large', ); diff --git a/mindspace.mjs b/mindspace.mjs index d3e3538..a70f42d 100644 --- a/mindspace.mjs +++ b/mindspace.mjs @@ -2,7 +2,7 @@ import crypto from 'node:crypto'; import { loadMindSpaceConfig } from './mindspace-config.mjs'; export const DEFAULT_SPACE_QUOTA_BYTES = 5 * 1024 * 1024; -export const DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024; +export const DEFAULT_MAX_FILE_BYTES = 30 * 1024 * 1024; export const SYSTEM_CATEGORIES = Object.freeze([ { diff --git a/server.mjs b/server.mjs index 629cd5a..7d4d261 100644 --- a/server.mjs +++ b/server.mjs @@ -148,6 +148,7 @@ import { } from './mindspace-chat-docx-package.mjs'; import { scanContent } from './mindspace-content-scan.mjs'; import { renderImageAssetViewerHtml, wantsInlineImageViewer } from './mindspace-asset-preview.mjs'; +import { DEFAULT_IMAGE_UPLOAD_MAX_BYTES } from './user-image-normalize.mjs'; import { createRechargeService } from './billing-recharge.mjs'; import { createSubscriptionService, createPlanCatalogService, ensurePlanCatalogSchema, PLAN_CATALOG } from './billing-subscription.mjs'; import { @@ -268,7 +269,7 @@ const jsonUnlessMultipart = (req, res, next) => { }; const rawUploadBody = express.raw({ type: 'application/octet-stream', - limit: mindSpaceServerRuntime.maxFileBytes, + limit: Math.max(mindSpaceServerRuntime.maxFileBytes, DEFAULT_IMAGE_UPLOAD_MAX_BYTES), }); const wikiAuth = createWikiAuth(path.join(resolveMindSpacePublishRoot(__dirname), 'wiki-db')); @@ -2188,6 +2189,28 @@ function mindSpaceError(res, req, error) { return sendError(res, req, status, code, message, error?.details); } +function isRequestBodyTooLarge(error) { + return ( + error?.type === 'entity.too.large' || + error?.status === 413 || + error?.statusCode === 413 + ); +} + +function apiRequestBodyError(error, req, res, next) { + if (!isRequestBodyTooLarge(error)) return next(error); + const isMindSpaceUpload = req.path.startsWith('/mindspace/v1/uploads/'); + return sendError( + res, + req, + 413, + isMindSpaceUpload ? 'file_too_large' : 'request_body_too_large', + isMindSpaceUpload + ? '上传文件超过单文件大小限制,请压缩后重试' + : '请求内容过大,请缩小后重试', + ); +} + function ensureMindSpaceEnabled(res, req, { upload = false, agent = false } = {}) { try { assertMindSpaceRoute(msFlags, upload ? 'upload' : agent ? 'agent' : undefined); @@ -4573,6 +4596,8 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => { }); }); +api.use(apiRequestBodyError); + api.use(async (req, res, next) => { await userAuthReady; if (!userAuth || !tkmindProxy) return next(); diff --git a/src/api/client.ts b/src/api/client.ts index 6216639..d415ce2 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -139,6 +139,70 @@ export class ApiError extends Error { } } +function formatBytes(bytes: number) { + if (!Number.isFinite(bytes) || bytes < 0) return '0B'; + if (bytes >= 1024 * 1024) { + const mb = bytes / 1024 / 1024; + return `${Number.isInteger(mb) ? mb : mb.toFixed(1)}MB`; + } + if (bytes >= 1024) { + const kb = bytes / 1024; + return `${Number.isInteger(kb) ? kb : kb.toFixed(1)}KB`; + } + return `${bytes}B`; +} + +function readNumberDetail(details: ApiError['details'], key: string) { + if (!details || typeof details !== 'object') return null; + const value = (details as Record)[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function isImageUploadFile(file: File) { + if (file.type.startsWith('image/')) return true; + return /\.(png|jpe?g|webp|gif)$/i.test(file.name); +} + +function normalizeMindSpaceUploadError(error: unknown, file: File): Error { + if (!(error instanceof ApiError)) { + return error instanceof Error ? error : new Error('上传失败,请重试'); + } + + const imageMax = formatBytes(CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES); + if (error.code === 'quota_exceeded' || error.status === 429) { + const requiredBytes = readNumberDetail(error.details, 'requiredBytes'); + const availableBytes = readNumberDetail(error.details, 'availableBytes'); + const detail = + requiredBytes !== null && availableBytes !== null + ? `当前剩余 ${formatBytes(availableBytes)},本次需要 ${formatBytes(requiredBytes)}。` + : ''; + return new ApiError( + error.status, + `剩余空间不足,${detail}请减少图片数量或压缩后重试。`, + error.code, + error.details, + ); + } + + if (error.code === 'file_too_large' || error.status === 413) { + const message = isImageUploadFile(file) + ? `图片文件过大,单张图片不能超过 ${imageMax},请压缩后重试。` + : `文件过大,请压缩到单文件上限以内后重试。`; + return new ApiError(error.status, message, error.code, error.details); + } + + if (/MindSpace\s*服务异常/.test(error.message) || error.code === 'internal_error') { + return new ApiError( + error.status, + `上传失败,请减少图片数量或压缩图片后重试;单张图片上限 ${imageMax}。`, + error.code, + error.details, + ); + } + + return error; +} + function sanitizeUserFacingErrorMessage(message: string) { const normalized = String(message ?? '').trim(); if (!normalized) return normalized; @@ -722,17 +786,22 @@ export async function uploadMindSpaceAsset( ); } - const created = await apiFetch<{ data: MindSpaceUpload }>('/mindspace/v1/uploads', { - method: 'POST', - body: JSON.stringify({ - category_id: categoryId, - filename: file.name, - size_bytes: file.size, - declared_mime_type: file.type || null, - ...(options.sessionId ? { session_id: options.sessionId } : {}), - ...(options.messageId ? { message_id: options.messageId } : {}), - }), - }); + let created: { data: MindSpaceUpload }; + try { + created = await apiFetch<{ data: MindSpaceUpload }>('/mindspace/v1/uploads', { + method: 'POST', + body: JSON.stringify({ + category_id: categoryId, + filename: file.name, + size_bytes: file.size, + declared_mime_type: file.type || null, + ...(options.sessionId ? { session_id: options.sessionId } : {}), + ...(options.messageId ? { message_id: options.messageId } : {}), + }), + }); + } catch (error) { + throw normalizeMindSpaceUploadError(error, file); + } try { await uploadFileContent(created.data.uploadUrl, file, options.onProgress); @@ -745,7 +814,7 @@ export async function uploadMindSpaceAsset( await apiFetch(`/mindspace/v1/uploads/${created.data.id}`, { method: 'DELETE', }).catch(() => {}); - throw error; + throw normalizeMindSpaceUploadError(error, file); } } diff --git a/src/components/MindSpaceView.tsx b/src/components/MindSpaceView.tsx index 7b1e190..cd35d4c 100644 --- a/src/components/MindSpaceView.tsx +++ b/src/components/MindSpaceView.tsx @@ -91,7 +91,7 @@ function sortPagesByCreatedAt(pages: MindSpacePage[]) { }); } const IMAGE_PAGE_SIZE = 10; -const DEFAULT_MAX_UPLOAD_FILE_BYTES = 5 * 1024 * 1024; +const DEFAULT_MAX_UPLOAD_FILE_BYTES = 30 * 1024 * 1024; const UPLOAD_FILE_EXTENSIONS = [ '.doc', '.docx', @@ -361,7 +361,7 @@ function validateSelectedUploadFile(file: File, maxBytes: number) { return `暂不支持 ${extension || '无扩展名'} 文件,请选择支持的资料格式。`; } const effectiveMaxBytes = isUploadImageFile(file) - ? Math.min(maxBytes, MINDSPACE_IMAGE_UPLOAD_MAX_BYTES) + ? MINDSPACE_IMAGE_UPLOAD_MAX_BYTES : maxBytes; if (file.size > effectiveMaxBytes) { return `文件不能超过 ${formatBytes(effectiveMaxBytes)},当前为 ${formatBytes(file.size)}。`; @@ -2118,7 +2118,8 @@ export function MindSpaceView({ )} 剩余 {formatBytes(space.quota.availableBytes)} ·{' '} - 单文件上限 {formatBytes(space.quota.maxFileBytes)} · 可发布页面{' '} + 文档上限 {formatBytes(space.quota.maxFileBytes)} · 图片上限{' '} + {formatBytes(MINDSPACE_IMAGE_UPLOAD_MAX_BYTES)} · 可发布页面{' '} {space.quota.publicPageUsed}/{space.quota.publicPageLimit} · 今日 AI{' '} {space.quota.aiDailyUsed}/{space.quota.aiDailyLimit}

@@ -3089,7 +3090,7 @@ export function MindSpaceView({

支持 {UPLOAD_FILE_TYPE_LABEL},文档最大 {formatBytes(maxUploadFileBytes)},图片最大{' '} - {formatBytes(Math.min(maxUploadFileBytes, MINDSPACE_IMAGE_UPLOAD_MAX_BYTES))}。 + {formatBytes(MINDSPACE_IMAGE_UPLOAD_MAX_BYTES)}。