触发后调用专利生成接口完成全部生成。本 skill 不自己撰写专利内容,只负责:
documentId.docx> 本文件自含:用到的辅助脚本源码在文末「附录」,只分发这一个 SKILL.md 即可——首次运行时按「第 0 步」写出脚本并装依赖。下文 $DIR 指本技能所在目录(加载时顶部打印的 Base directory)。
基址:https://www.cndeeptest.com/patent_draft/api (无需鉴权)
| 步骤 | 方法 & 路径 | 请求 | 返回 |
|---|---|---|---|
| ------ | ------------ | ------ | ------ |
| 上传交底 PDF | POST /files/upload-document | multipart/form-data,字段 file(仅 PDF) | JSON:{code, message, data, timestamp},code="200" 时 data 即 documentId |
| 生成专利文件 | POST /patent/generate | JSON:{"chatId":"<任意唯一串>", "documentFileId":"<上一步的 documentId>"} | text/event-stream(SSE) |
/patent/generate 的 SSE 事件:
| event | data | 处理方式 |
|---|---|---|
| ------- | ------ | --------- |
progress | {step, message} | 实时展示 message 给用户 |
message | {delta} | 累加 delta 得到最终文件全文 |
error | {step, message} | 展示 message 并终止 |
heartbeat | ping | 忽略 |
流结束无 [DONE] 标记,连接关闭即结束(curl -N 随之退出)。
$DIR/assemble.js 不存在 → 用 Write 工具按「附录 A」原样写出。$DIR/tools/node_modules 不存在 → 用 Write 写出「附录 B」$DIR/tools/package.json、「附录 C」$DIR/tools/svg-to-docx.js,再 cd $DIR/tools && npm install(需联网,一次即可)。脚本已存在就跳过本步。
上传接口只接受 PDF。若用户上传的是 DOCX/DOC 文件,自动静默转换为 PDF,无需提示用户或报错拦截。
DOCX → PDF 转换方法(按优先级尝试):
```powershell
soffice --headless --convert-to pdf --outdir "$outputDir" "C:\绝对路径\交底材料.docx"
```
```powershell
$word = New-Object -ComObject Word.Application
$doc = $word.Documents.Open("C:\绝对路径\交底材料.docx")
$doc.SaveAs([ref]"$DIR\temp_交底材料.pdf", [ref]17)
$doc.Close(); $word.Quit()
```
docx2pdf:```powershell
pip install docx2pdf
docx2pdf "C:\绝对路径\交底材料.docx" --output "$DIR\temp_交底材料.pdf"
```
⚠️ 不要在用户原始路径下产生临时文件,PDF 输出到 $DIR/ 目录下。
转换成功后,用 PDF 文件继续后续步骤。转换失败时才告知用户并请求手动转换。
curl.exe -s -X POST "https://www.cndeeptest.com/patent_draft/api/files/upload-document" `
-F "file=@C:\绝对路径\交底材料.pdf"
# => {"code":"200","message":"上传成功","data":"<documentId>","timestamp":...}
> ⚠️ PowerShell 中 curl 是 Invoke-WebRequest 别名,必须用 curl.exe。
从 data 取出 documentId。若 code != "200",把 message 反馈给用户并停止。
assemble.js 内部调用 curl.exe 并实时解析 SSE 流:message.delta 拼接全文,progress/error 实时输出到 stdout(带百分比进度)。
⚠️ 必须后台运行 + 轮询进度 + 实时汇报用户:
# 后台启动,timeout 必须设 900 秒
# 第三个参数传 "stdout" 让内容输出到标准输出(不写文件)
node "$DIR/assemble.js" "<documentId>" "-" "stdout"
启动后用 process poll 轮询进度,每当 poll 到新的 [进度] 行时,立即向用户发送一条简短进度消息(如 "📋 5% 校验交底材料中(剩余约12分30秒)"、"📝 50% 初稿已生成,开始质检(剩余约6分15秒)"、"🔧 75% 修复终稿中(剩余约3分45秒)"),让用户实时看到进展。不要等到全部完成才回复。
捕获生成结果:
process log 获取完整输出===PATENT_CONTENT_START=== 和 ===PATENT_CONTENT_END=== 之间的 JSONcontent(专利全文)和 charCount进度汇报规则:
> 生成全程约 8-15 分钟。assemble.js 内部 spawn curl.exe,完全绕开 PowerShell 管道编码问题。
从 assemble.js 的 stdout 输出中提取 JSON,解析 content(专利全文)和 charCount。
不展示全文,只向用户汇报统计信息:
✅ 100% 专利申请文件生成完成
共 X,XXX 字,包含 X 张附图
是否需要导出为 .docx 格式(内嵌高清PNG附图)?
统计方法:
charCount(JSON 中的字段)content 中 若期间收到 error 事件,仅转述其 message,不做原因猜测或建议,并停止。
> 注意:此阶段不生成任何文件,内容仅在内存中传递。
md 里的附图是内联 。生成完成后主动询问用户是否要把这些 SVG 渲染成高清 PNG、在原位置内嵌,导出 .docx。
用户同意后再执行:
.md 文件(仅用于转换)svg-to-docx.js 转换为 .docx(高清 PNG 内嵌,Markdown 表格转原生表格).md 文件.docx 文件提供给用户下载用户不同意:
# 用户同意后执行:
# 1. 写入临时 md 文件
$content | Out-File -FilePath "$DIR/临时专利文件.md" -Encoding UTF8
# 2. 转换为 docx
node "$DIR/tools/svg-to-docx.js" "$DIR/临时专利文件.md" "专利申请文件.docx"
# 3. 删除临时 md 文件
Remove-Item "$DIR/临时专利文件.md" -Force
# => [完成] N 张图已高清内嵌 → 专利申请文件.docx
转换器把每个 按 3 倍率渲染成高清 PNG,在原位置居中内嵌;正文按行成段,权利要求书/说明书/说明书附图 等标题行加粗。
| 现象 | 原因 / 处理 |
|---|---|
| ------ | ------------ |
上传返回 FORMAT_ERROR | 只支持 PDF,先转格式 |
收到 error 事件 | 按其 message 告知用户并停止 |
documentId 失效 | 重新上传交底 PDF |
| 流提前断开 | 重跑生成;确保未设置短的 curl/客户端超时 |
| docx 转换报缺模块 | 在 $DIR/tools 下 npm install 装好 @resvg/resvg-js、docx |
PowerShell curl 报错 | 必须用 curl.exe,PowerShell 的 curl 是 Invoke-WebRequest 别名 |
| SSE 管道中文乱码 | assemble.js 内部直接 spawn curl.exe,无需 PowerShell 管道 |
下面三段是第 0 步要写出的文件,按文件名原样落地。
assemble.js#!/usr/bin/env node
/**
* 专利 SSE 流生成 + 解析一体化脚本
*
* 用法:
* node assemble.js <documentId> [输出md文件] [输出模式]
*
* 输出模式: file (写文件,默认) | stdout (输出到标准输出)
*
* 流程:
* 1. 自动生成 chatId
* 2. 调用 curl.exe 请求 SSE 流(子进程,绕开 PowerShell 管道编码问题)
* 3. 实时解析 SSE:progress/error → stdout(带百分比),message.delta → 拼接全文
* 4. 结束后根据输出模式写入文件或输出到 stdout
*
* 进度输出到 stdout 而非 stderr,避免 PowerShell 将 stderr 当作错误流。
*/
const { spawn } = require("child_process");
const fs = require("fs");
const readline = require("readline");
const API_BASE = "https://www.cndeeptest.com/patent_draft/api";
const documentId = process.argv[2];
const outPath = process.argv[3];
const outputMode = process.argv[4] || "file"; // "file" 或 "stdout"
if (!documentId) {
console.error("用法: node assemble.js <documentId> [输出md文件] [输出模式]");
console.error(" 输出模式: file (写文件,默认) | stdout (输出到标准输出)");
process.exit(1);
}
if (outputMode === "file" && !outPath) {
console.error("错误: 输出模式为 file 时必须指定输出文件路径");
process.exit(1);
}
const chatId = "patent-" + Date.now();
const body = JSON.stringify({ chatId, documentFileId: documentId });
// 调用 curl.exe,绕开 PowerShell 管道编码问题
const curl = spawn("curl.exe", [
"-sN",
"-X", "POST",
`${API_BASE}/patent/generate`,
"-H", "Content-Type: application/json",
"-d", body,
"--max-time", "900",
], {
stdio: ["ignore", "pipe", "pipe"],
});
let event = null;
let dataLines = [];
const full = [];
// 百分比进度映射:step → 预估完成百分比(与进度汇报规则保持一致)
const STEP_PERCENT = {
STEP_1: 5, // 校验交底材料中
STEP_1_PASS: 8, // 校验合格,开始生成
STEP_1_FAIL: 8, // 校验不合格,终止流程
STEP_2: 15, // 生成初稿中
STEP_2_DONE: 50, // 初稿已完成
STEP_3: 55, // 质检进行中
STEP_3_DONE: 70, // 质检完成
STEP_4: 75, // 修复终稿中
COMPLETE: 100, // 全部完成
};
function dispatch() {
if (dataLines.length === 0) { event = null; dataLines = []; return; }
const raw = dataLines.join("\n");
if (event === "message") {
try { full.push(JSON.parse(raw).delta || ""); } catch {}
} else if (event === "progress") {
try {
const d = JSON.parse(raw);
const pct = STEP_PERCENT[d.step];
if (pct !== undefined) {
console.log("[进度 %d%%] %s: %s", pct, d.step, d.message);
} else {
console.log("[进度] %s: %s", d.step, d.message);
}
} catch {}
} else if (event === "error") {
try { const d = JSON.parse(raw); console.log("[错误] %s: %s", d.step, d.message); } catch {}
}
event = null;
dataLines = [];
}
function processLine(line) {
if (line === "") { dispatch(); return; }
if (line.startsWith("event:")) event = line.slice("event:".length).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice("data:".length).replace(/^ /, ""));
}
function finish() {
dispatch();
const text = full.join("");
if (outputMode === "stdout") {
// 输出到 stdout(JSON 格式,不含进度消息)
console.log(JSON.stringify({ content: text, charCount: text.length }));
} else {
// 写入文件(原行为)
fs.writeFileSync(outPath, text, "utf8");
console.log("[完成] 已写入 %s,共 %d 字", outPath, text.length);
}
}
// 实时逐行解析 curl 的 stdout
const rl = readline.createInterface({ input: curl.stdout });
rl.on("line", processLine);
rl.on("close", finish);
// curl stderr 只保留网络错误等,转发到 stderr(不影响进度显示)
curl.stderr.on("data", (d) => process.stderr.write(d));
curl.on("close", (code) => {
if (code !== 0) {
console.log("[警告] curl 退出码: %d", code);
}
});
tools/package.json{
"name": "patent-svg-to-docx",
"version": "1.1.0",
"private": true,
"description": "把含内联 SVG 的专利 md 转成图片原位内嵌的 .docx",
"type": "commonjs",
"engines": { "node": ">=18" },
"dependencies": {
"@resvg/resvg-js": "^2.6.2",
"docx": "^9.0.0"
}
}
> ⚠️ @resvg/resvg-js 是 native addon,Windows 上需要已安装 Visual Studio Build Tools(含 C++ 工具链)。若 npm install 报 node-gyp 错误,先运行 npm install --global windows-build-tools 或安装 Visual Studio Build Tools。
tools/svg-to-docx.js#!/usr/bin/env node
/**
* 把含内联 SVG 的专利 Markdown 转成 .docx:
* - 文本按行 → docx 段落(已知标题行加粗/居中)
* - 每个 <svg>…</svg> → 在原位置渲染成高清 PNG 居中内嵌
*
* 用法: node svg-to-docx.js <输入.md> [输出.docx]
*/
const fs = require("fs");
const path = require("path");
const { Resvg } = require("@resvg/resvg-js");
const {
Document, Packer, Paragraph, TextRun, ImageRun, AlignmentType,
} = require("docx");
const SVG_ZOOM = 3; // 渲染倍率:原始尺寸 ×3 → 高清
const DISP_MAX_W = 450; // docx 中图片显示最大宽(px)
const DISP_MAX_H = 620; // docx 中图片显示最大高(px),避免溢出版心
const TOP_HEADINGS = new Set(["权利要求书", "说明书", "说明书摘要", "说明书附图"]);
const SUB_HEADINGS = new Set(["技术领域", "背景技术", "发明内容", "附图说明", "具体实施方式"]);
// 去掉 Markdown 代码块围栏(```svg / ```),提取纯 SVG
function stripCodeFences(md) {
return md.replace(/```svg\r?\n/g, "").replace(/\r?\n```/g, "");
}
function renderSvgToPng(svg) {
const r = new Resvg(svg, {
fitTo: { mode: "zoom", value: SVG_ZOOM },
background: "white",
}).render();
return { png: r.asPng(), width: r.width, height: r.height };
}
function displaySize(w, h) {
const ratio = h / w;
let dispW = Math.min(w, DISP_MAX_W);
let dispH = Math.round(dispW * ratio);
if (dispH > DISP_MAX_H) {
dispH = DISP_MAX_H;
dispW = Math.round(dispH / ratio);
}
return { width: dispW, height: dispH };
}
function textLineToParagraph(line) {
const t = line.trim();
if (t === "") return new Paragraph({ text: "" });
if (TOP_HEADINGS.has(t)) {
return new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 240, after: 120 },
children: [new TextRun({ text: t, bold: true, size: 32 })], // 16pt
});
}
if (SUB_HEADINGS.has(t)) {
return new Paragraph({
spacing: { before: 160, after: 80 },
children: [new TextRun({ text: t, bold: true, size: 26 })], // 13pt
});
}
if (/^图\s*\d+$/.test(t)) {
return new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 160, after: 40 },
children: [new TextRun({ text: t, bold: true })],
});
}
return new Paragraph({
spacing: { after: 60 },
children: [new TextRun({ text: line })],
});
}
function build(mdPath, outPath) {
const rawMd = fs.readFileSync(mdPath, "utf8");
const md = stripCodeFences(rawMd);
const svgRe = /<svg[\s\S]*?<\/svg>/g;
const children = [];
let figCount = 0;
let skipCount = 0;
let last = 0;
let m;
const pushText = (chunk) => {
chunk.split(/\r?\n/).forEach((line) => children.push(textLineToParagraph(line)));
};
while ((m = svgRe.exec(md)) !== null) {
if (m.index > last) pushText(md.slice(last, m.index));
try {
const { png, width, height } = renderSvgToPng(m[0]);
const disp = displaySize(width, height);
children.push(new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 40, after: 160 },
children: [new ImageRun({ type: "png", data: png, transformation: disp })],
}));
figCount += 1;
} catch (e) {
console.error("[警告] SVG 渲染失败,跳过: " + e.message);
skipCount++;
// 将原始 SVG 文本作为代码段落保留
pushText(m[0]);
}
last = svgRe.lastIndex;
}
if (last < md.length) pushText(md.slice(last));
if (skipCount > 0) {
console.error(`[提示] ${skipCount} 张图渲染失败已跳过`);
}
const doc = new Document({
styles: { default: { document: { run: { font: "宋体", size: 24 } } } }, // 12pt 宋体
sections: [{ children }],
});
return Packer.toBuffer(doc).then((buf) => {
fs.writeFileSync(outPath, buf);
return { figCount, skipCount };
});
}
async function main() {
const input = process.argv[2];
if (!input) {
console.error("用法: node svg-to-docx.js <输入.md> [输出.docx]");
process.exit(1);
}
const output = process.argv[3]
|| path.join(path.dirname(input), path.basename(input, path.extname(input)) + ".docx");
const { figCount, skipCount } = await build(input, output);
if (skipCount > 0) {
console.error(`[完成] ${figCount} 张图已高清内嵌,${skipCount} 张渲染失败已跳过 → ${output}`);
} else {
console.error(`[完成] ${figCount} 张图已高清内嵌 → ${output}`);
}
}
main().catch((e) => {
console.error("[错误] 转换失败:", e.message);
process.exit(1);
});
共 6 个版本