diff --git a/capabilities.mjs b/capabilities.mjs index 6be04ae..8c73781 100644 --- a/capabilities.mjs +++ b/capabilities.mjs @@ -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) { diff --git a/capabilities.test.mjs b/capabilities.test.mjs index d00d73c..a5a11b9 100644 --- a/capabilities.test.mjs +++ b/capabilities.test.mjs @@ -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) diff --git a/mindspace-sandbox-mcp.mjs b/mindspace-sandbox-mcp.mjs index 0aad2f2..8152891 100644 --- a/mindspace-sandbox-mcp.mjs +++ b/mindspace-sandbox-mcp.mjs @@ -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(); diff --git a/mindspace-sandbox-mcp.test.mjs b/mindspace-sandbox-mcp.test.mjs index f9676e6..f1e43f2 100644 --- a/mindspace-sandbox-mcp.test.mjs +++ b/mindspace-sandbox-mcp.test.mjs @@ -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: `
下载 Word`, + }, + }); + } + + 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`); + } +}); diff --git a/skills/docx-generate/SKILL.md b/skills/docx-generate/SKILL.md index ea2a3e6..52e51f5 100644 --- a/skills/docx-generate/SKILL.md +++ b/skills/docx-generate/SKILL.md @@ -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 标准库):