Compare commits
4 Commits
702871a98c
...
42baa3e468
| Author | SHA1 | Date | |
|---|---|---|---|
| 42baa3e468 | |||
| 8d6e237128 | |||
| 52c7082c70 | |||
| 1ac9ad0277 |
+1
-1
@@ -233,7 +233,7 @@ export function sandboxDeveloperTools(capabilities) {
|
||||
export function sandboxMcpTools(capabilities) {
|
||||
const tools = [];
|
||||
if (capabilities.static_publish) {
|
||||
tools.push('read_file', 'write_file', 'edit_file', 'create_dir', 'generate_long_image');
|
||||
tools.push('read_file', 'write_file', 'edit_file', 'create_dir', 'generate_docx', 'generate_long_image');
|
||||
if (capabilities.shell || capabilities.code_browse) tools.push('list_dir');
|
||||
}
|
||||
if (capabilities.private_data_space) {
|
||||
|
||||
@@ -194,6 +194,7 @@ test('sandboxMcpTools returns correct tool list based on capabilities', () => {
|
||||
'write_file',
|
||||
'edit_file',
|
||||
'create_dir',
|
||||
'generate_docx',
|
||||
'generate_long_image',
|
||||
'private_data_info',
|
||||
'private_data_schema',
|
||||
@@ -257,6 +258,7 @@ test('static_publish with sandboxMcp uses stdio sandbox-fs extension instead of
|
||||
assert.equal(sandboxExt.args[1], '/opt/h5/MindSpace/abc123'); // also passed as argv[2]
|
||||
assert.ok(sandboxExt.available_tools.includes('write_file'));
|
||||
assert.ok(sandboxExt.available_tools.includes('read_file'));
|
||||
assert.ok(sandboxExt.available_tools.includes('generate_docx'));
|
||||
assert.ok(sandboxExt.available_tools.includes('generate_long_image'));
|
||||
|
||||
// built-in developer extension should only remain for read_image (image_read: true by default)
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -264,7 +264,7 @@ test('prepareHtmlPublishContent rewrites imgproxy local image urls to public sta
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(prepared, /https:\/\/mm\.tkmind\.cn\/MindSpace\/user-1\/images\/2026-06-29\/hero\.jpg/);
|
||||
assert.match(prepared, /https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/images\/2026-06-29\/hero\.jpg/);
|
||||
assert.doesNotMatch(prepared, /plain\/local:\/\//);
|
||||
});
|
||||
|
||||
@@ -546,7 +546,7 @@ test('prepareHtmlPublishContent rewrites imgproxy urls in srcset and css url con
|
||||
|
||||
assert.match(prepared, /\/MindSpace\/user-1\/images\/2026-06-29\/thumb\.jpg 1x/);
|
||||
assert.match(prepared, /\/MindSpace\/user-1\/images\/2026-06-29\/hero\.jpg 2x/);
|
||||
assert.match(prepared, /url\(https:\/\/mm\.tkmind\.cn\/MindSpace\/user-1\/images\/2026-06-29\/bg\.jpg\)/);
|
||||
assert.match(prepared, /url\(https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/images\/2026-06-29\/bg\.jpg\)/);
|
||||
assert.doesNotMatch(prepared, /plain\/local:\/\//);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import readline from 'node:readline';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import mysql from 'mysql2/promise';
|
||||
import { createScheduleService } from './schedule-service.mjs';
|
||||
@@ -24,6 +25,7 @@ if (!SANDBOX_ROOT) {
|
||||
}
|
||||
|
||||
const SANDBOX = path.resolve(SANDBOX_ROOT);
|
||||
const SANDBOX_MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PRIVATE_DATA_DIR = path.join(SANDBOX, '.mindspace');
|
||||
const PRIVATE_DATA_DB = path.join(PRIVATE_DATA_DIR, 'private-data.sqlite');
|
||||
const SQLITE_BIN = process.env.SQLITE_BIN?.trim() || 'sqlite3';
|
||||
@@ -48,6 +50,70 @@ function resolveSandboxed(p) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function resolveDocxGenerateScript() {
|
||||
const candidates = [
|
||||
path.join(SANDBOX, '.agents', 'skills', 'docx-generate', 'generate_docx.py'),
|
||||
path.join(SANDBOX_MODULE_DIR, 'skills', 'docx-generate', 'generate_docx.py'),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
'generate_docx: 未找到 generate_docx.py,请先 load_skill → docx-generate 同步技能到工作区',
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDocxSections(sections) {
|
||||
if (!Array.isArray(sections) || sections.length === 0) {
|
||||
throw new Error('generate_docx: sections 必须是非空数组');
|
||||
}
|
||||
return sections.map((section, index) => {
|
||||
if (!section || typeof section !== 'object') {
|
||||
throw new Error(`generate_docx: sections[${index}] 必须是对象`);
|
||||
}
|
||||
const normalized = {
|
||||
heading: section.heading != null ? String(section.heading) : '',
|
||||
paragraphs: Array.isArray(section.paragraphs)
|
||||
? section.paragraphs.map((paragraph) => String(paragraph))
|
||||
: [],
|
||||
};
|
||||
if (section.table && typeof section.table === 'object') {
|
||||
normalized.table = section.table;
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
}
|
||||
|
||||
function runGenerateDocxScript({ outputPath, title, sections }) {
|
||||
const script = resolveDocxGenerateScript();
|
||||
const python = process.env.PYTHON_BIN?.trim() || 'python3';
|
||||
const payload = JSON.stringify({
|
||||
title: String(title ?? ''),
|
||||
sections: normalizeDocxSections(sections),
|
||||
});
|
||||
const stdout = execFileSync(
|
||||
python,
|
||||
[script, '--json', '-', '--output', outputPath],
|
||||
{
|
||||
cwd: SANDBOX,
|
||||
input: payload,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
const abs = resolveSandboxed(outputPath);
|
||||
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
|
||||
throw new Error(`generate_docx: 脚本执行后未找到 ${outputPath}`);
|
||||
}
|
||||
const size = fs.statSync(abs).size;
|
||||
if (size < 64) {
|
||||
throw new Error(`generate_docx: ${outputPath} 体积异常(${size} 字节)`);
|
||||
}
|
||||
return { bytes: size, stdout: String(stdout ?? '').trim() };
|
||||
}
|
||||
|
||||
const ALL_TOOLS = [
|
||||
{
|
||||
name: 'read_file',
|
||||
@@ -122,6 +188,24 @@ const ALL_TOOLS = [
|
||||
required: ['html_path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'generate_docx',
|
||||
description:
|
||||
'生成 Word .docx 文件并落盘到工作区(如 public/报告.docx)。公网下载页必须先调用本工具确认文件存在,再写 HTML 相对链接。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
output_path: { type: 'string', description: '输出 .docx 路径,如 public/协和智慧门诊研究摘要.docx' },
|
||||
title: { type: 'string', description: '文档主标题' },
|
||||
sections: {
|
||||
type: 'array',
|
||||
description: '章节数组;每项可含 heading、paragraphs、可选 table(headers/rows)',
|
||||
items: { type: 'object' },
|
||||
},
|
||||
},
|
||||
required: ['output_path', 'title', 'sections'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'private_data_info',
|
||||
description:
|
||||
@@ -491,6 +575,25 @@ async function callTool(name, args) {
|
||||
},
|
||||
];
|
||||
}
|
||||
case 'generate_docx': {
|
||||
const outputPath = String(args.output_path ?? args.path ?? '').trim();
|
||||
if (!outputPath.toLowerCase().endsWith('.docx')) {
|
||||
throw new Error('generate_docx: output_path 必须是 .docx 文件');
|
||||
}
|
||||
resolveSandboxed(outputPath);
|
||||
const title = String(args.title ?? '').trim();
|
||||
if (!title) {
|
||||
throw new Error('generate_docx: title 不能为空');
|
||||
}
|
||||
const sections = args.sections ?? args.payload?.sections;
|
||||
const result = runGenerateDocxScript({ outputPath, title, sections });
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text: `已生成 ${outputPath}(${result.bytes} 字节)`,
|
||||
},
|
||||
];
|
||||
}
|
||||
case 'private_data_info': {
|
||||
ensurePrivateDataDb();
|
||||
const size = privateDataSize();
|
||||
|
||||
@@ -4,6 +4,49 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { syncPublicDocxDownloads } from './mindspace-public-finish-sync.mjs';
|
||||
|
||||
function copyDocxGenerateSkill(root) {
|
||||
const srcDir = path.join(process.cwd(), 'skills', 'docx-generate');
|
||||
const destDir = path.join(root, '.agents', 'skills', 'docx-generate');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
for (const name of ['generate_docx.py', 'SKILL.md']) {
|
||||
fs.copyFileSync(path.join(srcDir, name), path.join(destDir, name));
|
||||
}
|
||||
}
|
||||
|
||||
function summarySections(caseName) {
|
||||
return [
|
||||
{
|
||||
heading: '一、项目概述',
|
||||
paragraphs: [
|
||||
`${caseName}项目面向医疗服务流程中的核心痛点,通过数字化与 AI 能力重构诊前、诊中、诊后体验。`,
|
||||
'本摘要基于创新三角评估框架,从创新、成果、市场、团队四个维度提炼关键结论。',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '二、解决方案与技术创新',
|
||||
paragraphs: [
|
||||
'项目以数据驱动和智能辅助决策为主线,形成可复制的技术体系与流程再造方案。',
|
||||
'在技术创新、商业模式与政策吻合度方面均具备行业示范价值。',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '三、成果与价值',
|
||||
paragraphs: [
|
||||
'核心指标显示候诊效率、临床质量或安全水平获得显著提升,患者与医护双侧受益。',
|
||||
'项目已具备向医联体与区域平台输出的标准化能力。',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '四、壁垒、风险与未来展望',
|
||||
paragraphs: [
|
||||
'竞争壁垒来自临床数据积累、流程嵌入深度与专科Know-how。',
|
||||
'未来可在专科深化、基层赋能与支付模式创新方向持续扩展。',
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function startSandbox(root, envOverrides = {}) {
|
||||
const child = spawn(process.execPath, ['mindspace-sandbox-mcp.mjs', root], {
|
||||
@@ -102,3 +145,75 @@ test('sandbox MCP exposes schedule tools only when schedule env is configured',
|
||||
['schedule_create_item', 'schedule_create_reminder', 'schedule_list_items'],
|
||||
);
|
||||
});
|
||||
|
||||
test('generate_docx writes public Word files for Mark-style summary request', async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mindspace-sandbox-docx-'));
|
||||
fs.mkdirSync(path.join(root, 'public'), { recursive: true });
|
||||
copyDocxGenerateSkill(root);
|
||||
|
||||
const server = startSandbox(root, {
|
||||
ALLOWED_TOOLS: 'generate_docx,write_file,list_dir',
|
||||
});
|
||||
t.after(() => server.child.kill());
|
||||
|
||||
await server.request('initialize');
|
||||
const listed = await server.request('tools/list');
|
||||
assert.ok(listed.result.tools.some((tool) => tool.name === 'generate_docx'));
|
||||
|
||||
const cases = [
|
||||
{
|
||||
docx: 'public/协和智慧门诊研究摘要.docx',
|
||||
html: 'public/协和智慧门诊研究摘要.html',
|
||||
title: '北京协和医院「智慧门诊 + AI 预问诊」研究摘要',
|
||||
},
|
||||
{
|
||||
docx: 'public/广州妇儿CDSS研究摘要.docx',
|
||||
html: 'public/广州妇儿CDSS研究摘要.html',
|
||||
title: '广州妇儿中心「数据驱动 + AI 临床决策」研究摘要',
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of cases) {
|
||||
const generated = await server.request('tools/call', {
|
||||
name: 'generate_docx',
|
||||
arguments: {
|
||||
output_path: item.docx,
|
||||
title: item.title,
|
||||
sections: summarySections(item.title),
|
||||
},
|
||||
});
|
||||
assert.equal(generated.result.isError, false, generated.result.content?.[0]?.text);
|
||||
assert.match(generated.result.content[0].text, /已生成 public\/.+\.docx/);
|
||||
assert.ok(fs.statSync(path.join(root, item.docx)).size > 500);
|
||||
}
|
||||
|
||||
for (const item of cases) {
|
||||
const docxName = path.basename(item.docx);
|
||||
await server.request('tools/call', {
|
||||
name: 'write_file',
|
||||
arguments: {
|
||||
path: item.html,
|
||||
content: `<!doctype html><html><body><a href="${docxName}" download>下载 Word</a></body></html>`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const listedPublic = await server.request('tools/call', {
|
||||
name: 'list_dir',
|
||||
arguments: { path: 'public' },
|
||||
});
|
||||
assert.equal(listedPublic.result.isError, false);
|
||||
assert.match(listedPublic.result.content[0].text, /协和智慧门诊研究摘要\.docx/);
|
||||
assert.match(listedPublic.result.content[0].text, /广州妇儿CDSS研究摘要\.docx/);
|
||||
|
||||
const sync = syncPublicDocxDownloads({ publishDir: root });
|
||||
assert.deepEqual(sync.missing, []);
|
||||
assert.deepEqual(sync.synced, []);
|
||||
|
||||
for (const item of cases) {
|
||||
const abs = path.join(root, item.docx);
|
||||
assert.ok(fs.existsSync(abs));
|
||||
const zipHeader = fs.readFileSync(abs).subarray(0, 2).toString('utf8');
|
||||
assert.equal(zipHeader, 'PK', `${item.docx} should be a valid zip/docx`);
|
||||
}
|
||||
});
|
||||
|
||||
+1
-1
@@ -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([
|
||||
{
|
||||
|
||||
+26
-1
@@ -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();
|
||||
|
||||
@@ -19,7 +19,17 @@ description: 在工作区内用 Python 标准库(zipfile + XML)生成 Word .
|
||||
- 用户要 Word / docx / .doc 文档(输出 `.docx`)
|
||||
- 需要保存到 `oa/`、`private/`、`public/` 等分区
|
||||
|
||||
## 推荐命令
|
||||
## 推荐方式(优先)
|
||||
|
||||
**优先调用 sandbox-fs 的 `generate_docx` 工具**(与 `generate_long_image` 同级),直接写入 `public/文件名.docx` 或 `oa/文件名.docx`:
|
||||
|
||||
- `output_path`:如 `public/协和智慧门诊研究摘要.docx`
|
||||
- `title`:文档标题
|
||||
- `sections`:章节数组(`heading`、`paragraphs`、可选 `table`)
|
||||
|
||||
生成后必须 `list_dir public/` 确认目标文件已落盘,再写 HTML 下载链接。
|
||||
|
||||
## 备选命令(仅当 MCP 不可用时)
|
||||
|
||||
技能目录内有 `generate_docx.py`(仅依赖 Python 3 标准库):
|
||||
|
||||
|
||||
+81
-12
@@ -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<string, unknown>)[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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
</p>
|
||||
@@ -3089,7 +3090,7 @@ export function MindSpaceView({
|
||||
</div>
|
||||
<p className="mindspace-upload-dialog-desc">
|
||||
支持 {UPLOAD_FILE_TYPE_LABEL},文档最大 {formatBytes(maxUploadFileBytes)},图片最大{' '}
|
||||
{formatBytes(Math.min(maxUploadFileBytes, MINDSPACE_IMAGE_UPLOAD_MAX_BYTES))}。
|
||||
{formatBytes(MINDSPACE_IMAGE_UPLOAD_MAX_BYTES)}。
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
|
||||
@@ -25,7 +25,7 @@ export const PREVIEW_SPACE: MindSpace = {
|
||||
usedBytes: 1.2 * 1024 * 1024,
|
||||
reservedBytes: 0,
|
||||
availableBytes: 3.8 * 1024 * 1024,
|
||||
maxFileBytes: 5 * 1024 * 1024,
|
||||
maxFileBytes: 30 * 1024 * 1024,
|
||||
publicPageLimit: 3,
|
||||
publicPageUsed: 1,
|
||||
aiDailyLimit: 20,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export const CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES = 4 * 1024 * 1024;
|
||||
export const CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES = 10 * 1024 * 1024;
|
||||
export const CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES = 1.5 * 1024 * 1024;
|
||||
export const MINDSPACE_IMAGE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024;
|
||||
export const MINDSPACE_IMAGE_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
|
||||
export const CHAT_IMAGE_UPLOAD_MAX_COUNT = 10;
|
||||
export const CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
|
||||
export const CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES = 30 * 1024 * 1024;
|
||||
export const CHAT_IMAGE_MAX_SIDE = 1920;
|
||||
const MAX_PIXELS = 2_500_000;
|
||||
const ACCEPTED_IMAGE_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
export const DEFAULT_IMAGE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024;
|
||||
export const DEFAULT_IMAGE_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
|
||||
export const DEFAULT_IMAGE_MASTER_MAX_BYTES = 6 * 1024 * 1024;
|
||||
export const DEFAULT_IMAGE_MASTER_MAX_SIDE = 2560;
|
||||
export const DEFAULT_IMAGE_INPUT_MAX_PIXELS = 40_000_000;
|
||||
|
||||
Reference in New Issue
Block a user